Skip to content
Knowledge Hub

Dynamic modules in NestJS: forRoot, forRootAsync and why importing twice yields two instances Intermediate

Estimated reading time: 10 min read

A team writes a PaymentModule wrapping a payment gateway and reuses it in two apps: shop and admin. Each app has its own API key and base URL (sk_live_shop_abc with …/shop, sk_live_admin_xyz with …/admin). Keys must not be hard-coded: the app reads them from ConfigService at startup.

A static module takes no parameters. You need a module that accepts configuration at import time, waits for the config to be ready, and only then creates PaymentClient.

The question: how do you write a module that accepts configuration, how do you name its methods, and why does calling forRoot() in two places produce two PaymentClients?

Situation Choose Condition
Configuration known up front (tests, fixed values) PaymentModule.forRoot({ apiKey, baseUrl }) Don’t read secrets directly from code
Configuration comes from ConfigService or a secret manager forRootAsync({ imports, inject, useFactory }) imports contains the modules exporting the injected providers (except global providers)
Writing a configurable module with little boilerplate ConfigurableModuleBuilder The module declares its own exports for public providers
The whole app uses one client Call forRoot once in AppModule, with isGlobal: true or re-exported through a shared module Don’t call forRoot again in feature modules
Each module needs its own configuration register() in each module Accept that every import is a separate instance

A static module declares fixed metadata in @Module(). A dynamic module is an object of type DynamicModule returned by a static method; Nest treats that object as extra metadata for the module class, and then the DI container creates providers as usual.

Property Meaning In PaymentModule
module The module class being extended (required) PaymentModule
providers Providers generated from the parameters Options token + PaymentClient
exports Providers other modules can see PaymentClient
imports Modules the providers inside need ConfigModule (async version)
global Registers in global scope, no re-import needed Only when deliberate

The shortest hand-written version, run in the demo:

@Module({})
class ManualPaymentModule {
static forRoot(options: PaymentOptions): DynamicModule {
return {
module: ManualPaymentModule,
providers: [{ provide: 'MANUAL_PAYMENT_OPTIONS', useValue: options }],
exports: ['MANUAL_PAYMENT_OPTIONS'],
};
}
}

