Dynamic Module Pattern
A static @Module declares a fixed set of providers and imports at class-definition time. That is fine for application code, but it breaks down the moment you want to ship a reusable module — a database client, an HTTP wrapper, a feature toggle service — that consumers must configure with their own credentials, URLs, or options. The dynamic module pattern solves this: a module exposes static methods (by convention forRoot, forRootAsync, and forFeature) that return a DynamicModule object computed from caller-supplied options. This is how @nestjs/config, TypeOrmModule, JwtModule, and most of the ecosystem ship configurable libraries.
Why dynamic modules exist
A DynamicModule is just a plain object that carries the same metadata you would normally put in @Module, plus a module key pointing back at the host class. Because it is produced by a method call, you can fold runtime options into the providers — typically by registering an extra “options” provider that the module’s own services inject.
| Convention | Returns | Typical use |
|---|---|---|
forRoot(options) | DynamicModule | Configure a module once, globally (connections, secrets) |
forRootAsync(options) | DynamicModule | Same, but options resolved asynchronously via DI |
forFeature(options) | DynamicModule | Register scoped, per-feature resources (entities, repositories) |
register(options) | DynamicModule | Generic name when the root/feature split doesn’t apply |
The names are conventions, not framework keywords. Nest treats any static method returning a
DynamicModuleidentically — but following the convention is what makes your library feel native to other Nest developers.
A configurable module with forRoot
Let’s build a small StorageModule that wraps an object-store client. Consumers pass a bucket and region; the module turns those into an injectable STORAGE_OPTIONS token and a service that reads it.
// storage.constants.ts
export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';
export interface StorageOptions {
bucket: string;
region: string;
isGlobal?: boolean;
}
// storage.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
@Injectable()
export class StorageService {
constructor(
@Inject(STORAGE_OPTIONS) private readonly options: StorageOptions,
) {}
describe(): string {
return `bucket=${this.options.bucket} region=${this.options.region}`;
}
}
// storage.module.ts
import { DynamicModule, Module } 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: options.isGlobal ?? false,
providers: [
{ provide: STORAGE_OPTIONS, useValue: options },
StorageService,
],
exports: [StorageService],
};
}
}
A consumer wires it up in their root module:
// app.module.ts
import { Module } from '@nestjs/common';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
StorageModule.forRoot({ bucket: 'app-uploads', region: 'eu-west-1' }),
],
})
export class AppModule {}
Output:
StorageService.describe() -> bucket=app-uploads region=eu-west-1
Async configuration with forRootAsync
Hard-coded options rarely survive contact with production. Usually you need values from ConfigService, a secrets manager, or another async source. forRootAsync accepts a useFactory that runs through DI, so it can inject other providers.
// storage.module.ts (additional method)
import { DynamicModule, Module, Provider } from '@nestjs/common';
export interface StorageAsyncOptions {
imports?: any[];
inject?: any[];
useFactory: (...args: any[]) => Promise<StorageOptions> | StorageOptions;
}
@Module({})
export class StorageModule {
static forRootAsync(asyncOptions: StorageAsyncOptions): DynamicModule {
const optionsProvider: Provider = {
provide: STORAGE_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject ?? [],
};
return {
module: StorageModule,
imports: asyncOptions.imports ?? [],
providers: [optionsProvider, StorageService],
exports: [StorageService],
};
}
}
// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot(),
StorageModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
bucket: config.getOrThrow<string>('STORAGE_BUCKET'),
region: config.getOrThrow<string>('STORAGE_REGION'),
}),
}),
],
})
export class AppModule {}
Because the factory is a real provider, Nest resolves its inject dependencies first, awaits the (possibly async) result, and binds it to STORAGE_OPTIONS before any service that needs it is instantiated.
Reducing boilerplate with ConfigurableModuleBuilder
Hand-writing forRoot and forRootAsync for every library is repetitive. Nest ships ConfigurableModuleBuilder to generate both, plus the options token and a typed base class.
// 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')
.setExtras({ isGlobal: false }, (def, extras) => ({
...def,
global: extras.isGlobal,
}))
.build();
// storage.module.ts
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 {}
Inject MODULE_OPTIONS_TOKEN in StorageService instead of a hand-rolled string. You get both StorageModule.forRoot(...) and StorageModule.forRootAsync(...) for free, fully typed.
forFeature for scoped registration
forRoot configures the module once; forFeature registers per-feature resources and is meant to be imported in many feature modules. It should not re-declare the global connection — only the feature-scoped providers.
static forFeature(names: string[]): DynamicModule {
const providers: Provider[] = names.map((name) => ({
provide: `BUCKET_${name}`,
useFactory: (svc: StorageService) => svc.scope(name),
inject: [StorageService],
}));
return {
module: StorageModule,
providers,
exports: providers,
};
}
Best Practices
- Follow the ecosystem naming conventions (
forRoot,forRootAsync,forFeature) so your module behaves predictably for other developers. - Pass options through a dedicated injection token (a
Symbolorstringconstant), never by reading globals — this keeps the module testable and DI-friendly. - Provide both sync and async variants; production deployments almost always need
forRootAsyncto read configuration through DI. - Use
global: truesparingly — only for cross-cutting infrastructure (logging, config). Prefer explicit imports for everything else. - Prefer
ConfigurableModuleBuilderover hand-written factory methods to eliminate boilerplate and get consistent typing. - Keep
forRoot(one-time setup) andforFeature(per-feature resources) responsibilities clearly separated.