What a NestJS dynamic module actually is

A dynamic module is a plain object that matches the DynamicModule interface, returned from a static method on a module class. It carries the same keys as @Module metadata (providers, exports, imports) plus a required module property that points back to the class, and an optional global flag. When Nest compiles the module graph, it merges this object with the static metadata on the decorator. Providers declared in @Module are always present. Those returned at runtime are added on top, so the decorator holds what never changes and the static method supplies what depends on the caller.

The pattern exists for per-call-site configuration. A static module is identical everywhere it is imported. A dynamic module lets each importer pass its own values: a connection string, a queue name, a retry policy. Those values become providers inside that module instance. Think of it as a contract. The library defines what options it accepts and what it exports. The consumer supplies options and gets configured services back without touching the library's internals.

A minimal hand-written version is a CacheModule with static forRoot(options). It returns { module: CacheModule, providers: [{ provide: CACHE_OPTIONS, useValue: options }, CacheService], exports: [CacheService] }. CacheService then injects CACHE_OPTIONS through its constructor.

Authors of shared or internal library modules benefit most, especially when several apps or teams consume the same module with different settings.

import { DynamicModule, Inject, Injectable, Module } from '@nestjs/common';

// Token consumers never touch directly; the library owns it.
export const CACHE_OPTIONS = 'CACHE_OPTIONS';

export interface CacheModuleOptions {
  ttlSeconds: number;
  keyPrefix: string;
}

@Injectable()
export class CacheService {
  // Options arrive via DI, supplied by whichever app called forRoot().
  constructor(@Inject(CACHE_OPTIONS) private readonly options: CacheModuleOptions) {}

  key(name: string): string {
    return `${this.options.keyPrefix}:${name}`;
  }
}

// Static metadata: nothing here depends on the caller.
@Module({})
export class CacheModule {
  static forRoot(options: CacheModuleOptions): DynamicModule {
    return {
      module: CacheModule, // required: points back to this class
      providers: [
        { provide: CACHE_OPTIONS, useValue: options }, // per-call-site config
        CacheService,
      ],
      exports: [CacheService], // the public side of the contract
    };
  }
}

// Consumer: passes its own values, receives a configured CacheService.
@Module({
  imports: [CacheModule.forRoot({ ttlSeconds: 300, keyPrefix: 'orders-api' })],
})
export class AppModule {}

A hand-written CacheModule.forRoot() that returns a DynamicModule, registering the caller's options under an injection token so CacheService can inject them while the module exports only the service.

forRoot, forFeature and register: what the names signal

Once a module has more than one consumer, the static method you expose becomes part of its API. Nest's own packages follow a naming pattern that tells consumers how often to call a method and what it sets up.

forRoot (and forRootAsync) configures a module once, usually in the root AppModule. It typically creates something expensive or singular, such as a connection pool, a client, or a parsed configuration object, and often exports it globally so the rest of the app can depend on it. forFeature is called in each feature module and registers the pieces that module needs, such as repositories for specific entities. It assumes the root has already been configured and reuses its shared state. register (and registerAsync) configures a fresh instance for each import, with no shared root. Two modules can register the same module with different options and won't interfere with each other.

Method When it is called Where it is imported Holds shared state Typical examples
forRoot / forRootAsync Once per application Root module Yes, often global TypeOrmModule.forRoot, ConfigModule.forRoot
forFeature Once per consuming module Feature modules Reads root state TypeOrmModule.forFeature
register / registerAsync Once per import Any module No, scoped to the importer HttpModule.register, JwtModule.register

The framework enforces none of this. Nothing stops forRoot from being imported twice, and nothing makes register avoid globals. These names only signal intent to whoever reads the imports, so pick the name that matches what your method actually does.

How module options flow: injection tokens, validation and async configuration

Whatever the entry point is called, the options end up in the same place: a provider registered under an injection token. The token can be a string, a symbol, or a constant generated for you, and services read it with @Inject(TOKEN). Validate options where they are first consumed, in the options provider itself or in the constructor of the service that injects them. A thrown error there aborts application bootstrap, so a missing API key fails the deploy instead of the first request.