Method names are a convention: the docs say “there are no hard rules”, but the @nestjs/* packages follow the guidance below, each method usually paired with an …Async() version:

Method Per the docs Example
register() Configuration specific to the calling module; each import site can differ An HTTP client with a per-feature base URL
forRoot() Configure once, reuse in many places A database connection, the app’s PaymentModule
forFeature() Uses the forRoot() configuration, tweaks part of it for the calling module Per-entity repositories

forRootAsync creates nothing right away. It returns an object containing a factory provider for the options token.

forRootAsync: the factory waits for ConfigService, options go into the token, and only then is PaymentClient created
forRootAsync: the factory waits for ConfigService, options go into the token, and only then is PaymentClient createdStep 1/6: AppModule calls PaymentModule.forRootAsync(). The function only returns a DynamicModule object; no provider has been created yet.PaymentModule(instance created byforRootAsync)AppModuleshop appforRootAsync()returns DynamicModuleConfigServiceexported by ConfigModuleuseFactoryinject: [ConfigService]PAYMENT_MODULE_OPTIONS{ apiKey, baseUrl }PaymentClient@Inject(token)imports
AppModule → forRootAsync()imports
Step 1/6

AppModule calls PaymentModule.forRootAsync(). The function only returns a DynamicModule object; no provider has been created yet.

  1. Import: forRootAsync() runs when AppModule’s decorator is evaluated and returns { module, imports, providers }. No instances exist yet.
  2. Compile: PaymentModule imports ConfigModule and has a PAYMENT_MODULE_OPTIONS provider (useFactory, inject: [ConfigService]).
  3. Wait for dependencies: the factory needs ConfigService. PaymentModule only sees its own providers and the exports of modules it imports.
  4. Resolve options: the factory can be async; Nest waits for the promise and assigns the value to the token.
  5. Create the client: PaymentClient receives the options via @Inject(PAYMENT_MODULE_OPTIONS). Bootstrap is done.

Why is imports: [ConfigModule] required? The factory provider belongs to PaymentModule, not AppModule. AppModule importing ConfigModule does not make ConfigService visible to PaymentModule.

The exception: ConfigModule.forRoot({ isGlobal: true }) from @nestjs/config registers globally, and then no re-import is needed.

ConfigurableModuleBuilder (in @nestjs/common) generates the boilerplate:

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN: PAYMENT_OPTIONS } =
new ConfigurableModuleBuilder<PaymentOptions>({ moduleName: 'Payment' })
.setClassMethodName('forRoot')
.setExtras({ isGlobal: false }, (definition, extras) => ({
...definition,
global: extras.isGlobal,
}))
.build();
@Injectable()
export class PaymentClient {
constructor(@Inject(PAYMENT_OPTIONS) readonly options: PaymentOptions) {}
}
@Module({ providers: [PaymentClient], exports: [PaymentClient] })
export class PaymentModule extends ConfigurableModuleClass {}
  • By default the generated class has register() and registerAsync(). setClassMethodName('forRoot') renames them to forRoot() and forRootAsync().
  • MODULE_OPTIONS_TOKEN: with moduleName: 'Payment' it is the string PAYMENT_MODULE_OPTIONS; without it, a random string. Always import the constant.
  • The async version accepts useFactory + inject, or useClass/useExisting with a create() method (renamed via setFactoryMethodName).
  • setExtras adds parameters that are not part of the options. Per the 11.2.6 source, isGlobal is stripped from the value injected via PAYMENT_OPTIONS; only the transform function receives it.
  • The builder does not add exports automatically. Public providers must be listed in exports of the @Module() on the subclass.
  • Nest 11 identifies dynamic modules by object reference (moduleIdGeneratorAlgorithm defaults to 'reference'). Each forRoot() call returns a new object, hence a new module.

The demo lives in examples/src/nestjs-dynamic-modules/ (NestJS 11.2.6). @nestjs/config is not in examples, so config.module.ts is a minimal version: ConfigService reads process.env and is exported by ConfigModule.

PaymentModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService): Promise<PaymentOptions> => ({
apiKey: config.getOrThrow('PAYMENT_API_KEY'),
baseUrl: config.getOrThrow('PAYMENT_BASE_URL'),
timeoutMs: Number(config.get('PAYMENT_TIMEOUT_MS') ?? 3000),
}),
})
Terminal window
npx --prefix examples ts-node examples/src/nestjs-dynamic-modules/main.ts

Real output (objects printed on one line, errors wrapped for readability):

[2] shop: factory runs, ConfigService is ready -> PaymentClient created
options = { apiKey: 'sk_live_shop_abc', baseUrl: 'https://pay.example.test/shop', timeoutMs: 3000 }
[2] admin: factory runs, ConfigService is ready -> PaymentClient created
options = { apiKey: 'sk_live_admin_xyz', baseUrl: 'https://pay.example.test/admin', timeoutMs: 3000 }
[3] forgot imports: [ConfigModule] -> Nest can't resolve dependencies of the PAYMENT_MODULE_OPTIONS (?).
Please make sure that the argument ConfigService at index [0] is available in the PaymentModule module.
[4] forgot exports -> Nest can't resolve dependencies of the CheckoutService (?).
Please make sure that the argument "PAYMENT_CLIENT" at index [0] is available in the NoExportApp module.
[5a] forRoot() called twice: PaymentClients created = 2, orders === refunds: false
[5b] sharing one const: PaymentClients created = 1, orders === refunds: true
[5c] called twice + moduleIdGeneratorAlgorithm: 'deep-hash': PaymentClients created = 1, orders === refunds: true
[5d] forRoot({ isGlobal: true }) in AppModule, feature modules don't import: PaymentClients created = 1, orders === refunds: true

In [5a], OrdersModule and RefundsModule both call forRoot(OPTS) with the same options and still get two clients. [5b]–[5d] are three ways back to a single instance.

Loading diagram…

Two clients means two connection pools, two rate limit counters, and two separate circuit breakers.

What is still missing for production: validating options at startup (see validate env), timeouts and retries for HTTP calls, and an idempotency key for charge commands.

Pros

  • One module reused across apps, with configuration at the import site.

  • forRootAsync can read secrets or call a secret manager before creating the client.

  • ConfigurableModuleBuilder generates the token, sync/async methods and options type.

  • < slot="cons">
  • The number of instances depends on how many times the method is called, which the module name does not reveal.

  • DI errors from the factory live in the generated module; without moduleName, messages point to hard-to-read tokens.

  • global: true is convenient but hides dependencies between modules.

Cons

  1. Call forRoot/forRootAsync once in AppModule (or one infrastructure module) for each shared client.
  2. Feature modules only import the configured module or rely on global registration; they never call forRoot again.
  3. forRootAsync declares imports for every provider in inject, except global providers.
  4. Set moduleName on the builder so error messages are readable; declare exports for exactly the public providers; don’t export the options token if feature modules don’t need it.
  5. Validate options in the factory and fail fast; never log the raw apiKey.
  6. Enable isGlobal only for infrastructure modules used everywhere (config, logger), not for business modules.
  7. Write a test asserting identity: app.get(OrdersService).payments === app.get(RefundsService).payments.

The most expensive mistake is duplicating providers with no error:

Calling forRoot in each feature module

Common

@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class OrdersModule {}
@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class RefundsModule {}

Prefer

@Module({ imports: [PaymentModule.forRoot({ ...OPTS, isGlobal: true }), OrdersModule, RefundsModule] })
class AppModule {}

Why

Nest 11 compares dynamic modules by object reference: two calls are two modules and two PaymentClients (demo [5a]). Call it once at the root, or share one constant PAYMENT_MODULE = PaymentModule.forRoot(...).

The other two mistakes usually surface right at bootstrap:

Misconception Consequence What to do
“Same options, so Nest merges them into one” Two clients, two pools, separate state Call once; verify with an identity check
“AppModule importing ConfigModule is enough for the factory” UnknownDependenciesException in PaymentModule Add imports inside forRootAsync
“global: true for convenience” Every module can use it, and nobody sees who depends on whom Only for infrastructure
  1. When is forRootAsync() called, and does PaymentClient exist at that point?
  2. Why does the missing-ConfigService error mention PaymentModule and not AppModule?
  3. Two feature modules both call forRoot(OPTS) with the same OPTS object. Why are there still two instances, and which three approaches bring it back to one?