NestJS — Enterprise Node.js

Dynamic Modules & Provider Scopes

17 min Lesson 9 of 30

Dynamic Modules & Provider Scopes

So far modules have been static — fixed configurations. But how does ConfigModule.forRoot() accept options? That is a dynamic module: a module configured at import time. Paired with provider scopes, these are the tools that make NestJS modules reusable and flexible.

The forRoot / forFeature pattern

You have seen this everywhere: ConfigModule.forRoot(), TypeOrmModule.forRoot(), JwtModule.register(). Each is a static method returning a configured module on the fly.

import { Module, DynamicModule } from '@nestjs/common'; @Module({}) export class ConfigModule { static forRoot(options: { folder: string }): DynamicModule { return { module: ConfigModule, providers: [ { provide: 'CONFIG_OPTIONS', useValue: options }, ConfigService, ], exports: [ConfigService], }; } }

Now consumers pass options when importing:

@Module({ imports: [ConfigModule.forRoot({ folder: './config' })], }) export class AppModule {}
Convention: forRoot() configures a module once for the whole app (the root); forFeature() configures a slice for a specific feature module. register() is used when there is no global/feature distinction.

Provider scopes

By default every provider is a singleton — one instance shared app-wide. NestJS offers three scopes:

  • DEFAULT (singleton) — one shared instance. Fast, the right choice for almost everything.
  • REQUEST — a new instance per incoming request.
  • TRANSIENT — a fresh instance for every consumer that injects it.
import { Injectable, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.REQUEST }) export class RequestContextService { // a new instance is created for each request }

When you need REQUEST scope

Request scope is useful when a provider must hold data unique to the current request — the authenticated user, a per-request trace ID, or tenant info in a multi-tenant app. A request-scoped provider can even inject the request object itself:

import { Injectable, Scope, Inject } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @Injectable({ scope: Scope.REQUEST }) export class TenantService { constructor(@Inject(REQUEST) private readonly request: any) {} get tenantId() { return this.request.headers['x-tenant-id']; } }

The cost of request scope

Scope bubbles up — and has a performance cost. If a controller injects a request-scoped service, the controller itself becomes request-scoped, and so does anything it depends on. NestJS must then instantiate that whole chain on every request instead of once at startup. Use request scope only when you truly need per-request state.
Often there is a better alternative: pass request data as a method argument, or read it from an AsyncLocalStorage context, instead of making a whole service request-scoped. Keep singletons singletons whenever you can.

Summary

Dynamic modules (the forRoot/forFeature/register pattern) return a DynamicModule configured at import time, which is how configurable library modules work. Provider scopes control instance lifetime: DEFAULT singletons are the norm; REQUEST and TRANSIENT exist for per-request or per-consumer state, but they bubble up and cost performance. This completes Phase 2 — you now understand how NestJS wires and configures everything.