Dynamic modules and async configuration
A static @Module has a fixed provider set; a dynamic module is a class whose static method returns a DynamicModule descriptor at runtime so the importer can pass config. forRoot once, forFeature per slice, register per import — and forRootAsync when config comes from a provider.
The pager went off at 2am: FATAL: sorry, too many clients already. Postgres was at its max_connections of 100, refusing new logins, and every pod in the deployment was crash-looping on boot. The schema hadn’t changed, traffic was flat. The diff that shipped the day before added a DatabaseModule.forRoot({ poolSize: 10 }) to a feature module so a background job could “reuse the connection.” But forRoot() was already called once in AppModule. Calling it again didn’t reuse anything — it registered the connection provider a second time, stood up a second pool of 10, and across five pods that quietly doubled the cluster’s idle connections from 50 to 100. The shared singleton the team thought they had was two instances all along. This lesson is about the machinery underneath forRoot, forFeature, register, and async config — and the failure modes that come from getting them wrong.
Static set vs runtime descriptor
A static @Module({ providers, exports }) has a fixed provider set, decided at class-definition time. It can’t take configuration — imports: [CacheModule] gives you whatever CacheModule decided for itself.
A dynamic module is a class with a static method that returns a DynamicModule descriptor at runtime: { module, providers, imports?, exports?, global? }. The importer calls that method and passes config, and Nest splices the returned providers into the graph. This is exactly how ConfigModule.forRoot({ ... }), JwtModule.register({ ... }), and TypeOrmModule.forFeature([ ... ]) work — each call is a method invocation that builds a tailored module on the spot.
import { DynamicModule, Module } from '@nestjs/common';
export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';
export interface StorageOptions { bucket: string; region: string; }
@Module({})
export class StorageModule {
// hand-rolled dynamic module: the importer passes config, we turn it into a provider
static forRoot(options: StorageOptions): DynamicModule {
return {
module: StorageModule,
providers: [{ provide: STORAGE_OPTIONS, useValue: options }],
exports: [STORAGE_OPTIONS], // so consumers can inject the options token
global: true, // exports visible app-wide without re-importing
};
}
}StorageModule.forRoot({ bucket: 'uploads', region: 'eu-central-1' }) returns a fully-formed module whose single provider is the options object, exported under a token that downstream services inject. The @Module({}) shell is empty on purpose — the method is the real constructor.
The naming convention: forRoot, forFeature, register
The three method names are a convention, not enforced by Nest — but every Nest developer reads them as a contract, so breaking it is a footgun.
forRoot()— configure once, at the app root. Typically returns the shared infrastructure (a connection, a config store) and is oftenglobal. Calling it more than once is the bug in the hook.forFeature()— register a slice, repeatedly, per feature module.TypeOrmModule.forFeature([User])in one module,forFeature([Order])in another — each call scopes repositories into that module without re-creating the connectionforRootalready made.register()— per-import configuration with no root/global semantics. EachHttpModule.register({ baseURL })is independent; two imports mean two separately-configured instances, and that’s intended.
// forRoot ONCE, at the root — stands up the shared connection (global)
imports: [DatabaseModule.forRoot({ url: process.env.DB_URL, poolSize: 10 })]
// forFeature PER module — scopes entities/repos, reuses the forRoot connection
imports: [DatabaseModule.forFeature([User])] // in UsersModule
imports: [DatabaseModule.forFeature([Order])] // in OrdersModule
// register PER import — each gets its own independent config, no sharing implied
imports: [HttpModule.register({ baseURL: 'https://api.billing.internal' })]Together the three names encode the sharing intent: forRoot means “one shared pool for the whole app”, forFeature means “slice of that pool, per module”, and register means “independent instance, no sharing”. When you see a colleague call forRoot in a feature module, that’s the 2am incident waiting to happen — the name itself is the contract.
▸Why this works
Why does calling forRoot() twice double your pool instead of reusing the connection? Because forRoot() is a plain function that returns a fresh provider descriptor every time. The first call registers { provide: DB_POOL, useFactory: makePool } in AppModule; the second call returns the same descriptor object again, and Nest registers it a second time in the feature module’s injector — useFactory runs again, opening a second physical pool. There is no identity check that says “this provider already exists, skip it.” @Global doesn’t save you either: global only changes visibility of exports, not cardinality of providers. Two forRoot calls = two factory runs = two pools, full stop. The shared-singleton mental model is a lie the second forRoot quietly breaks; the fix is to call forRoot exactly once and have everyone downstream forFeature or inject the exported token.
Async config: forRootAsync and the factory provider
The hand-rolled forRoot above takes a literal options object. But the real config — a DB URL, a JWT secret — lives in env and is read by ConfigService. You can’t pass a literal at import time; you need the value resolved from another provider. That’s forRootAsync:
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
JwtModule.registerAsync({
imports: [ConfigModule], // make ConfigService resolvable IN this context
inject: [ConfigService], // the factory's dependencies, in order
useFactory: (cfg: ConfigService) => ({
secret: cfg.getOrThrow('JWT_SECRET'),
signOptions: { expiresIn: cfg.get('JWT_TTL') ?? '15m' },
}),
});Three pieces work together. useFactory is itself a provider — a function Nest calls to produce the options. inject lists that function’s dependencies, positionally, so Nest knows to resolve ConfigService and pass it as the first argument. imports makes those dependencies resolvable inside the dynamic module’s own injection context — a dynamic module has its own scope, so unless ConfigModule is global, the factory can’t see ConfigService without importing it here.
Forget imports: [ConfigModule] (and ConfigModule isn’t global), and you get a boot-time crash, not a runtime one: Nest can't resolve dependencies of the <factory> (?). Please make sure that the argument ConfigService at index [0] is available in the <module> context. The (?) is the unresolved ConfigService — the factory declared it via inject but nothing in scope provides it.
ConfigurableModuleBuilder: stop hand-rolling
Writing forRoot and forRootAsync by hand — the literal path, the factory path, the useExisting/useClass async variants, the options token — is ~40 lines of boilerplate per module, repeated identically across every configurable module you own. ConfigurableModuleBuilder generates all of it:
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { StorageOptions } from './storage-options.interface';
// generates the class + token + sync AND async entry points for you
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<StorageOptions>().build();import { Module } from '@nestjs/common';
import { ConfigurableModuleClass } from './storage.module-definition';
@Module({ providers: [StorageService], exports: [StorageService] })
export class StorageModule extends ConfigurableModuleClass {}
// now StorageModule.register({...}) AND StorageModule.registerAsync({ useFactory, inject, imports })
// both exist, and StorageService can inject MODULE_OPTIONS_TOKENThe builder hands back ConfigurableModuleClass (extend it) and MODULE_OPTIONS_TOKEN (inject it to read the options). You get both register and registerAsync for free, with the imports/inject/useFactory async plumbing already wired. Want forRoot naming instead of register? .setClassMethodName('forRoot'). Want it global? .setExtras({ isGlobal: true }, ...). The ~40 lines per module collapse to two.
Your AuthModule needs the JWT signing secret from the environment, read via ConfigService. How should you configure JwtModule?
In JwtModule.registerAsync({ imports, inject, useFactory }), what do `inject` and `imports` each do — and why would you get 'Nest can't resolve dependencies of the useFactory (?)' at boot?
A team marks DatabaseModule @Global() and calls DatabaseModule.forRoot() in both AppModule and a feature module, expecting @Global to dedupe it into one shared connection. What actually happens?
- 01What is a dynamic module versus a static one, and what do the forRoot / forFeature / register names each signal? Why does calling forRoot twice cause the connection-pool incident?
- 02How does forRootAsync turn an injected provider into config, and what does ConfigurableModuleBuilder generate? Why is 'Nest can't resolve dependencies of the useFactory' a boot-time error?
A static @Module has a fixed provider set decided at definition time and can’t be configured by its importer. A dynamic module fixes that: it’s a class whose static method returns a DynamicModule descriptor — { module, providers, imports?, exports?, global? } — at runtime, so the caller passes config and Nest splices the providers in. That’s the machinery behind ConfigModule.forRoot, JwtModule.register, and TypeOrmModule.forFeature. The names are a convention, not enforced: forRoot configures once at the root and returns shared infra (often global); forFeature registers a slice repeatedly per feature, reusing the forRoot connection; register is per-import config with no global semantics, so each import is independent. The first failure mode: calling forRoot twice doesn’t reuse anything — it’s a function returning a fresh provider descriptor each time, so the factory runs again and a second connection pool opens, which across several pods can double idle connections and exhaust Postgres max_connections (default 100); @Global can’t save you because it changes export visibility, not provider cardinality. For env-derived config use forRootAsync({ imports, inject, useFactory }): useFactory is a provider producing the options, inject lists its positional dependencies, and imports makes those deps resolvable in the dynamic module’s own scope — forget imports: [ConfigModule] and you get the boot-time ‘Nest can’t resolve dependencies of the useFactory (?)’. ConfigurableModuleBuilder generates ConfigurableModuleClass and MODULE_OPTIONS_TOKEN plus both register and registerAsync, collapsing ~40 lines of async-provider plumbing per module to two. The senior stance on @Global: reserve it for truly cross-cutting infra (Config, Logger, DB) — overuse hides dependencies and turns the DI graph into a hairball where modules silently use providers they never import. Now when you see a connection-pool exhaustion incident, scan your module tree for any forRoot call that appears more than once — that second call is where your second pool is born.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.