The async variant exists because real configuration is rarely a literal. Secrets come from a vault, environment-specific values from a config service, and both resolve only after other providers exist. useFactory takes a function plus an inject array and an imports array, so the factory can receive ConfigService and build the options from it. useClass and useExisting point to a class that implements an options-factory interface, which exposes one method that returns the options. The difference is that useClass instantiates a new instance and useExisting reuses a provider that is already registered.

ConfigModule (imported)
   └─ ConfigService
        └─ useFactory(config) ──► OPTIONS_TOKEN
                                     └─ @Inject(OPTIONS_TOKEN) ──► YourService

Trade-offs:

  • String tokens can collide. Two libraries that both use 'OPTIONS' will silently overwrite each other. Symbols and generated constants avoid this.
  • Async options add a dependency chain. If an inject entry is missing or ConfigModule is absent from imports, Nest reports that it cannot resolve a dependency of the factory provider. The error names your module's internals, not the config line you forgot.
import { DynamicModule, Inject, Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';

export interface PaymentsOptions { apiKey: string; timeoutMs: number; }

// A symbol token cannot collide with another library's 'OPTIONS' string.
export const PAYMENTS_OPTIONS = Symbol('PAYMENTS_OPTIONS');

@Injectable()
export class PaymentsService {
  // The service reads already-validated options from the token.
  constructor(@Inject(PAYMENTS_OPTIONS) private readonly options: PaymentsOptions) {}
}

@Module({})
export class PaymentsModule {
  static forRootAsync(): DynamicModule {
    return {
      module: PaymentsModule,
      imports: [ConfigModule], // without this, Nest cannot resolve ConfigService for the factory
      providers: [
        {
          provide: PAYMENTS_OPTIONS,
          inject: [ConfigService], // positional: becomes the factory's first argument
          useFactory: (config: ConfigService): PaymentsOptions => {
            const apiKey = config.get<string>('PAYMENTS_API_KEY');
            // Throwing here aborts bootstrap, so a missing key fails the deploy.
            if (!apiKey) throw new Error('PAYMENTS_API_KEY is not set');
            const timeoutMs = Number(config.get<string>('PAYMENTS_TIMEOUT_MS') ?? '5000');
            if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
              throw new Error('PAYMENTS_TIMEOUT_MS must be a positive number');
            }
            return { apiKey, timeoutMs };
          },
        },
        PaymentsService,
      ],
      exports: [PaymentsService],
    };
  }
}

A forRootAsync module that builds options from ConfigService through useFactory, registers them under a symbol token, validates them inside the factory so bad config fails at bootstrap, and injects them into a service with @Inject.

Implementing the module with ConfigurableModuleBuilder

Most of that wiring is boilerplate, and ConfigurableModuleBuilder generates it for you. Start with a plain options interface, then call new ConfigurableModuleBuilder<YourOptions>() and destructure the result of .build(). You get ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, and the type helpers OPTIONS_TYPE and ASYNC_OPTIONS_TYPE. Your module class extends ConfigurableModuleClass, and services inject options with @Inject(MODULE_OPTIONS_TOKEN). Because the token comes from the builder rather than a hand-written string, a typo can't split consumers and providers across two tokens.

By default the generated methods are register and registerAsync. If the module holds one shared client, call setClassMethodName('forRoot') so the API reads forRoot and forRootAsync, matching the convention from section 2. setFactoryMethodName changes the method Nest calls on a useClass options factory. The default is create. Rename it when a descriptive name such as createCacheOptions reads better, or when one config class serves several modules and each needs its own method.

Some inputs shape the module rather than its configuration. setExtras declares those flags, such as isGlobal, with defaults and a callback that maps them onto the DynamicModule, for example by setting global. Extras are stripped before the options reach MODULE_OPTIONS_TOKEN, so services never see them.

