NestJS Dynamic Modules: Build Configurable Feature Modules Once, Reuse Everywhere
NestJS dynamic modules let you write a feature once and register it multiple times with different configs — each registration gets isolated providers. Learn the pattern with a reusable CacheModule example, see the trade-offs, and verify isolation in your own app.
10 Aug 2025, 04:16 UTC

The Problem: Duplicate Feature Code for Different Configurations
You need a Redis cache in two microservices — one for sessions, another for rate limiting. Each needs its own connection URL, TTL, and key prefix. Copy-pasting a CacheModule and tweaking constants works until a third service needs a third configuration. The duplication makes updates error-prone and testing fragile.
NestJS dynamic modules solve this by letting you write the feature once, then register it multiple times with different options — each registration gets its own isolated provider set.
How Dynamic Modules Work
A dynamic module is a class decorated with @Module({}) that exposes a static forRoot (synchronous) or forRootAsync (asynchronous) method. That method returns a DynamicModule object describing imports, providers, controllers, and exports computed at registration time.
The returned DynamicModule merges into the importing module's dependency graph. Providers defined inside are scoped to that registration by default, so two CacheModule.forRoot({ url: 'redis://a' }) and CacheModule.forRoot({ url: 'redis://b' }) produce two independent CacheService instances.
Worked Example: A Reusable CacheModule
Create cache/cache.module.ts:
import { Module, DynamicModule, Global, Provider, InjectionToken } from '@nestjs/common';
import { CacheService } from './cache.service';
import { CacheOptions } from './cache.interface';
export const CACHE_OPTIONS = 'CACHE_OPTIONS' as InjectionToken<CacheOptions>;
export interface CacheOptions {
url: string;
ttl?: number;
prefix?: string;
}
@Global()
@Module({})
export class CacheModule {
static forRoot(options: CacheOptions): DynamicModule {
const optionsProvider: Provider = {
provide: CACHE_OPTIONS,
useValue: options,
};
return {
module: CacheModule,
providers: [optionsProvider, CacheService],
exports: [CacheService, CACHE_OPTIONS],
};
}
static forRootAsync(asyncOptions: {
useFactory: (...args: any[]) => Promise<CacheOptions> | CacheOptions;
inject?: any[];
imports?: any[];
}): DynamicModule {
const asyncProvider: Provider = {
provide: CACHE_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject || [],
};
return {
module: CacheModule,
imports: asyncOptions.imports || [],
providers: [asyncProvider, CacheService],
exports: [CacheService, CACHE_OPTIONS],
};
}
}CacheService injects CACHE_OPTIONS and creates a client per registration. Because the module is @Global(), its exports are available everywhere without re-importing — but you can omit @Global() if you prefer explicit imports.
Register Twice in AppModule
@Module({
imports: [
CacheModule.forRoot({ url: 'redis://session-cache:6379', prefix: 'sess:', ttl: 3600 }),
CacheModule.forRoot({ url: 'redis://rate-limit-cache:6379', prefix: 'rl:', ttl: 60 }),
],
})
export class AppModule {}Each registration yields a distinct CacheService. Inject the one you need with a custom token or use @Inject(CACHE_OPTIONS) to differentiate. For true multi-tenancy, create a named token per registration:
// In a feature module
const SESSION_CACHE = 'SESSION_CACHE';
CacheModule.forRoot({ url: '...', token: SESSION_CACHE });
// Injection
constructor(@Inject(SESSION_CACHE) private cache: CacheService) {}Trade-offs and Limitations
- Indirection cost: Dynamic modules add a layer of abstraction. For a single-use feature, a plain module is simpler.
- Circular dependencies: If two dynamic modules import each other's
forRoot, useforwardRefor extract shared providers into a core module. - Provider isolation: Providers are not singletons across different
forRootcalls unless you export them from a shared parent or mark the module@Global(). - Async bootstrap failures:
forRootAsyncfactories returning promises must resolve before the app starts. Unhandled rejections can crash bootstrap silently in older Nest versions — always attach.catch()or usetry/catchin the factory. - Type inference: Dynamically created providers (e.g., connection tokens) may have weak types. Export a typed
InjectionToken<MyService>from the module for safer injection.
Verify It Works
- Run
nest new demo && cd demo(requires Node 18+, npm 9+). - Add the
CacheModulefiles above. - Register it twice in
AppModulewith different URLs. - Create a test:
npm test -- --testPathPattern=cacheusingTest.createTestingModule({ imports: [CacheModule.forRoot(testOpts)] })and assert two distinctCacheServiceinstances viamoduleRef.get(CacheService, { strict: false }). - Start the app:
npm run start:dev. Check logs for two separate connection attempts.
If both connections succeed and each service uses its own prefix/TTL, the isolation works.
Next Steps
Extract a dynamic module for any feature you configure repeatedly — database connections, HTTP clients, feature flags. Start with forRoot; migrate to forRootAsync when options depend on ConfigService or external APIs. Keep the module non-global until you genuinely need app-wide access. That restraint keeps the dependency graph traceable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.