Skip to content
NestJS ns patterns 4 min read

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.

ConventionReturnsTypical use
forRoot(options)DynamicModuleConfigure a module once, globally (connections, secrets)
forRootAsync(options)DynamicModuleSame, but options resolved asynchronously via DI
forFeature(options)DynamicModuleRegister scoped, per-feature resources (entities, repositories)
register(options)DynamicModuleGeneric name when the root/feature split doesn’t apply

The names are conventions, not framework keywords. Nest treats any static method returning a DynamicModule identically — 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 Symbol or string constant), never by reading globals — this keeps the module testable and DI-friendly.
  • Provide both sync and async variants; production deployments almost always need forRootAsync to read configuration through DI.
  • Use global: true sparingly — only for cross-cutting infrastructure (logging, config). Prefer explicit imports for everything else.
  • Prefer ConfigurableModuleBuilder over hand-written factory methods to eliminate boilerplate and get consistent typing.
  • Keep forRoot (one-time setup) and forFeature (per-feature resources) responsibilities clearly separated.
Last updated June 14, 2026
Was this helpful?