Building Configurable Dynamic Modules

Design your own forRoot / forRootAsync / forFeature module the way TypeOrmModule and JwtModule do it.

Every serious Nest library ships a dynamic module: you import it with a call like ThingModule.forRoot({...}) and the options flow into its providers. Learning to build one is a rite of passage, because it forces you to understand tokens, exports, and async configuration together.

The naming conventions

  • forRoot(options) - configure the module once, at the app root (a connection, a client).
  • forRootAsync(options) - same, but options are resolved asynchronously (read from ConfigService, a secret store, etc.).
  • forFeature(...) - register per-feature slices that depend on the root configuration (specific entities, queues, or repositories).

The core move: options become a provider

The trick is simple once you see it: your static method returns a DynamicModule whose providers array contains a useValue (or useFactory) provider carrying the options under a private token. Everything else in the module injects that token.

static forRoot(opts: MailerOptions): DynamicModule {
  return {
    module: MailerModule,
    providers: [
      { provide: MAILER_OPTIONS, useValue: opts },
      MailerService,
    ],
    exports: [MailerService],
  };
}

forRootAsync is where seniors are made

Real config lives in the environment, not in source. forRootAsync accepts a useFactory plus an inject array so your options can be built from ConfigService. The pattern below is exactly how JwtModule.registerAsync works, and once you can write it you can integrate almost any third-party client cleanly.

Global or not?

Add global: true to the returned object when the module should register its exports app-wide (typical for a config or logging module). Leave it off for feature libraries so imports stay explicit.

Example

Example · typescript
import {
  DynamicModule,
  Module,
  Provider,
  Injectable,
  Inject,
} from '@nestjs/common';

export interface MailerOptions {
  from: string;
  apiKey: string;
}
export interface MailerAsyncOptions {
  useFactory: (...args: any[]) => Promise<MailerOptions> | MailerOptions;
  inject?: any[];
  imports?: any[];
}

const MAILER_OPTIONS = Symbol('MAILER_OPTIONS');

@Injectable()
export class MailerService {
  constructor(@Inject(MAILER_OPTIONS) private readonly opts: MailerOptions) {}
  send(to: string, body: string) {
    return `from ${this.opts.from} to ${to}: ${body}`;
  }
}

@Module({})
export class MailerModule {
  static forRoot(options: MailerOptions): DynamicModule {
    return {
      module: MailerModule,
      global: true,
      providers: [{ provide: MAILER_OPTIONS, useValue: options }, MailerService],
      exports: [MailerService],
    };
  }

  static forRootAsync(options: MailerAsyncOptions): DynamicModule {
    const optionsProvider: Provider = {
      provide: MAILER_OPTIONS,
      useFactory: options.useFactory,
      inject: options.inject ?? [],
    };
    return {
      module: MailerModule,
      global: true,
      imports: options.imports ?? [],
      providers: [optionsProvider, MailerService],
      exports: [MailerService],
    };
  }
}

// Usage in AppModule:
// MailerModule.forRootAsync({
//   inject: [ConfigService],
//   useFactory: (cfg: ConfigService) => ({
//     from: cfg.getOrThrow('MAIL_FROM'),
//     apiKey: cfg.getOrThrow('MAIL_KEY'),
//   }),
// })

When to use it

  • A library author writes a SmsModule.register(options) so consumers pass their API key at import time, just like TypeOrmModule.forRoot() accepts connection options.
  • A developer writes SmsModule.registerAsync({ useFactory: (cfg) => ({ apiKey: cfg.get("SMS_KEY") }) }) so the key is loaded from ConfigService asynchronously.
  • A team creates a forFeature static method that registers entity-level providers (repositories) within a feature module, mirroring the TypeORM pattern.

More examples

Module with register pattern

Implements the forRoot/register pattern, returning a DynamicModule object that includes the options value as a named provider.

Example · ts
import { Module, DynamicModule } from '@nestjs/common';

export interface SmsOptions { apiKey: string; region: string; }

@Module({})
export class SmsModule {
  static register(options: SmsOptions): DynamicModule {
    return {
      module: SmsModule,
      providers: [
        { provide: 'SMS_OPTIONS', useValue: options },
        SmsService,
      ],
      exports: [SmsService],
    };
  }
}

Async registration with factory

Adds a registerAsync method so consumers can load options from ConfigService or any other async dependency via useFactory.

Example · ts
static registerAsync(options: {
  imports?: any[];
  inject?: any[];
  useFactory: (...args: any[]) => Promise<SmsOptions> | SmsOptions;
}): DynamicModule {
  return {
    module: SmsModule,
    imports: options.imports ?? [],
    providers: [
      {
        provide: 'SMS_OPTIONS',
        inject: options.inject ?? [],
        useFactory: options.useFactory,
      },
      SmsService,
    ],
    exports: [SmsService],
  };
}

Consuming the dynamic module

Calls registerAsync with ConfigService injection so the SMS credentials are read from environment variables, not hardcoded.

Example · ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SmsModule } from './sms/sms.module';

@Module({
  imports: [
    SmsModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) => ({
        apiKey: cfg.get('SMS_KEY'),
        region: cfg.get('SMS_REGION'),
      }),
    }),
  ],
})
export class AppModule {}

Discussion

  • Be the first to comment on this lesson.