Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке динамічні модулі в NestJS і коли їх використовувати?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Динамічний модуль (dynamic module) у NestJS** - це модуль, який повертає об'єкт `DynamicModule` зі статичного методу типу `forRoot()`, дозволяючи передавати конфігурацію під час імпорту. ```typescript @Module({}) export class LoggerModule { static forRoot(level: 'error' | 'debug'): DynamicModule { return { module: LoggerModule, providers: [{ provide: 'LOG_LEVEL', useValue: level }, LoggerService], exports: [LoggerService], }; } } ``` **Ключове:** використовуй dynamic modules, коли конфіг відрізняється між середовищами або екземплярами застосунку. Для провайдерів без конфігурації достатньо статичного модуля.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Динамічний модуль (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` створює скопований провайдер, що обгортає спільний сервіс з конкретною назвою бакета. Дублювання з'єднання немає.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.