Що таке динамічні модулі в NestJS і коли їх використовувати?
Динамічний модуль (dynamic module) у NestJS - це модуль, який повертає налаштований об'єкт DynamicModule зі статичного методу на кшталт forRoot(), що дозволяє передавати конфігурацію під час імпорту, а не вшивати її в код.
Теорія
TL;DR
- Статичний модуль - це готовий набір LEGO. Динамічний - це фабрика: надсилаєш інструкції, отримуєш набір під конкретне завдання.
- Статичні модулі мають фіксований декоратор
@Module({})і однакові провайдери при кожному імпорті. Динамічні генерують метадані на льоту на основі переданих опцій. - Конфіг відрізняється між середовищами - використовуй dynamic. Спільна логіка без конфігурації - достатньо static.
- Стандартне іменування:
forRoot()для глобального налаштування одного разу,forFeature()для конфігурації на рівні модуля,forRootAsync()коли опції приходять з іншого сервісу. - Об'єкт, що повертається, завжди має містити ключ
module. Без нього DI-граф не збудується, і помилка вказує в інше місце.
Швидкий приклад
// Статичний: провайдери завжди однакові
@Module({ providers: [LoggerService] })
export class LoggerModule {}
// Динамічний: провайдери залежать від переданих аргументів
@Module({})
export class LoggerModule {
static forRoot(level: 'error' | 'debug' | 'verbose'): DynamicModule {
return {
module: LoggerModule,
providers: [
{ provide: 'LOG_LEVEL', useValue: level }, // значення з зовнішнього коду
LoggerService,
],
exports: [LoggerService],
};
}
}
// Використання
@Module({
imports: [LoggerModule.forRoot('debug')],
})
export class AppModule {}LoggerModule.forRoot('debug') виконується під час імпорту і повертає звичайний об'єкт, який NestJS сприймає як метадані модуля. Токен LOG_LEVEL тепер доступний для ін'єкції в будь-якому місці модуля.
Статичний проти динамічного
Статичні модулі мають фіксований декоратор @Module({}) і незмінні провайдери. Це нормально для утилітних сервісів без конфігурації. Динамічні викликають статичний метод, який повертає об'єкт DynamicModule з опціями, переданими ззовні. Це дозволяє мати різні конфігурації одного модуля в різних частинах застосунку, що вирішує проблему спільного стану в мультитенантних або конфігурованих бібліотеках.
Практичний наслідок: зі статичними модулями немає зручного місця для креденшалів БД. Або хардкодиш, або тягнешся до глобальних змінних. Динамічні модулі дають чистий API для цього.
Коли використовувати
- Конфіг відрізняється між середовищами:
forRoot()з об'єктом опцій (хост, порт, пароль) - Опції приходять з іншого сервісу, наприклад
ConfigService:forRootAsync()з фабричною функцією і масивомinject - Налаштування сутностей або черг на рівні модуля:
forFeature(), наприкладTypeOrmModule.forFeature([User]) - Спільний сервіс без конфігурації: достатньо статичного модуля
- Тести з підробленою конфігурацією: dynamic дозволяє передати mock-опції без зміни коду
Як NestJS обробляє динамічні модулі
Під час бутстрапу NestModuleLoader.create() проходить по кожному запису в imports[]. Якщо запис - це виклик функції на кшталт LoggerModule.forRoot('debug'), він виконується синхронно і повертає звичайний об'єкт DynamicModule. NestJS зливає providers, imports і exports цього об'єкта в глобальний граф метаданих у ModulesContainer. Все це відбувається до того, як будь-які провайдери починають резолвитись. Після бутстрапу жодних додаткових витрат немає.
forRootAsync() відрізняється: NestJS реєструє провайдер useFactory звичайним чином, але відкладає його виклик до того, як стануть доступні всі залежності з масиву inject.
Типові помилки
Забутий ключ module у об'єкті, що повертається
// Неправильно
static forRoot(opts: Options) {
return { providers: [{ provide: 'CONFIG', useValue: opts }] }; // немає module
}
// Правильно
static forRoot(opts: Options): DynamicModule {
return {
module: MyModule, // обов'язково
providers: [{ provide: 'CONFIG', useValue: opts }],
exports: ['CONFIG'],
};
}Без ключа module NestJS не може зареєструвати метадані. Помилка "Nest can't resolve dependencies" зазвичай вказує в інше місце, тому знайти причину непросто - це один з тих багів, що з'їдає 20 хвилин дебагу.
Мутація об'єкта опцій
// Неправильно
static forRoot(opts: Options) {
opts.host = 'overridden'; // мутує об'єкт з боку викликаючого коду
return { module: MyModule, providers: [{ provide: 'CONFIG', useValue: opts }] };
}
// Правильно
static forRoot(opts: Options): DynamicModule {
return {
module: MyModule,
providers: [{ provide: 'CONFIG', useValue: { ...opts } }], // спред або глибокий клон
};
}Якщо два місця у застосунку викликають forRoot() з різними опціями, мутація призводить до взаємного впливу конфігурацій. Спред-копія або structuredClone вирішують проблему.
Експорти в декораторі класу замість об'єкта, що повертається
// Неправильно
@Module({ exports: [Service] }) // спрацьовує навіть без виклику forRoot
export class MyModule {
static forRoot(): DynamicModule {
return { module: MyModule };
}
}
// Правильно
@Module({})
export class MyModule {
static forRoot(): DynamicModule {
return { module: MyModule, providers: [Service], exports: [Service] };
}
}Декоратор класу виконується безумовно. Модулі, які ніколи не викликають forRoot(), все одно отримують ці експорти, що спричиняє приховані баги в мультитенантних застосунках.
Використання forRoot у feature-модулях
forRoot призначений для глобальної кореневої конфігурації, яку викликають один раз в AppModule. Виклик у feature-модулі створює дублікати провайдерів у DI-контейнері. Feature-модулі мають використовувати forFeature().
Забутий inject у forRootAsync
// Неправильно - config буде undefined під час виконання
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => config.getDbOptions(),
// inject відсутній
});
// Правильно
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => config.getDbOptions(),
inject: [ConfigService], // обов'язково, порядок має відповідати параметрам фабрики
});Найпоширеніша помилка серед тих, хто тільки знайомиться з async-конфігурацією. Фабрика отримує undefined замість аргументів і падає в runtime. TypeScript це не відловить.
Де зустрічається в реальному коді
@nestjs/typeorm:TypeOrmModule.forRoot({ host, entities })в AppModule,TypeOrmModule.forFeature([User])у кожному feature-модулі@nestjs/jwt:JwtModule.register({ secret })для підпису токенів@nestjs/bull:BullModule.forRoot({ redis })для черг,BullModule.forFeature(Processor)на тип задачі@nestjs/config:ConfigModule.forRoot()завантажує.envглобально@nestjs/mongoose:MongooseModule.forRoot('mongodb://...'), потімMongooseModule.forFeature([Cat])у кожному модулі
Кожна велика інтеграційна бібліотека NestJS використовує цей патерн. Якщо розумієш dynamic modules, читати вихідний код будь-якої з них стає значно простіше.
Follow-up питання
Q: Який тип повертає forRoot() і які поля він приймає?
A: Повертається інтерфейс DynamicModule: { module: Type<any>, providers?: Provider[], imports?: any[], exports?: any[], global?: boolean }. Обов'язкове поле - тільки module.
Q: Яка різниця між forRoot() і forRootAsync()?
A: forRoot() приймає синхронні опції напряму як аргументи. forRootAsync() приймає функцію useFactory з масивом inject, тому NestJS спочатку резолвить залежності на кшталт ConfigService, а потім передає їх у фабрику.
Q: Що буде, якщо викликати forRoot() двічі для одного модуля?
A: NestJS об'єднує провайдери в одному екземплярі модуля. Для єдиної глобальної конфігурації встанови global: true. Якщо потрібні ізольовані екземпляри, використовуй forFeature().
Q: Чи може dynamic module включати інші dynamic modules у свій imports?
A: Так. Можна вкладати виклики forFeature() в масив imports об'єкта, що повертається forRoot(). Саме так TypeORM вбудовує репозиторії сутностей всередину кореневого модуля з'єднання.
Q: (Senior) Що станеться, якщо метод forRoot() кине помилку під час бутстрапу?
A: Застосунок не запуститься з винятком бутстрапу. Запусти nest start --debug і дивись стек від ModulesContainer.register(). Додай console.log перед рядком return у динамічному методі, щоб побачити який саме об'єкт отримує NestJS. Циклічні залежності між модулями, що обидва використовують forRootAsync, - ще одна поширена причина тихих збоїв.
Q: Коли варто взагалі не використовувати dynamic module і обійтись звичайним провайдером?
A: Для одноразових сценаріїв без потреби в повторному використанні. Dynamic module додає накладні витрати в плані проектування API. Якщо один застосунок і одна БД, фабричний провайдер прямо в AppModule простіший і цілком нормальний підхід.
Приклади
Базовий: конфігурований модуль логування
import { Module, DynamicModule } from '@nestjs/common';
type LogLevel = 'error' | 'debug' | 'verbose';
@Module({})
export class LoggerModule {
static forRoot(level: LogLevel): DynamicModule {
return {
module: LoggerModule,
providers: [
{ provide: 'LOG_LEVEL', useValue: level },
LoggerService, // отримує LOG_LEVEL через @Inject('LOG_LEVEL') в конструкторі
],
exports: [LoggerService],
};
}
}
@Module({
imports: [LoggerModule.forRoot('debug')],
})
export class AppModule {}LoggerService отримує рівень 'debug' через конструктор за допомогою @Inject('LOG_LEVEL'). Змінивши аргумент на 'error' у продакшені, не торкаєшся жодного сервісного коду.
Середній: модуль бази даних з async-конфігурацією
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({})
export class DatabaseModule {
static forRootAsync(options: {
imports?: any[];
useFactory: (...args: any[]) => Promise<{ host: string; port: number }>;
inject?: any[];
}): DynamicModule {
return {
module: DatabaseModule,
imports: options.imports || [],
providers: [
{
provide: 'DB_CONFIG',
useFactory: options.useFactory,
inject: options.inject || [],
},
DatabaseService,
],
exports: [DatabaseService],
};
}
}
@Module({
imports: [
ConfigModule.forRoot(),
DatabaseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
host: config.get<string>('DB_HOST'),
port: config.get<number>('DB_PORT'),
}),
inject: [ConfigService], // порядок має відповідати параметрам useFactory
}),
],
})
export class AppModule {}DB_CONFIG резолвиться тільки після того, як ConfigService готовий. Прибери масив inject - аргумент config стає undefined в runtime, і TypeScript це не відловить, бо підпис фабрики приймає any[].
Просунутий: forFeature для бакетів сховища
@Module({})
export class StorageModule {
static forRoot(options: { provider: string; region: string }): DynamicModule {
return {
module: StorageModule,
global: true, // один спільний StorageService на весь застосунок
providers: [
{ provide: 'STORAGE_OPTIONS', useValue: { ...options } },
StorageService,
],
exports: [StorageService],
};
}
static forFeature(bucket: string): DynamicModule {
return {
module: StorageModule,
providers: [
{
provide: `STORAGE_BUCKET_${bucket.toUpperCase()}`,
useFactory: (storage: StorageService) => storage.getBucket(bucket),
inject: [StorageService], // бере глобальний StorageService
},
],
exports: [`STORAGE_BUCKET_${bucket.toUpperCase()}`],
};
}
}
// Кожен feature-модуль отримує свій скопований клієнт бакета
@Module({
imports: [StorageModule.forFeature('avatars')],
})
export class UsersModule {}
@Module({
imports: [StorageModule.forFeature('documents')],
})
export class DocumentsModule {}StorageService реєструється глобально один раз через forRoot. Кожен виклик forFeature створює скопований провайдер, що обгортає спільний сервіс з конкретною назвою бакета. Дублювання з'єднання немає.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.