Dynamic modules: forRoot, forRootAsync, forFeature
A DynamicModule returns its definition at runtime. forRoot configures once, forRootAsync pulls options from DI (ConfigService), forFeature registers per-consumer. Build one with an options token; ConfigurableModuleBuilder generates the boilerplate.
You’re extracting your S3 upload code into a shared StorageModule so three services can reuse it. The first consumer hardcodes the bucket and region, ships, works. The second consumer needs a different bucket per environment — so its values live in ConfigService, which is populated from .env at startup. Now you’re stuck: the module needs the bucket name to construct its S3Client provider, but the bucket name lives in a provider that doesn’t exist until after modules are wired. You can’t @Inject(ConfigService) into a @Module() decorator — that’s a compile-time annotation, and config is a runtime value. This chicken-and-egg is exactly the problem forRootAsync exists to solve, and TypeOrmModule.forRootAsync, JwtModule.registerAsync, and ConfigModule.forRoot are all the same three lines under the hood.
A DynamicModule is a module computed at runtime
When you import TypeOrmModule.forRoot(dbConfig) or JwtModule.register({ secret }), you’re not calling magic NestJS internals — you’re calling a static method that returns a plain object. Understanding that object is what lets you build reusable modules instead of copying boilerplate.
A normal @Module({...}) is static: its provider list is fixed when you write it. A dynamic module is a module whose definition is returned by a method call at runtime. The contract is a plain object — the DynamicModule interface — with these fields:
import { DynamicModule } from '@nestjs/common';
interface DynamicModule {
module: Type<any>; // required: the host class
imports?: any[]; // modules this dynamic module needs
providers?: Provider[]; // providers to create, possibly built from options
exports?: any[]; // what consumers can inject
global?: boolean; // register once, available everywhere
controllers?: Type<any>[];
}That one runtime object is the entire mechanism behind every configurable library you import. TypeOrmModule.forRoot(dbConfig), JwtModule.register({ secret }), ConfigModule.forRoot() — each is a static method that returns this shape, baking your options into the providers array before Nest ever instantiates them. The convention is the method name: forRoot for global once-per-app configuration, register for per-import configuration, forFeature for per-consumer extensions.
The base pattern for building one yourself: define an options token, provide the options value from the static method, and have inner providers @Inject that token. The token is the seam — it lets a provider depend on options that don’t exist until the consumer calls forRoot.
// storage.constants.ts
export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';
export interface StorageOptions {
bucket: string;
region: string;
}// storage.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
@Injectable()
export class StorageService {
constructor(@Inject(STORAGE_OPTIONS) private readonly opts: StorageOptions) {}
// this.opts.bucket / this.opts.region are now available
}forRoot vs forRootAsync: when options come from DI
forRoot(options) is synchronous: the caller passes a literal options object, the static method wraps it in a useValue provider, done. Use it for configure-once, app-wide singletons — a database connection, an HTTP client, an auth secret — typically with global: true so every module can inject the exports without re-importing.
// storage.module.ts — synchronous forRoot
import { Module, DynamicModule } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
import { StorageService } from './storage.service';
@Module({})
export class StorageModule {
static forRoot(options: StorageOptions): DynamicModule {
return {
module: StorageModule,
global: true,
providers: [
{ provide: STORAGE_OPTIONS, useValue: options }, // options baked in as a value
StorageService,
],
exports: [StorageService],
};
}
}forRootAsync({ imports, useFactory, inject }) is for when the options themselves must be produced by another provider. You can’t pass a literal object because the values live in ConfigService, a secrets manager, or another async source. Instead of a useValue, the options token is provided by a factory that Nest resolves through DI — which is how you break the chicken-and-egg: the factory runs after its inject dependencies are constructed.
// storage.module.ts — asynchronous forRootAsync
import { Module, DynamicModule, Provider } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
import { StorageService } from './storage.service';
interface StorageAsyncOptions {
imports?: any[];
inject?: any[];
useFactory: (...args: any[]) => Promise<StorageOptions> | StorageOptions;
}
@Module({})
export class StorageModule {
static forRootAsync(options: StorageAsyncOptions): DynamicModule {
const optionsProvider: Provider = {
provide: STORAGE_OPTIONS,
useFactory: options.useFactory, // runs AFTER inject deps are ready
inject: options.inject || [],
};
return {
module: StorageModule,
global: true,
imports: options.imports || [], // e.g. ConfigModule, so ConfigService is injectable
providers: [optionsProvider, StorageService],
exports: [StorageService],
};
}
}// app.module.ts — wiring it with ConfigService
@Module({
imports: [
ConfigModule.forRoot(),
StorageModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
bucket: config.getOrThrow('S3_BUCKET'),
region: config.getOrThrow('AWS_REGION'),
}),
}),
],
})
export class AppModule {}forFeature: per-consumer registration
forRoot/forRootAsync configure the module once for the whole app — the connection, the singletons, the global state. forFeature(...) is the second tier: it registers something scoped to the importing module only, layered on top of an existing root configuration. The textbook example is TypeORM: TypeOrmModule.forRoot(dbConfig) opens the one connection in your root module; TypeOrmModule.forFeature([User]) then registers the User repository for that feature module’s injector, so UsersModule can inject Repository<User> and OrdersModule can inject Repository<Order> without either seeing the other’s repositories.
@Module({
imports: [TypeOrmModule.forFeature([User])], // User repo, scoped to UsersModule
providers: [UsersService],
})
export class UsersModule {}The split is the senior judgment: configure-once-globally → forRoot; register-per-feature → forFeature. A root call that you accidentally invoke twice creates duplicate singletons (two connection pools, two clients) — connection configuration must be a forRoot imported exactly once. A forFeature call, by contrast, is meant to appear in many modules, each registering its own slice.
ConfigurableModuleBuilder: stop writing the boilerplate
Hand-writing both forRoot and forRootAsync (plus the useClass/useExisting/useFactory variants of async) is repetitive and easy to get subtly wrong. Since Nest 9, ConfigurableModuleBuilder generates all of it. You give it your options interface; it hands back a ConfigurableModuleClass to extend and the MODULE_OPTIONS_TOKEN to inject.
// storage.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { StorageOptions } from './storage.constants';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<StorageOptions>()
.setClassMethodName('forRoot') // -> generates forRoot AND forRootAsync
.build();// storage.module.ts — now trivially small
import { Module } from '@nestjs/common';
import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './storage.module-definition';
import { StorageService } from './storage.service';
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule extends ConfigurableModuleClass {}setClassMethodName('forRoot') makes the generated methods forRoot/forRootAsync (the default is register/registerAsync); .setExtras() lets you add fields like isGlobal that aren’t part of the options object. MODULE_OPTIONS_TOKEN is the same options token from the manual pattern — your StorageService injects it with @Inject(MODULE_OPTIONS_TOKEN), and the builder wires both the sync and async paths to it.
| Method | Options source | Sync / async | Scope | Typical use |
|---|---|---|---|---|
| forRoot | Literal object passed by caller | Synchronous (useValue) | Once, app-wide (often global) | Connection / client / secret singleton |
| forRootAsync | A provider via useFactory + inject | Asynchronous (factory) | Once, app-wide (often global) | Options that come from ConfigService / async |
| forFeature | Per-import arguments (e.g. entities) | Synchronous | Per importing module | Repositories / feature-scoped providers |
| plain @Module | None — fixed at author time | N/A | Wherever imported | No external configuration needed |
▸Why this works
Why can’t you just inject ConfigService into the @Module() decorator and skip forRootAsync? Because the decorator’s metadata is evaluated at class-definition time — when the file is first loaded — long before the DI container has constructed anything. ConfigService is a runtime instance that only exists after ConfigModule initializes from .env. The factory in forRootAsync is the deferral mechanism: it’s a function, not a value, so Nest can hold it until the inject dependencies are built, then call it. That single layer of indirection — value becomes factory — is the whole reason async registration exists.
You're building a reusable StorageModule. The bucket and region differ per environment and are read from .env via ConfigService at startup. How should consumers configure it?
Why does forRootAsync use a useFactory with an inject array instead of a plain options object?
TypeOrmModule.forRoot(dbConfig) is in your root module. Where does TypeOrmModule.forFeature([User]) belong, and why?
- 01What is a DynamicModule, what fields does it return, and how does the options-token pattern let an inner provider depend on caller-supplied configuration?
- 02Distinguish forRoot, forRootAsync, and forFeature — when is each correct, and when is async mandatory rather than optional?
A dynamic module is a module computed at runtime: a static method returns the DynamicModule object — module, imports, providers, exports, and optionally global — and that one object is the mechanism behind every configurable library you import (TypeOrmModule, JwtModule, ConfigModule). You build one with the options-token pattern: define a token, have the static method provide the options bound to it, and have inner providers @Inject that token. forRoot(options) is synchronous and bakes a literal object into a useValue provider — right for configure-once, app-wide singletons, often global: true. forRootAsync({ imports, useFactory, inject }) provides the same token via a factory Nest resolves through DI, which is the only way to feed in options that come from another provider like ConfigService — it defers option creation until the inject dependencies exist, solving the chicken-and-egg of needing config before the module is constructed. forFeature(…) is the per-consumer tier, registering something scoped to the importing module (TypeOrmModule.forFeature([User]) gives that module its repository). The senior split is configure-once-globally (forRoot, run exactly once or you get duplicate singletons) versus register-per-feature (forFeature, repeated across modules). ConfigurableModuleBuilder generates the whole forRoot/forRootAsync/MODULE_OPTIONS_TOKEN boilerplate so you extend ConfigurableModuleClass and inject the token. Now when you see TypeOrmModule.forRootAsync or JwtModule.registerAsync in a codebase and need to wire ConfigService into them, you know exactly what’s happening under the hood — and how to build the same pattern yourself.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.