Динамические модули и асинхронная конфигурация
У статического @Module набор провайдеров фиксирован; динамический модуль — класс, чей static-метод возвращает дескриптор DynamicModule в рантайме, чтобы импортёр передал конфиг. forRoot один раз, forFeature на срез, register на импорт, forRootAsync — для конфига из провайдера.
Пейджер сработал в 2 ночи: FATAL: sorry, too many clients already. Postgres упёрся в свой max_connections равный 100, отказывал новым логинам, и каждый под в деплое уходил в краш-луп на старте. Схема не менялась, трафик ровный. Диф, выехавший накануне, добавил DatabaseModule.forRoot({ poolSize: 10 }) в фиче-модуль, чтобы фоновая задача «переиспользовала соединение». Но forRoot() уже был вызван один раз в AppModule. Повторный вызов ничего не переиспользовал — он зарегистрировал провайдер соединения второй раз, поднял второй пул из 10, и на пяти подах это тихо удвоило простаивающие соединения кластера с 50 до 100. Тот общий синглтон, который команда думала, что у неё есть, всё это время был двумя инстансами. Этот урок — про машинерию под forRoot, forFeature, register и асинхронным конфигом, и про режимы отказа, которые возникают, когда ты их путаешь.
Фиксированный набор против рантайм-дескриптора
У статического @Module({ providers, exports }) набор провайдеров фиксирован, решён в момент определения класса. Он не может принять конфигурацию — imports: [CacheModule] даёт тебе то, что CacheModule решил за себя сам.
Динамический модуль — это класс со static-методом, который возвращает дескриптор DynamicModule в рантайме: { module, providers, imports?, exports?, global? }. Импортёр вызывает этот метод и передаёт конфиг, а Nest вклеивает возвращённые провайдеры в граф. Именно так работают ConfigModule.forRoot({ ... }), JwtModule.register({ ... }) и TypeOrmModule.forFeature([ ... ]) — каждый вызов есть вызов метода, собирающий заточенный модуль прямо на месте.
import { DynamicModule, Module } from '@nestjs/common';
export const STORAGE_OPTIONS = 'STORAGE_OPTIONS';
export interface StorageOptions { bucket: string; region: string; }
@Module({})
export class StorageModule {
// hand-rolled dynamic module: the importer passes config, we turn it into a provider
static forRoot(options: StorageOptions): DynamicModule {
return {
module: StorageModule,
providers: [{ provide: STORAGE_OPTIONS, useValue: options }],
exports: [STORAGE_OPTIONS], // so consumers can inject the options token
global: true, // exports visible app-wide without re-importing
};
}
}StorageModule.forRoot({ bucket: 'uploads', region: 'eu-central-1' }) возвращает полностью сформированный модуль, единственный провайдер которого — объект опций, экспортированный под токеном, который инжектят нижестоящие сервисы. Оболочка @Module({}) пуста намеренно — настоящий конструктор тут метод.
Конвенция имён: forRoot, forFeature, register
Три имени метода — это конвенция, не навязанная Nest, — но каждый Nest-разработчик читает их как контракт, так что нарушение её есть выстрел себе в ногу.
forRoot()— настроить один раз, в корне приложения. Обычно возвращает общую инфраструктуру (соединение, хранилище конфига) и частоglobal. Вызов более одного раза — это и есть баг из хука.forFeature()— зарегистрировать срез, многократно, на фиче-модуль.TypeOrmModule.forFeature([User])в одном модуле,forFeature([Order])в другом — каждый вызов скоупит репозитории в этот модуль, не пересоздавая соединение, которое уже сделалforRoot.register()— конфигурация на импорт, без root/global-семантики. КаждыйHttpModule.register({ baseURL })независим; два импорта означают два отдельно сконфигурированных инстанса, и это задумано.
// forRoot ONCE, at the root — stands up the shared connection (global)
imports: [DatabaseModule.forRoot({ url: process.env.DB_URL, poolSize: 10 })]
// forFeature PER module — scopes entities/repos, reuses the forRoot connection
imports: [DatabaseModule.forFeature([User])] // in UsersModule
imports: [DatabaseModule.forFeature([Order])] // in OrdersModule
// register PER import — each gets its own independent config, no sharing implied
imports: [HttpModule.register({ baseURL: 'https://api.billing.internal' })]Вместе три имени кодируют намерение по шарингу: forRoot — «один общий пул на всё приложение», forFeature — «срез этого пула, на модуль», register — «независимый инстанс, без шаринга». Когда видишь, что коллега вызывает forRoot в фиче-модуле, — это инцидент в 2 ночи, ждущий своего часа: само имя несёт контракт.
▸Почему это работает
Почему вызов forRoot() дважды удваивает пул вместо переиспользования соединения? Потому что forRoot() — это обычная функция, которая возвращает свежий дескриптор провайдера каждый раз. Первый вызов регистрирует { provide: DB_POOL, useFactory: makePool } в AppModule; второй вызов возвращает тот же объект-дескриптор снова, и Nest регистрирует его второй раз в инжекторе фиче-модуля — useFactory отрабатывает заново, открывая второй физический пул. Нет никакой проверки идентичности, которая сказала бы «этот провайдер уже есть, пропусти». @Global тебя тоже не спасает: global меняет только видимость экспортов, а не кардинальность провайдеров. Два вызова forRoot = два прогона фабрики = два пула, точка. Ментальная модель общего синглтона — это ложь, которую второй forRoot тихо ломает; фикс — вызывать forRoot ровно один раз, а всем ниже по графу делать forFeature или инжектить экспортированный токен.
Асинхронный конфиг: forRootAsync и провайдер-фабрика
Самописный forRoot выше принимает литеральный объект опций. Но настоящий конфиг — URL базы, JWT-секрет — лежит в env и читается через ConfigService. Ты не можешь передать литерал в момент импорта; тебе нужно значение, разрезолвленное из другого провайдера. Это forRootAsync:
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
JwtModule.registerAsync({
imports: [ConfigModule], // make ConfigService resolvable IN this context
inject: [ConfigService], // the factory's dependencies, in order
useFactory: (cfg: ConfigService) => ({
secret: cfg.getOrThrow('JWT_SECRET'),
signOptions: { expiresIn: cfg.get('JWT_TTL') ?? '15m' },
}),
});Три части работают вместе. useFactory сам является провайдером — функцией, которую Nest вызывает, чтобы произвести опции. inject перечисляет зависимости этой функции, позиционно, чтобы Nest знал, что нужно разрезолвить ConfigService и передать его первым аргументом. imports делает эти зависимости резолвимыми внутри собственного контекста инъекции динамического модуля — у динамического модуля свой скоуп, так что, если ConfigModule не глобальный, фабрика не увидит ConfigService без импорта его здесь.
Забудь imports: [ConfigModule] (а ConfigModule не глобальный) — и получишь падение на старте, а не в рантайме: Nest can't resolve dependencies of the <factory> (?). Please make sure that the argument ConfigService at index [0] is available in the <module> context. (?) — это неразрезолвленный ConfigService: фабрика объявила его через inject, но ничего в скоупе его не провайдит.
ConfigurableModuleBuilder: хватит писать руками
Писать forRoot и forRootAsync руками — литеральный путь, путь через фабрику, async-варианты useExisting/useClass, токен опций — это ~40 строк бойлерплейта на модуль, повторяемых идентично в каждом конфигурируемом модуле, которым ты владеешь. ConfigurableModuleBuilder генерирует всё это:
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { StorageOptions } from './storage-options.interface';
// generates the class + token + sync AND async entry points for you
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<StorageOptions>().build();import { Module } from '@nestjs/common';
import { ConfigurableModuleClass } from './storage.module-definition';
@Module({ providers: [StorageService], exports: [StorageService] })
export class StorageModule extends ConfigurableModuleClass {}
// now StorageModule.register({...}) AND StorageModule.registerAsync({ useFactory, inject, imports })
// both exist, and StorageService can inject MODULE_OPTIONS_TOKENБилдер отдаёт обратно ConfigurableModuleClass (наследуешь его) и MODULE_OPTIONS_TOKEN (инжектишь, чтобы прочитать опции). Ты получаешь и register, и registerAsync бесплатно, с уже разведённой async-обвязкой imports/inject/useFactory. Хочешь именование forRoot вместо register? .setClassMethodName('forRoot'). Хочешь глобальный? .setExtras({ isGlobal: true }, ...). Те ~40 строк на модуль схлопываются до двух.
Твоему AuthModule нужен секрет подписи JWT из окружения, читаемый через ConfigService. Как сконфигурировать JwtModule?
В JwtModule.registerAsync({ imports, inject, useFactory }) что делают `inject` и `imports` каждый — и почему ты получишь 'Nest can't resolve dependencies of the useFactory (?)' на старте?
Команда помечает DatabaseModule @Global() и вызывает DatabaseModule.forRoot() и в AppModule, и в фиче-модуле, ожидая, что @Global дедуплицирует его в одно общее соединение. Что происходит на самом деле?
- 01Что такое динамический модуль против статического, и что сигналят имена forRoot / forFeature / register каждое? Почему вызов forRoot дважды вызывает инцидент с пулом соединений?
- 02Как forRootAsync превращает инжектированный провайдер в конфиг, и что генерирует ConfigurableModuleBuilder? Почему 'Nest can't resolve dependencies of the useFactory' — ошибка времени старта?
У статического @Module набор провайдеров фиксирован, решён в момент определения, и он не может быть сконфигурирован импортёром. Динамический модуль это чинит: это класс, чей статический метод возвращает дескриптор DynamicModule — { module, providers, imports?, exports?, global? } — в рантайме, так что вызывающий передаёт конфиг, а Nest вклеивает провайдеры. Это машинерия за ConfigModule.forRoot, JwtModule.register и TypeOrmModule.forFeature. Имена — конвенция, не навязанная: forRoot настраивает один раз в корне и возвращает общую инфру (часто global); forFeature регистрирует срез многократно на фичу, переиспользуя соединение forRoot; register — конфиг на импорт без global-семантики, так что каждый импорт независим. Первый режим отказа: вызов forRoot дважды ничего не переиспользует — это функция, возвращающая свежий дескриптор провайдера каждый раз, так что фабрика отрабатывает заново и открывается второй пул соединений, что на нескольких подах может удвоить простаивающие соединения и исчерпать Postgres max_connections (по умолчанию 100); @Global тебя не спасёт, потому что он меняет видимость экспортов, не кардинальность провайдеров. Для env-зависимого конфига используй forRootAsync({ imports, inject, useFactory }): useFactory — провайдер, производящий опции, inject перечисляет его позиционные зависимости, а imports делает эти зависимости резолвимыми в собственном скоупе динамического модуля — забудь imports: [ConfigModule], и получишь на старте ‘Nest can’t resolve dependencies of the useFactory (?)’. ConfigurableModuleBuilder генерирует ConfigurableModuleClass и MODULE_OPTIONS_TOKEN плюс и register, и registerAsync, схлопывая ~40 строк обвязки async-провайдеров на модуль до двух. Сеньорская позиция по @Global: береги его для по-настоящему сквозной инфры (Config, Logger, DB) — злоупотребление прячет зависимости и превращает DI-граф в клубок, где модули молча используют провайдеры, которые никогда не импортируют. Теперь, когда увидишь инцидент с исчерпанием пула соединений, — пройдись по дереву модулей в поисках forRoot, вызванного больше одного раза: именно второй вызов рожает второй пул.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.