open atlas
↑ Back to track
NestJS, zero to senior NEST · 03 · 02

Dynamic modules: forRoot, forRootAsync, forFeature

A DynamicModule returns its definition at runtime. forRoot configures once, forRootAsync pulls options from DI (ConfigService), forFeature registers per-consumer. Build one with an options token; ConfigurableModuleBuilder generates the boilerplate.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

You’re extracting your S3 upload code into a shared StorageModule so three services can reuse it. The first consumer hardcodes the bucket and region, ships, works. The second consumer needs a different bucket per environment — so its values live in ConfigService, which is populated from .env at startup. Now you’re stuck: the module needs the bucket name to construct its S3Client provider, but the bucket name lives in a provider that doesn’t exist until after modules are wired. You can’t @Inject(ConfigService) into a @Module() decorator — that’s a compile-time annotation, and config is a runtime value. This chicken-and-egg is exactly the problem forRootAsync exists to solve, and TypeOrmModule.forRootAsync, JwtModule.registerAsync, and ConfigModule.forRoot are all the same three lines under the hood.

A DynamicModule is a module computed at runtime

When you import TypeOrmModule.forRoot(dbConfig) or JwtModule.register({ secret }), you’re not calling magic NestJS internals — you’re calling a static method that returns a plain object. Understanding that object is what lets you build reusable modules instead of copying boilerplate.

A normal @Module({...}) is static: its provider list is fixed when you write it. A dynamic module is a module whose definition is returned by a method call at runtime. The contract is a plain object — the DynamicModule interface — with these fields:

import { DynamicModule } from '@nestjs/common';

interface DynamicModule {
  module: Type<any>;        // required: the host class
  imports?: any[];          // modules this dynamic module needs
  providers?: Provider[];   // providers to create, possibly built from options
  exports?: any[];          // what consumers can inject
  global?: boolean;         // register once, available everywhere
  controllers?: Type<any>[];
}

That one runtime object is the entire mechanism behind every configurable library you import. TypeOrmModule.forRoot(dbConfig), JwtModule.register({ secret }), ConfigModule.forRoot() — each is a static method that returns this shape, baking your options into the providers array before Nest ever instantiates them. The convention is the method name: forRoot for global once-per-app configuration, register for per-import configuration, forFeature for per-consumer extensions.

The base pattern for building one yourself: define an options token, provide the options value from the static method, and have inner providers @Inject that token. The token is the seam — it lets a provider depend on options that don’t exist until the consumer calls forRoot.

// storage.constants.ts
export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';

export interface StorageOptions {
  bucket: string;
  region: string;
}
// storage.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';

@Injectable()
export class StorageService {
  constructor(@Inject(STORAGE_OPTIONS) private readonly opts: StorageOptions) {}
  // this.opts.bucket / this.opts.region are now available
}

forRoot vs forRootAsync: when options come from DI

forRoot(options) is synchronous: the caller passes a literal options object, the static method wraps it in a useValue provider, done. Use it for configure-once, app-wide singletons — a database connection, an HTTP client, an auth secret — typically with global: true so every module can inject the exports without re-importing.

// storage.module.ts — synchronous forRoot
import { Module, DynamicModule } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
import { StorageService } from './storage.service';

@Module({})
export class StorageModule {
  static forRoot(options: StorageOptions): DynamicModule {
    return {
      module: StorageModule,
      global: true,
      providers: [
        { provide: STORAGE_OPTIONS, useValue: options }, // options baked in as a value
        StorageService,
      ],
      exports: [StorageService],
    };
  }
}

forRootAsync({ imports, useFactory, inject }) is for when the options themselves must be produced by another provider. You can’t pass a literal object because the values live in ConfigService, a secrets manager, or another async source. Instead of a useValue, the options token is provided by a factory that Nest resolves through DI — which is how you break the chicken-and-egg: the factory runs after its inject dependencies are constructed.

// storage.module.ts — asynchronous forRootAsync
import { Module, DynamicModule, Provider } from '@nestjs/common';
import { STORAGE_OPTIONS, StorageOptions } from './storage.constants';
import { StorageService } from './storage.service';

interface StorageAsyncOptions {
  imports?: any[];
  inject?: any[];
  useFactory: (...args: any[]) => Promise<StorageOptions> | StorageOptions;
}

@Module({})
export class StorageModule {
  static forRootAsync(options: StorageAsyncOptions): DynamicModule {
    const optionsProvider: Provider = {
      provide: STORAGE_OPTIONS,
      useFactory: options.useFactory, // runs AFTER inject deps are ready
      inject: options.inject || [],
    };
    return {
      module: StorageModule,
      global: true,
      imports: options.imports || [],   // e.g. ConfigModule, so ConfigService is injectable
      providers: [optionsProvider, StorageService],
      exports: [StorageService],
    };
  }
}
// app.module.ts — wiring it with ConfigService
@Module({
  imports: [
    ConfigModule.forRoot(),
    StorageModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        bucket: config.getOrThrow('S3_BUCKET'),
        region: config.getOrThrow('AWS_REGION'),
      }),
    }),
  ],
})
export class AppModule {}

forFeature: per-consumer registration