To add providers or exports that depend on options, override the static method, call super.forRoot(options), and extend the returned definition.

Builder details have changed between releases, so check the documentation for the NestJS version you run.

import { ConfigurableModuleBuilder, DynamicModule, Inject, Injectable, Module } from '@nestjs/common';

export interface CacheModuleOptions {
  host: string;
  port: number;
  ttlSeconds: number;
  enableMetrics?: boolean;
}

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } =
  new ConfigurableModuleBuilder<CacheModuleOptions>()
    .setClassMethodName('forRoot') // generates forRoot / forRootAsync instead of register / registerAsync
    .setFactoryMethodName('createCacheOptions') // useClass factories implement createCacheOptions(), not create()
    // isGlobal shapes the DynamicModule and is stripped before options reach MODULE_OPTIONS_TOKEN
    .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
    .build();

@Injectable()
export class CacheService {
  constructor(@Inject(MODULE_OPTIONS_TOKEN) private readonly options: CacheModuleOptions) {}
}

@Injectable()
export class CacheMetricsService {}

@Module({ providers: [CacheService], exports: [CacheService] })
export class CacheModule extends ConfigurableModuleClass {
  // OPTIONS_TYPE = CacheModuleOptions plus the isGlobal extra
  static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
    const base = super.forRoot(options);
    if (!options.enableMetrics) return base;
    // Opt-in provider and export derived from the options object
    return {
      ...base,
      providers: [...(base.providers ?? []), CacheMetricsService],
      exports: [...(base.exports ?? []), CacheMetricsService],
    };
  }
}
// Usage: CacheModule.forRoot({ host: 'cache.internal', port: 6379, ttlSeconds: 60, isGlobal: true })

A cache module built with ConfigurableModuleBuilder that renames register to forRoot, sets a custom useClass factory method name, adds an isGlobal extra, and overrides forRoot with a super call to add a metrics provider when enabled.

Adding forFeature alongside the generated root

The builder generates one configuration method shape, plus its async counterpart. You choose that shape's name with setClassMethodName, usually forRoot. It cannot also emit a forFeature with a different signature, so that method stays hand-written: a static method on the same class that returns a DynamicModule.

A typical forFeature takes a list of resource names and maps each one to a provider. Every provider needs a token that consumers can reproduce, so expose a helper such as getClientToken(name) that builds the token from a fixed prefix and the name. Consumers then write @Inject(getClientToken('billing')). Neither side has to agree on a raw string.

Each feature provider should be a factory that injects the builder's MODULE_OPTIONS_TOKEN rather than reading environment variables or ConfigService again. This keeps one source of truth: whatever forRoot or forRootAsync resolved is what every feature instance sees. For the token to resolve, it has to be visible to the importing module. Either make the root global, or add the options provider to the root's exports and have the module that calls forFeature import the root module.

This introduces coupling. forFeature silently depends on forRoot having been imported somewhere visible. If it hasn't, Nest reports an unresolved dependency that names an internal token, which means little to the person who forgot the import. To give a clearer error, declare the options dependency as optional in the factory's inject array with { token: MODULE_OPTIONS_TOKEN, optional: true }. Then throw your own error when the value is undefined, naming the module and telling the reader to import forRoot first.

import { ConfigurableModuleBuilder, DynamicModule, Module } from '@nestjs/common';

export interface ClientsOptions { baseUrl: string; timeoutMs: number; }

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<ClientsOptions>()
    .setClassMethodName('forRoot') // also generates forRootAsync
    // Root is global and exports its options so feature providers can inject them.
    .setExtras({ isGlobal: true }, (def, extras) => ({
      ...def,
      global: extras.isGlobal,
      exports: [...(def.exports ?? []), MODULE_OPTIONS_TOKEN],
    }))
    .build();

// Stable token helper: providers and consumers derive the same token from a name.
export const getClientToken = (name: string) => `CLIENTS_MODULE_CLIENT:${name}`;

