Динамические модули: forRoot, forRootAsync, forFeature
DynamicModule возвращает своё определение в рантайме. forRoot конфигурирует один раз, forRootAsync берёт опции из DI (ConfigService), forFeature регистрирует на каждого потребителя. Свой модуль строится через options-токен; ConfigurableModuleBuilder генерирует бойлерплейт.
Ты выносишь код загрузки в S3 в общий StorageModule, чтобы три сервиса могли его переиспользовать. Первый потребитель хардкодит бакет и регион, выкатывается, работает. Второму нужен разный бакет на окружение — поэтому его значения лежат в ConfigService, который наполняется из .env на старте. И тут ты застрял: модулю нужно имя бакета, чтобы сконструировать provider с S3Client, но имя бакета живёт в provider, которого ещё не существует, пока модули не связаны. Нельзя сделать @Inject(ConfigService) в декоратор @Module() — это аннотация времени компиляции, а конфиг — значение рантайма. Эта проблема курицы и яйца ровно та, ради которой существует forRootAsync, и TypeOrmModule.forRootAsync, JwtModule.registerAsync и ConfigModule.forRoot — это одни и те же три строки под капотом.
DynamicModule — это модуль, вычисленный в рантайме
Когда ты импортируешь TypeOrmModule.forRoot(dbConfig) или JwtModule.register({ secret }), ты вызываешь не магию NestJS — ты вызываешь статический метод, который возвращает простой объект. Понимание этого объекта — то, что позволяет тебе строить переиспользуемые модули вместо копирования бойлерплейта.
Обычный @Module({...}) статичен: его список providers зафиксирован в момент написания. Динамический модуль — это модуль, чьё определение возвращается вызовом метода в рантайме. Контракт — простой объект, интерфейс DynamicModule (буквально «описание модуля, собранное в рантайме»), с такими полями:
import { DynamicModule } from '@nestjs/common';
interface DynamicModule {
module: Type<any>; // обязательно: класс-хост
imports?: any[]; // модули, нужные этому динамическому модулю
providers?: Provider[]; // providers для создания, возможно из опций
exports?: any[]; // что потребители могут инжектить
global?: boolean; // зарегистрировать один раз, доступно везде
controllers?: Type<any>[];
}Этот один рантайм-объект — весь механизм за каждой конфигурируемой библиотекой, которую ты импортируешь. TypeOrmModule.forRoot(dbConfig), JwtModule.register({ secret }), ConfigModule.forRoot() — каждый из них статический метод, возвращающий эту форму и запекающий твои опции в массив providers ещё до того, как Nest их инстанцирует. Соглашение — это имя метода: forRoot для глобальной конфигурации один-раз-на-приложение, register для конфигурации на каждый импорт, forFeature для расширений на каждого потребителя.
Базовый паттерн, чтобы построить такой модуль самому: определить options-токен, отдать значение опций из статического метода, а внутренним providers сделать @Inject этого токена. Токен — это шов: он позволяет provider зависеть от опций, которых не существует, пока потребитель не вызовет 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 теперь доступны
}forRoot vs forRootAsync: когда опции приходят из DI
forRoot(options) синхронный: вызывающий передаёт литеральный объект опций, статический метод оборачивает его в provider с useValue, готово. Используй его для конфигурируемых-один-раз синглтонов на всё приложение — соединение с БД, HTTP-клиент, секрет авторизации — обычно с global: true, чтобы любой модуль мог инжектить exports без повторного импорта.
// storage.module.ts — синхронный 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 }, // опции запечены как значение
StorageService,
],
exports: [StorageService],
};
}
}forRootAsync({ imports, useFactory, inject }) — для случая, когда сами опции должны быть произведены другим provider. Нельзя передать литеральный объект, потому что значения живут в ConfigService, менеджере секретов или другом асинхронном источнике. Вместо useValue options-токен отдаётся фабрикой, которую Nest разрешает через DI, — так и разрывается курица-и-яйцо: фабрика выполняется после того, как её зависимости из inject сконструированы.
// storage.module.ts — асинхронный 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, // выполнится ПОСЛЕ готовности inject-зависимостей
inject: options.inject || [],
};
return {
module: StorageModule,
global: true,
imports: options.imports || [], // например ConfigModule, чтобы ConfigService был инжектируем
providers: [optionsProvider, StorageService],
exports: [StorageService],
};
}
}// app.module.ts — связываем с 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: регистрация на каждого потребителя
forRoot/forRootAsync конфигурируют модуль один раз на всё приложение — соединение, синглтоны, глобальное состояние. forFeature(...) — второй ярус: он регистрирует нечто, ограниченное только импортирующим модулем, поверх существующей корневой конфигурации. Учебниковый пример — TypeORM: TypeOrmModule.forRoot(dbConfig) открывает одно соединение в корневом модуле; TypeOrmModule.forFeature([User]) затем регистрирует repository User для injector именно этого feature-модуля, так что UsersModule может инжектить Repository<User>, а OrdersModule — Repository<Order>, и ни один не видит репозитории другого.
@Module({
imports: [TypeOrmModule.forFeature([User])], // repo User, ограничен UsersModule
providers: [UsersService],
})
export class UsersModule {}Разделение — это сеньорское суждение: конфигурировать-один-раз-глобально → forRoot; регистрировать-на-фичу → forFeature. Корневой вызов, который ты случайно сделал дважды, создаёт дублирующиеся синглтоны (два пула соединений, два клиента) — конфигурация соединения должна импортироваться через forRoot ровно один раз. Вызов forFeature, наоборот, должен появляться во многих модулях, каждый регистрирует свой кусочек.
ConfigurableModuleBuilder: перестань писать бойлерплейт
Писать руками и forRoot, и forRootAsync (плюс варианты useClass/useExisting/useFactory для async) — повторяемо и легко ошибиться по мелочи. Начиная с Nest 9, ConfigurableModuleBuilder генерирует это всё. Ты даёшь ему интерфейс опций; он возвращает ConfigurableModuleClass для наследования и MODULE_OPTIONS_TOKEN для инжекта.
// 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') // -> генерирует forRoot И forRootAsync
.build();// storage.module.ts — теперь тривиально маленький
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') делает сгенерированные методы forRoot/forRootAsync (по умолчанию — register/registerAsync); .setExtras() позволяет добавить поля вроде isGlobal, не входящие в объект опций. MODULE_OPTIONS_TOKEN — тот же самый options-токен из ручного паттерна: твой StorageService инжектит его через @Inject(MODULE_OPTIONS_TOKEN), а builder связывает с ним и синхронный, и асинхронный пути.
| Метод | Источник опций | Sync / async | Область | Типичное применение |
|---|---|---|---|---|
| forRoot | Литеральный объект от вызывающего | Синхронно (useValue) | Один раз, на всё приложение (часто global) | Синглтон соединения / клиента / секрета |
| forRootAsync | Provider через useFactory + inject | Асинхронно (фабрика) | Один раз, на всё приложение (часто global) | Опции из ConfigService / async |
| forFeature | Аргументы на импорт (например entities) | Синхронно | На каждый импортирующий модуль | Репозитории / providers области фичи |
| обычный @Module | Нет — зафиксирован при написании | N/A | Везде, где импортирован | Внешняя конфигурация не нужна |
▸Почему это работает
Почему нельзя просто инжектить ConfigService в декоратор @Module() и пропустить forRootAsync? Потому что метаданные декоратора вычисляются во время определения класса — когда файл впервые загружается — задолго до того, как DI-контейнер что-либо сконструирует. ConfigService — рантайм-инстанс, который существует только после того, как ConfigModule инициализируется из .env. Фабрика в forRootAsync — это механизм отложенности: это функция, а не значение, поэтому Nest может придержать её, пока не построены зависимости из inject, и затем вызвать. Этот один слой косвенности — значение становится фабрикой — и есть вся причина, по которой существует асинхронная регистрация.
Ты строишь переиспользуемый StorageModule. Бакет и регион различаются по окружениям и читаются из .env через ConfigService на старте. Как потребители должны его конфигурировать?
Почему forRootAsync использует useFactory с массивом inject, а не простой объект опций?
TypeOrmModule.forRoot(dbConfig) стоит в твоём корневом модуле. Куда принадлежит TypeOrmModule.forFeature([User]) и почему?
- 01Что такое DynamicModule, какие поля он возвращает и как паттерн options-токена позволяет внутреннему provider зависеть от конфигурации, переданной вызывающим?
- 02Различи forRoot, forRootAsync и forFeature — когда каждый верен и когда async обязателен, а не опционален?
Динамический модуль — это модуль, вычисленный в рантайме: статический метод возвращает объект DynamicModule — module, imports, providers, exports и опционально global — и этот один объект есть механизм за каждой конфигурируемой библиотекой, которую ты импортируешь (TypeOrmModule, JwtModule, ConfigModule). Свой строится через паттерн options-токена: определи токен, пусть статический метод отдаст опции, привязанные к нему, а внутренние providers сделают @Inject этого токена. forRoot(options) синхронный и запекает литеральный объект в provider с useValue — верно для конфигурируемых-один-раз синглтонов на всё приложение, часто global: true. forRootAsync({ imports, useFactory, inject }) отдаёт тот же токен через фабрику, которую Nest разрешает через DI, — это единственный способ подать опции, приходящие из другого provider вроде ConfigService — он откладывает создание опций, пока inject-зависимости не существуют, решая курицу-и-яйцо, когда конфиг нужен до конструирования модуля. forFeature(…) — ярус на каждого потребителя, регистрирующий нечто, ограниченное импортирующим модулем (TypeOrmModule.forFeature([User]) даёт этому модулю его repository). Сеньорское разделение — конфигурировать-один-раз-глобально (forRoot, выполнить ровно один раз, иначе получишь дублирующиеся синглтоны) против регистрировать-на-фичу (forFeature, повторяется по модулям). ConfigurableModuleBuilder генерирует весь бойлерплейт forRoot/forRootAsync/MODULE_OPTIONS_TOKEN, так что ты наследуешь ConfigurableModuleClass и инжектишь токен. Теперь, когда встретишь TypeOrmModule.forRootAsync или JwtModule.registerAsync в кодовой базе и нужно будет подключить туда ConfigService, ты будешь точно знать, что происходит под капотом — и как построить тот же паттерн самому.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.