What are Dynamic Modules in NestJS and when to use them?
Dynamic module in NestJS is a module that returns a customized DynamicModule object from a static method like forRoot(), letting you pass configuration at import time instead of hardcoding it.
Theory
TL;DR
- Think of a static module as a pre-built LEGO set. A dynamic one is a factory kit: you send instructions, you get a set tailored to your build.
- Static modules export a fixed
@Module({})decorator with the same providers every time. Dynamic modules generate that metadata on demand based on the options you pass in. - Use dynamic if config varies per app or environment (DB credentials, JWT secret). Stick to static for shared logic that never needs configuration.
- Standard naming:
forRoot()for global one-time setup,forFeature()for per-module customization,forRootAsync()when options come from another service. - The return object must always include a
modulekey. Forgetting it breaks the DI graph in a way that is surprisingly hard to trace.
Quick example
// Static: always the same providers
@Module({ providers: [LoggerService] })
export class LoggerModule {}
// Dynamic: providers change based on what you pass in
@Module({})
export class LoggerModule {
static forRoot(level: 'error' | 'debug' | 'verbose'): DynamicModule {
return {
module: LoggerModule,
providers: [
{ provide: 'LOG_LEVEL', useValue: level }, // value injected from outside
LoggerService,
],
exports: [LoggerService],
};
}
}
// Usage
@Module({
imports: [LoggerModule.forRoot('debug')],
})
export class AppModule {}LoggerModule.forRoot('debug') runs at import time and returns a plain object that NestJS treats as module metadata. No magic. The LOG_LEVEL token is now available for injection anywhere in the module.
Static vs dynamic modules
Static modules export a fixed @Module({}) decorator with hardcoded providers. Every import gets the identical setup, which works fine for a shared utility service with no configuration. Dynamic modules call a static method that returns a DynamicModule object, injecting options at registration time. This lets you create different configurations of the same module in different parts of the app, which solves shared-state problems in multi-tenant or configurable libraries.
One practical consequence: with static modules, there is no clean place to put DB credentials. You either hardcode them or reach for globals. Dynamic modules give you a proper API for that.
When to use
- Config differs per app or environment:
forRoot()with an options object (host, port, credentials) - Options come from another service like
ConfigService:forRootAsync()with a factory function and aninjectarray - Per-module entity or queue setup:
forFeature(), for exampleTypeOrmModule.forFeature([User]) - Shared service with no configuration needed: a static module is enough
- Testing with mock configuration: dynamic modules let you pass fake options without touching source code
How NestJS processes dynamic modules
When NestModuleLoader.create() runs during bootstrap, it scans every entry in imports[]. If the entry is a function call like LoggerModule.forRoot('debug'), that call executes synchronously and returns a plain DynamicModule object. NestJS then merges that object's providers, imports, and exports into the global metadata graph stored in ModulesContainer. All of this happens before any providers resolve. After bootstrap, there is no additional overhead as providers resolve lazily through the DI container.
forRootAsync() works differently. NestJS registers the useFactory provider normally but delays calling the factory until all dependencies listed in inject are available.
Common mistakes
Forgetting module in the return object
// Wrong
static forRoot(opts: Options) {
return { providers: [{ provide: 'CONFIG', useValue: opts }] }; // no module key
}
// Right
static forRoot(opts: Options): DynamicModule {
return {
module: MyModule, // required
providers: [{ provide: 'CONFIG', useValue: opts }],
exports: ['CONFIG'],
};
}NestJS cannot register metadata without the module key. The error message ("Nest can't resolve dependencies") typically points somewhere else, which makes this one of those bugs that wastes 20 minutes because the stack trace never points directly at the cause.
Mutating the options object
// Wrong
static forRoot(opts: Options) {
opts.host = 'overridden'; // mutates the caller's object
return { module: MyModule, providers: [{ provide: 'CONFIG', useValue: opts }] };
}
// Right
static forRoot(opts: Options): DynamicModule {
return {
module: MyModule,
providers: [{ provide: 'CONFIG', useValue: { ...opts } }], // spread or deep clone
};
}If two parts of the app call forRoot() with different options, mutation causes them to interfere with each other. Spread or deep-clone the options before using them.
Putting exports in the class decorator instead of the return object
// Wrong
@Module({ exports: [Service] }) // fires even without forRoot being called
export class MyModule {
static forRoot(): DynamicModule {
return { module: MyModule }; // no exports here
}
}
// Right
@Module({})
export class MyModule {
static forRoot(): DynamicModule {
return { module: MyModule, providers: [Service], exports: [Service] };
}
}The class-level decorator runs unconditionally. Modules that never call forRoot() still get those exports, which leads to subtle bugs in multi-tenant setups.
Using forRoot inside feature modules
forRoot is for global root configuration, called once in AppModule. Calling it inside a feature module creates duplicate providers in the DI container. Feature modules should use forFeature() instead.
Forgetting inject in forRootAsync
// Wrong - config is undefined at runtime
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => config.getDbOptions(),
// inject is missing
});
// Right
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => config.getDbOptions(),
inject: [ConfigService], // required, must match factory parameter order
});This is the most common mistake among developers new to async configuration. The factory receives undefined arguments and throws at runtime. TypeScript won't catch it at compile time.
Real-world usage
@nestjs/typeorm:TypeOrmModule.forRoot({ host, entities })in AppModule,TypeOrmModule.forFeature([User])in each feature module@nestjs/jwt:JwtModule.register({ secret })for auth token signing@nestjs/bull:BullModule.forRoot({ redis })for queue setup,BullModule.forFeature(Processor)per job type@nestjs/config:ConfigModule.forRoot()loads.envglobally@nestjs/mongoose:MongooseModule.forRoot('mongodb://...'), thenMongooseModule.forFeature([Cat])per module
Every major NestJS integration library follows this pattern. Once you understand how dynamic modules work, reading the source of any of these becomes straightforward.
Follow-up questions
Q: What is the return type of forRoot() and what fields does it accept?
A: It returns the DynamicModule interface: { module: Type<any>, providers?: Provider[], imports?: any[], exports?: any[], global?: boolean }. The module field is the only required one.
Q: What is the difference between forRoot() and forRootAsync()?
A: forRoot() takes synchronous options directly as arguments. forRootAsync() takes a useFactory function with an inject array, so NestJS resolves the listed dependencies first and passes them into the factory.
Q: How does NestJS handle two forRoot() calls for the same module?
A: It merges providers into a single module instance. Set global: true for a shared global configuration. If you need isolated instances per module, use forFeature() instead.
Q: Can a dynamic module include other dynamic modules in its imports?
A: Yes. You can nest forFeature() calls inside a forRoot() return's imports array. This is exactly how TypeORM chains entity repositories inside the root connection module.
Q: (Senior) What happens if forRoot() throws during bootstrap, and how do you debug it?
A: The app fails to start with a bootstrap exception. Run nest start --debug and trace the stack from ModulesContainer.register(). Add a console.log before the return statement in your dynamic method to inspect what object NestJS actually receives. Circular imports between modules that both use forRootAsync are another common cause of silent bootstrap failures.
Q: When would you skip dynamic modules entirely and use a plain factory provider?
A: For one-off setups that are not meant to be reused across projects. If you have one app and one DB, a useFactory provider directly in AppModule is simpler and completely valid. Dynamic modules add API design overhead that only pays off when the module is shared or reused.
Examples
Basic: configurable logger module
import { Module, DynamicModule } from '@nestjs/common';
type LogLevel = 'error' | 'debug' | 'verbose';
@Module({})
export class LoggerModule {
static forRoot(level: LogLevel): DynamicModule {
return {
module: LoggerModule,
providers: [
{ provide: 'LOG_LEVEL', useValue: level },
LoggerService, // uses @Inject('LOG_LEVEL') in its constructor
],
exports: [LoggerService],
};
}
}
@Module({
imports: [LoggerModule.forRoot('debug')],
})
export class AppModule {}LoggerService receives the 'debug' level through its constructor via @Inject('LOG_LEVEL'). Change the argument to 'error' in production without touching any service code.
Intermediate: database module with async config
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({})
export class DatabaseModule {
static forRootAsync(options: {
imports?: any[];
useFactory: (...args: any[]) => Promise<{ host: string; port: number }>;
inject?: any[];
}): DynamicModule {
return {
module: DatabaseModule,
imports: options.imports || [],
providers: [
{
provide: 'DB_CONFIG',
useFactory: options.useFactory,
inject: options.inject || [],
},
DatabaseService,
],
exports: [DatabaseService],
};
}
}
// Usage in AppModule
@Module({
imports: [
ConfigModule.forRoot(),
DatabaseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
host: config.get<string>('DB_HOST'),
port: config.get<number>('DB_PORT'),
}),
inject: [ConfigService], // must match useFactory parameter order
}),
],
})
export class AppModule {}DB_CONFIG resolves only after ConfigService is ready. Remove the inject array and config becomes undefined at runtime. TypeScript does not catch this because the factory signature accepts any[].
Advanced: forFeature for per-module storage buckets
@Module({})
export class StorageModule {
static forRoot(options: { provider: string; region: string }): DynamicModule {
return {
module: StorageModule,
global: true, // one shared StorageService across the app
providers: [
{ provide: 'STORAGE_OPTIONS', useValue: { ...options } },
StorageService,
],
exports: [StorageService],
};
}
static forFeature(bucket: string): DynamicModule {
return {
module: StorageModule,
providers: [
{
provide: `STORAGE_BUCKET_${bucket.toUpperCase()}`,
useFactory: (storage: StorageService) => storage.getBucket(bucket),
inject: [StorageService], // pulls from global StorageService
},
],
exports: [`STORAGE_BUCKET_${bucket.toUpperCase()}`],
};
}
}
// Each feature module gets its own scoped bucket client
@Module({
imports: [StorageModule.forFeature('avatars')],
})
export class UsersModule {}
@Module({
imports: [StorageModule.forFeature('documents')],
})
export class DocumentsModule {}StorageService is registered globally once via forRoot. Each forFeature call creates a scoped provider that wraps the shared service with a specific bucket name. The underlying connection is not duplicated.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.