Skip to main content

Що таке динамічні модулі в NestJS і коли їх використовувати?

Динамічний модуль (dynamic module) у NestJS - це модуль, який повертає налаштований об'єкт DynamicModule зі статичного методу на кшталт forRoot(), що дозволяє передавати конфігурацію під час імпорту, а не вшивати її в код.

Теорія

TL;DR

  • Статичний модуль - це готовий набір LEGO. Динамічний - це фабрика: надсилаєш інструкції, отримуєш набір під конкретне завдання.
  • Статичні модулі мають фіксований декоратор @Module({}) і однакові провайдери при кожному імпорті. Динамічні генерують метадані на льоту на основі переданих опцій.
  • Конфіг відрізняється між середовищами - використовуй dynamic. Спільна логіка без конфігурації - достатньо static.
  • Стандартне іменування: forRoot() для глобального налаштування одного разу, forFeature() для конфігурації на рівні модуля, forRootAsync() коли опції приходять з іншого сервісу.
  • Об'єкт, що повертається, завжди має містити ключ module. Без нього DI-граф не збудується, і помилка вказує в інше місце.

Швидкий приклад

typescript
// Статичний: провайдери завжди однакові @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 у об'єкті, що повертається

typescript
// Неправильно 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 хвилин дебагу.

Мутація об'єкта опцій

typescript
// Неправильно 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 вирішують проблему.

Експорти в декораторі класу замість об'єкта, що повертається

typescript
// Неправильно @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

typescript
// Неправильно - 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 простіший і цілком нормальний підхід.

Приклади

Базовий: конфігурований модуль логування

typescript
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-конфігурацією

typescript
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 для бакетів сховища

typescript
@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 створює скопований провайдер, що обгортає спільний сервіс з конкретною назвою бакета. Дублювання з'єднання немає.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?