Solving Multi‑Tenant Configuration Drift with NestJS Dynamic Modules
When a NestJS service grows to support many tenants, configuration drift can explode. Dynamic modules let you generate per‑tenant providers at bootstrap, keeping core logic untouched and tests simple.
30 Mar 2026, 21:56 UTC

Problem Statement
In a growing micro‑service, each tenant may require its own database connection string, feature flags, or API keys. A naive approach is to create a separate module for every tenant or to hard‑code values in a global config service. Both strategies lead to:
- Repeated boilerplate code.
- Configuration drift when values change.
- Hard‑to‑test provider trees.
What if the framework could generate the necessary providers on the fly, based on a tenant ID supplied at runtime?
Dynamic Modules 101
A Dynamic Module is a module that returns a DynamicModule object instead of a plain class. The object contains imports, providers, and exports that Nest resolves during bootstrap. The typical entry points are forRoot (synchronous) and forRootAsync (asynchronous).
Key points:
- Static methods return a
DynamicModuleobject. - Providers can be created from factory functions that receive configuration parameters.
- Dynamic modules integrate with Nest’s
ModuleRefand dependency injection system. - They can be nested; a feature module can import a dynamic module and pass its own config.
Practical Pattern: Tenant‑Specific Database Provider
Suppose each tenant has a separate PostgreSQL database. A single DatabaseModule can expose a forRoot method that accepts a tenant ID and returns a provider for that tenant’s connection.
import { DynamicModule, Module } from '@nestjs/common';
import { createConnection, Connection } from 'typeorm';
@Module({})
export class DatabaseModule {
static forRoot(tenantId: string): DynamicModule {
const connectionProvider = {
provide: `TENANT_CONNECTION_${tenantId}`,
useFactory: async () => {
const config = getTenantConfig(tenantId); // fetch tenant‑specific DB settings
return await createConnection({
name: tenantId,
type: 'postgres',
host: config.host,
port: config.port,
username: config.user,
password: config.password,
database: config.database,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
});
},
};
return {
module: DatabaseModule,
providers: [connectionProvider],
exports: [connectionProvider],
};
}
}
Usage in a tenant‑aware controller:
@Controller('orders')
export class OrdersController {
constructor(
@Inject('TENANT_CONNECTION_user123') private readonly connection: Connection,
) {}
// controller logic uses this.connection
}
Notice the provider token includes the tenant ID, allowing the same controller class to be reused with different connections.
Worked Example: Two Tenants in One Request Cycle
Below is a minimal test scenario that demonstrates the dynamic module in action. The test bootstraps the application twice, each time with a different tenant ID, and verifies that the resolved connection points to the correct database name.
import { Test } from '@nestjs/testing';
import { DatabaseModule } from './database.module';
import { Connection } from 'typeorm';
describe('Dynamic DatabaseModule', () => {
it('should provide tenant‑specific connections', async () => {
const tenantA = await Test.createTestingModule({
imports: [DatabaseModule.forRoot('tenantA')],
}).compile();
const connA = tenantA.get<Connection>('TENANT_CONNECTION_tenantA');
expect(connA.options.database).toBe('tenant_a_db');
const tenantB = await Test.createTestingModule({
imports: [DatabaseModule.forRoot('tenantB')],
}).compile();
const connB = tenantB.get<Connection>('TENANT_CONNECTION_tenantB');
expect(connB.options.database).toBe('tenant_b_db');
});
});
In a real application, you would replace the hard‑coded getTenantConfig with a service that pulls from a shared configuration store. The test demonstrates that the same module class can be reused safely with different parameters.
Trade‑offs & Limitations
- Complexity: Dynamic modules add an extra layer of indirection. New developers may find the provider tokens confusing.
- Debugging: Stack traces will show provider names like
TENANT_CONNECTION_tenantA, which can clutter logs if many tenants exist. - Memory Footprint: Each tenant’s connection is kept alive until the module is destroyed. In a large tenant base, consider connection pooling or a shared connection strategy.
- Testing Overhead: While the module can be imported with different parameters, you must write separate tests for each variant to guarantee correctness.
- Circular Dependencies: If a dynamic module imports another dynamic module that references the first, a circular dependency can arise. Keep the dependency graph flat or use
forwardRefsparingly.
Actionable Takeaways
- Use
forRoot/forRootAsyncto expose tenant‑specific configuration as a provider token. - Keep provider tokens descriptive (
TENANT_CONNECTION_${tenantId}) to aid debugging. - Write unit tests that bootstrap the dynamic module with mock configuration for each tenant scenario.
- Monitor connection usage in production; consider pooling or lazy loading if tenant count grows.
- Document the dynamic module’s API clearly so new developers understand the parameter contract.
Dynamic modules let you keep your core NestJS code clean while scaling to hundreds of tenants. By generating providers at bootstrap, you avoid configuration drift, reduce boilerplate, and maintain a single source of truth for each feature. Implement the pattern, test each tenant path, and monitor resource usage to keep the system healthy as it grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.