Dynamic modules in NestJS: forRoot, forRootAsync and why importing twice yields two instances Intermediate
Estimated reading time: 10 min read
The problem
Section titled “The problem”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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| 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 |
Core concepts
Section titled “Core concepts”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 |
How it works
Section titled “How it works”forRootAsync creates nothing right away. It returns an object containing a factory provider for the options token.
importsAppModule calls PaymentModule.forRootAsync(). The function only returns a DynamicModule object; no provider has been created yet.
- Import:
forRootAsync()runs whenAppModule’s decorator is evaluated and returns{ module, imports, providers }. No instances exist yet. - Compile:
PaymentModuleimportsConfigModuleand has aPAYMENT_MODULE_OPTIONSprovider (useFactory,inject: [ConfigService]). - Wait for dependencies: the factory needs
ConfigService.PaymentModuleonly sees its own providers and the exports of modules it imports. - Resolve options: the factory can be
async; Nest waits for the promise and assigns the value to the token. - Create the client:
PaymentClientreceives 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.
Operational details
Section titled “Operational details”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()andregisterAsync().setClassMethodName('forRoot')renames them toforRoot()andforRootAsync(). MODULE_OPTIONS_TOKEN: withmoduleName: 'Payment'it is the stringPAYMENT_MODULE_OPTIONS; without it, a random string. Always import the constant.- The async version accepts
useFactory+inject, oruseClass/useExistingwith acreate()method (renamed viasetFactoryMethodName). setExtrasadds parameters that are not part of the options. Per the 11.2.6 source,isGlobalis stripped from the value injected viaPAYMENT_OPTIONS; only the transform function receives it.- The builder does not add
exportsautomatically. Public providers must be listed inexportsof the@Module()on the subclass. - Nest 11 identifies dynamic modules by object reference (
moduleIdGeneratorAlgorithmdefaults to'reference'). EachforRoot()call returns a new object, hence a new module.
Real-world example
Section titled “Real-world example”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), }),})npx --prefix examples ts-node examples/src/nestjs-dynamic-modules/main.tsReal 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: trueIn [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.
Trade-offs
Section titled “Trade-offs”Pros
-
One module reused across apps, with configuration at the import site.
-
forRootAsynccan read secrets or call a secret manager before creating the client. -
ConfigurableModuleBuildergenerates 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: trueis convenient but hides dependencies between modules.
Cons
Production checklist
Section titled “Production checklist”- Call
forRoot/forRootAsynconce inAppModule(or one infrastructure module) for each shared client. - Feature modules only import the configured module or rely on global registration; they never call
forRootagain. forRootAsyncdeclaresimportsfor every provider ininject, except global providers.- Set
moduleNameon the builder so error messages are readable; declareexportsfor exactly the public providers; don’t export the options token if feature modules don’t need it. - Validate options in the factory and fail fast; never log the raw
apiKey. - Enable
isGlobalonly for infrastructure modules used everywhere (config, logger), not for business modules. - Write a test asserting identity:
app.get(OrdersService).payments === app.get(RefundsService).payments.
Common mistakes
Section titled “Common mistakes”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 |
Self-check
Section titled “Self-check”- When is
forRootAsync()called, and doesPaymentClientexist at that point? - Why does the missing-
ConfigServiceerror mentionPaymentModuleand notAppModule? - Two feature modules both call
forRoot(OPTS)with the sameOPTSobject. Why are there still two instances, and which three approaches bring it back to one?
Summary
Section titled “Summary”References
Section titled “References”- NestJS — Dynamic modules: method naming conventions,
ConfigurableModuleBuilder,setExtras. - NestJS — Modules: global modules, and why not to make everything global.
- NestJS — Configuration:
isGlobalin@nestjs/config. - Source
configurable-module.builder.ts: token generation, stripping extras, no automatic exports. - Source
by-reference-module-opaque-key-factory.ts: identifying modules by reference.