Що таке модулі в NestJS і як вони працюють?
NestJS модуль - це TypeScript-клас з декоратором @Module(), який групує пов'язані контролери, провайдери та підмодулі в одну область видимості для dependency injection (DI).
Теорія
TL;DR
- Модуль як контейнер: тримає своє (controllers, providers), каже що йому потрібно (
imports) і що дає назовні (exports) - Провайдери приватні за замовчуванням - нічого не витікає без явного
exports - Один функціональний домен = один модуль (
UsersModule,OrdersModule).AppModuleтільки імпортує їх providersреєструє клас у DI-скопі модуля;exportsробить частину провайдерів доступними для імпортерів- Кругові залежності вирішуються через
forwardRef()
Швидкий приклад
// users/users.module.ts
@Module({
controllers: [UsersController], // обробляє /users-маршрути
providers: [UsersService], // видно тільки всередині цього модуля
exports: [UsersService], // тепер інші модулі можуть ін'єктувати це
})
export class UsersModule {}
// app.module.ts
@Module({
imports: [UsersModule], // підтягує те, що UsersModule експортує
})
export class AppModule {}При старті NestJS сканує AppModule, завантажує UsersModule і автоматично підключає UsersService до UsersController. Ендпоінти /users стають активними. Без exports - жоден імпортер нічого не отримує.
Скоп залежностей
Провайдер живе тільки всередині модуля, який його оголошує. Якщо EmailsModule потрібен UsersService, він мусить імпортувати UsersModule, а UsersModule мусить експортувати UsersService. Цей контракт явний і перевіряється при запуску.
В звичайному Express ти просто require() що завгодно звідки завгодно. Для маленького проекту нормально, для великого - незрозуміло хто що використовує. NestJS робить залежності видимими.
Типи модулів
Функціональний модуль (feature module) групує один домен. Він має власні контролери та сервіси, і експортує те, що потрібне іншим модулям.
Спільний модуль (shared module) тримає утиліти, що використовуються скрізь: DatabaseService, EmailService, LoggerService. Будь-який модуль, що імпортує SharedModule, отримує доступ до всього що той експортує.
Глобальний модуль (з декоратором @Global()) реєструє провайдери на рівні всього застосунку - жодному модулю не потрібно явно його імпортувати. Зручно для конфігурації чи логування. Але якщо зловживати, залежності стають невидимими: не зрозуміло з imports що модуль насправді використовує.
Динамічний модуль (dynamic module) приймає конфігурацію під час виконання. TypeOrmModule.forRoot(options) - класичний приклад. Статичний метод повертає об'єкт DynamicModule, що дозволяє передати параметри підключення або ключі під час імпорту.
Як будується граф модулів
При запуску NestJS читає всі декоратори @Module() і будує направлений ациклічний граф (DAG) залежностей. imports розгортаються рекурсивно, поки все дерево не відоме. Потім провайдери ініціалізуються через рефлексію TypeScript-метаданих (reflect-metadata).
IoC-контейнер зіставляє токени @Inject() з провайдерами у вирішеному скопі. Якщо токен не знайдено - отримуєш UnknownInjectionToken ще до першого запиту. Це навмисно: краще впасти при старті, ніж у три ночі в продакшені.
Динамічний модуль на практиці
// config/config.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
static forRoot(configPath: string): DynamicModule {
return {
module: ConfigModule,
global: true, // доступно всюди без явних імпортів
providers: [
{ provide: 'CONFIG_PATH', useValue: configPath },
ConfigService,
],
exports: [ConfigService],
};
}
}
// app.module.ts
@Module({
imports: [ConfigModule.forRoot('prod.env')],
})
export class AppModule {}global: true робить ConfigService доступним всюди. Без нього сервіс доступний тільки модулям, які явно імпортують ConfigModule. Обидва підходи працюють - вибір залежить від того наскільки широко потрібен сервіс.
Типові помилки
Забути exports - найчастіша помилка, особливо після перенесення сервісу в новий модуль:
// users.module.ts - НЕПРАВИЛЬНО: немає exports
@Module({ providers: [UsersService] })
export class UsersModule {}
// emails.module.ts імпортує UsersModule, але:
constructor(private usersService: UsersService) {}
// Error: Nest can't resolve dependencies of EmailsService.
// No provider for UsersService!Виправлення: додай exports: [UsersService] до UsersModule. Самого імпорту модуля недостатньо.
Кругові залежності виникають коли UsersModule імпортує EmailsModule, а той імпортує UsersModule. NestJS кидає помилку. Рішення через forwardRef():
// users.module.ts
@Module({
imports: [forwardRef(() => EmailsModule)],
})
export class UsersModule {}Складати все в AppModule - типовий патерн у джунів. 30-50 провайдерів у кореневому модулі означають неможливість ізольованого тестування і болючі рефакторинги. AppModule має тільки імпортувати функціональні модулі, а не оголошувати сервіси напряму.
global: true на кожному модулі - швидке рішення для "мій сервіс не ін'єктується". Працює, але приховує залежності. Правильно: явно експортуй і імпортуй там де потрібно.
Де зустрічається
nest newгенеруєAppModuleз імпортом функціональних модулів одразу@nestjs/passportпоставляється якPassportModuleзі стандартним синтаксисом імпорту- Prisma-інтеграція живе в спільному
PrismaModule, що експортується до всіх функціональних модулів TypeOrmModule.forRoot()у кореневому модулі таTypeOrmModule.forFeature([Entity])у кожному функціональному - канонічний приклад динамічного модуля- Мікросервісні транспорти (TCP, Redis, Kafka) використовують ту саму структуру
@Module()
Питання на співбесіді
Q: Яка різниця між providers і exports?
A: providers реєструє класи в DI-скопі цього модуля. exports робить частину з них доступною для інших модулів. Провайдер без exports залишається приватним.
Q: Як NestJS вирішує токени ін'єкції?
A: За класом (найчастіше) або за рядковим чи символьним токеном з @Inject('TOKEN'). NestJS шукає токен у графі модулів і кидає UnknownInjectionToken якщо не знаходить.
Q: Коли варто використовувати global: true?
A: Для сінглтонів, потрібних всюди: конфіг, логування. Для доменних сервісів - ні. Явні імпорти роблять залежності видимими і спрощують відлагодження.
Q: Як модулі допомагають у тестуванні?
A: У юніт-тестах створюється TestingModule, де реальні імпорти замінюються моками. Межа модуля дозволяє підключати тільки ті залежності, які потрібні тестованому класу.
Q: Побудуй динамічний модуль з асинхронним завантаженням конфіга через forRootAsync.
A: Використовуй useFactory з async-підтримкою у поверненому DynamicModule. Фабрика отримує залежності через inject і повертає об'єкт конфігурації. NestJS чекає на проміс перед тим як завершити ініціалізацію модуля і почати приймати запити.
Приклади
Функціональний модуль: CRUD користувачів
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // AuthModule зможе ін'єктувати для перевірки юзерів
})
export class UsersModule {}
// users/users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private users = [{ id: 1, name: 'Alice' }];
findById(id: number) {
return this.users.find(u => u.id === id);
}
}
// users/users.controller.ts
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
getUser(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findById(id); // DI підключає сервіс автоматично
}
}UsersService оголошений у UsersModule і ін'єктується в UsersController без жодного new UsersService(). Контролер просто вказує залежність у конструкторі.
Спільний модуль для доступу до бази даних
// database/database.module.ts
import { Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Module({
providers: [DatabaseService],
exports: [DatabaseService], // будь-який імпортер отримує доступ
})
export class DatabaseModule {}
// users/users.module.ts
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [DatabaseModule], // підтягує DatabaseService
controllers: [UsersController],
providers: [UsersService], // тепер UsersService може ін'єктувати DatabaseService
})
export class UsersModule {}
// users/users.service.ts
@Injectable()
export class UsersService {
constructor(private readonly db: DatabaseService) {}
findAll() {
return this.db.query('SELECT * FROM users');
}
}DatabaseService оголошений один раз і використовується в UsersModule, OrdersModule чи будь-якому іншому модулі що імпортує DatabaseModule. Одне визначення - багато споживачів.
Динамічний модуль з асинхронною фабрикою
// config/config.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
static forRootAsync(options: {
useFactory: (...args: any[]) => Promise<Record<string, string>>;
inject?: any[];
}): DynamicModule {
return {
module: ConfigModule,
global: true,
providers: [
{
provide: 'CONFIG_OPTIONS',
useFactory: options.useFactory,
inject: options.inject || [],
},
ConfigService,
],
exports: [ConfigService],
};
}
}
// app.module.ts
@Module({
imports: [
ConfigModule.forRootAsync({
useFactory: async () => ({
DB_HOST: process.env.DB_HOST ?? 'localhost',
JWT_SECRET: process.env.JWT_SECRET ?? 'dev-secret',
}),
}),
],
})
export class AppModule {}Фабрика виконується асинхронно до того як застосунок починає приймати запити. ConfigService доступний всюди завдяки global: true. Саме так працює @nestjs/config під капотом.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.