forRoot/forRootAsync configure the module once for the whole app — the connection, the singletons, the global state. forFeature(...) is the second tier: it registers something scoped to the importing module only, layered on top of an existing root configuration. The textbook example is TypeORM: TypeOrmModule.forRoot(dbConfig) opens the one connection in your root module; TypeOrmModule.forFeature([User]) then registers the User repository for that feature module’s injector, so UsersModule can inject Repository&lt;User> and OrdersModule can inject Repository&lt;Order> without either seeing the other’s repositories.

@Module({
  imports: [TypeOrmModule.forFeature([User])], // User repo, scoped to UsersModule
  providers: [UsersService],
})
export class UsersModule {}

The split is the senior judgment: configure-once-globally → forRoot; register-per-feature → forFeature. A root call that you accidentally invoke twice creates duplicate singletons (two connection pools, two clients) — connection configuration must be a forRoot imported exactly once. A forFeature call, by contrast, is meant to appear in many modules, each registering its own slice.

ConfigurableModuleBuilder: stop writing the boilerplate

Hand-writing both forRoot and forRootAsync (plus the useClass/useExisting/useFactory variants of async) is repetitive and easy to get subtly wrong. Since Nest 9, ConfigurableModuleBuilder generates all of it. You give it your options interface; it hands back a ConfigurableModuleClass to extend and the MODULE_OPTIONS_TOKEN to inject.

// storage.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { StorageOptions } from './storage.constants';

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<StorageOptions>()
    .setClassMethodName('forRoot') // -> generates forRoot AND forRootAsync
    .build();
// storage.module.ts — now trivially small
import { Module } from '@nestjs/common';
import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './storage.module-definition';
import { StorageService } from './storage.service';

@Module({
  providers: [StorageService],
  exports: [StorageService],
})
export class StorageModule extends ConfigurableModuleClass {}

setClassMethodName('forRoot') makes the generated methods forRoot/forRootAsync (the default is register/registerAsync); .setExtras() lets you add fields like isGlobal that aren’t part of the options object. MODULE_OPTIONS_TOKEN is the same options token from the manual pattern — your StorageService injects it with @Inject(MODULE_OPTIONS_TOKEN), and the builder wires both the sync and async paths to it.

MethodOptions sourceSync / asyncScopeTypical use
forRootLiteral object passed by callerSynchronous (useValue)Once, app-wide (often global)Connection / client / secret singleton
forRootAsyncA provider via useFactory + injectAsynchronous (factory)Once, app-wide (often global)Options that come from ConfigService / async
forFeaturePer-import arguments (e.g. entities)SynchronousPer importing moduleRepositories / feature-scoped providers
plain @ModuleNone — fixed at author timeN/AWherever importedNo external configuration needed
Why this works

Why can’t you just inject ConfigService into the @Module() decorator and skip forRootAsync? Because the decorator’s metadata is evaluated at class-definition time — when the file is first loaded — long before the DI container has constructed anything. ConfigService is a runtime instance that only exists after ConfigModule initializes from .env. The factory in forRootAsync is the deferral mechanism: it’s a function, not a value, so Nest can hold it until the inject dependencies are built, then call it. That single layer of indirection — value becomes factory — is the whole reason async registration exists.

Pick the best fit

You're building a reusable StorageModule. The bucket and region differ per environment and are read from .env via ConfigService at startup. How should consumers configure it?

Quiz

Why does forRootAsync use a useFactory with an inject array instead of a plain options object?

Quiz

TypeOrmModule.forRoot(dbConfig) is in your root module. Where does TypeOrmModule.forFeature([User]) belong, and why?

Recall before you leave
  1. 01
    What is a DynamicModule, what fields does it return, and how does the options-token pattern let an inner provider depend on caller-supplied configuration?
  2. 02
    Distinguish forRoot, forRootAsync, and forFeature — when is each correct, and when is async mandatory rather than optional?
Recap

A dynamic module is a module computed at runtime: a static method returns the DynamicModule object — module, imports, providers, exports, and optionally global — and that one object is the mechanism behind every configurable library you import (TypeOrmModule, JwtModule, ConfigModule). You build one with the options-token pattern: define a token, have the static method provide the options bound to it, and have inner providers @Inject that token. forRoot(options) is synchronous and bakes a literal object into a useValue provider — right for configure-once, app-wide singletons, often global: true. forRootAsync({ imports, useFactory, inject }) provides the same token via a factory Nest resolves through DI, which is the only way to feed in options that come from another provider like ConfigService — it defers option creation until the inject dependencies exist, solving the chicken-and-egg of needing config before the module is constructed. forFeature(…) is the per-consumer tier, registering something scoped to the importing module (TypeOrmModule.forFeature([User]) gives that module its repository). The senior split is configure-once-globally (forRoot, run exactly once or you get duplicate singletons) versus register-per-feature (forFeature, repeated across modules). ConfigurableModuleBuilder generates the whole forRoot/forRootAsync/MODULE_OPTIONS_TOKEN boilerplate so you extend ConfigurableModuleClass and inject the token. Now when you see TypeOrmModule.forRootAsync or JwtModule.registerAsync in a codebase and need to wire ConfigService into them, you know exactly what’s happening under the hood — and how to build the same pattern yourself.

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.