export class NamedClient {
  constructor(readonly name: string, readonly options: ClientsOptions) {}
}

@Module({})
export class ClientsModule extends ConfigurableModuleClass {
  // Hand-written: the builder only emits the forRoot/forRootAsync shape.
  static forFeature(names: string[]): DynamicModule {
    const providers = names.map((name) => ({
      provide: getClientToken(name),
      // Optional, so a missing forRoot reaches our check instead of a generic resolution error.
      inject: [{ token: MODULE_OPTIONS_TOKEN, optional: true }],
      useFactory: (options?: ClientsOptions) => {
        if (!options) {
          throw new Error(`ClientsModule.forFeature(['${name}']) requires ClientsModule.forRoot() or forRootAsync() to be imported first.`);
        }
        return new NamedClient(name, options); // reuse resolved root options, no config re-read
      },
    }));
    return { module: ClientsModule, providers, exports: providers.map((p) => p.provide) };
  }
}
// Consumer: constructor(@Inject(getClientToken('billing')) private readonly billing: NamedClient) {}

A hand-written forFeature next to the builder-generated forRoot creates one factory provider per name under a reproducible token, reuses the global root's MODULE_OPTIONS_TOKEN, and throws a descriptive error when the root module was never imported.

When to avoid dynamic modules, global scope and the alternatives

A dynamic module earns its complexity when several consumers need different configurations of the same capability. If exactly one application imports it, a plain module with a provider that reads configuration directly is usually clearer. Callers have no options to pass, no token to export, and no forRoot to document, and the provider can be tested by overriding it like any other.

Global scope

global: true is reasonable for a single shared client, such as one HTTP client or one connection pool, that almost every feature uses. The cost is that consumers no longer list the module in their imports, so the dependency disappears from the place a reader looks first. Tests that build a narrow testing module then fail with missing-provider errors or silently pick up the real client. Splitting a feature into its own package later also forces you to work out every implicit dependency it was relying on.

Re-imports and instance identity

Nest identifies a dynamic module by its class plus its dynamic metadata. Importing it twice with different options creates two module instances, each with its own providers. That can mean two connection pools you did not intend to have. The reverse catches people too: importing it twice with identical options generally resolves to the same instance, so you cannot rely on that to get isolated copies. If you need separate instances, make the options differ deliberately, for example by giving each a distinct name.

Approach Explicitness Boilerplate Async support Test replaceability
Plain static module High Minimal Via provider factories Easy override
Hand-written DynamicModule High High Manual forRootAsync Easy override
ConfigurableModuleBuilder High Low Generated Easy override
Global module Low at import site Low Depends on underlying approach Harder; leaks into unrelated tests

Testing dynamic modules and the impact on engineering time

Overriding options in a testing module

In tests, you can usually skip overriding and import the module with test values, such as PaymentsModule.forRoot({ ... }), inside Test.createTestingModule. When the module comes from a shared setup you don't control, call overrideProvider(MODULE_OPTIONS_TOKEN).useValue(...) (or your exported token) before compile(). Every provider that injects the options then sees the replacement. Individual clients get the same treatment: override the HTTP or database client provider so the test never touches the network.

The test worth writing first checks that bootstrap fails. Pass invalid options, for example a missing API key, and assert that compile() rejects with your validation error. This only works if validation runs inside a provider factory or in forRoot itself, so that resolving the module triggers it. If validation is deferred to first use, this test exposes that.

What it costs and what it saves

The upfront cost is the contract: the options interface, validation, the async variant, and the tests above. That cost is paid once per module. It pays back each time a service imports the module instead of copying setup code. Misconfiguration surfaces at startup rather than in production traffic, and option names and defaults stay consistent across services. ConfigurableModuleBuilder reduces the boilerplate further by generating the sync and async variants and their types.

The ongoing cost is real. When injection fails, you have to trace through generated providers and factory chains to find the cause. Hand-written forFeature methods also sit outside the builder, so you have to keep them in sync with the root options yourself.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs dynamic modules, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.