open atlas
↑ К треку
NestJS с нуля до senior NEST · 03 · 02

Динамические модули: forRoot, forRootAsync, forFeature

DynamicModule возвращает своё определение в рантайме. forRoot конфигурирует один раз, forRootAsync берёт опции из DI (ConfigService), forFeature регистрирует на каждого потребителя. Свой модуль строится через options-токен; ConfigurableModuleBuilder генерирует бойлерплейт.

NEST Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Ты выносишь код загрузки в 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&lt;User>, а OrdersModuleRepository&lt;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)Синглтон соединения / клиента / секрета
forRootAsyncProvider через 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]) и почему?

Вспомните перед уходом
  1. 01
    Что такое DynamicModule, какие поля он возвращает и как паттерн options-токена позволяет внутреннему provider зависеть от конфигурации, переданной вызывающим?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.