Skip to main content

Що таке модулі в NestJS і як вони працюють?

NestJS модуль - це TypeScript-клас з декоратором @Module(), який групує пов'язані контролери, провайдери та підмодулі в одну область видимості для dependency injection (DI).

Теорія

TL;DR

  • Модуль як контейнер: тримає своє (controllers, providers), каже що йому потрібно (imports) і що дає назовні (exports)
  • Провайдери приватні за замовчуванням - нічого не витікає без явного exports
  • Один функціональний домен = один модуль (UsersModule, OrdersModule). AppModule тільки імпортує їх
  • providers реєструє клас у DI-скопі модуля; exports робить частину провайдерів доступними для імпортерів
  • Кругові залежності вирішуються через forwardRef()

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

typescript
// 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 ще до першого запиту. Це навмисно: краще впасти при старті, ніж у три ночі в продакшені.

Динамічний модуль на практиці

typescript
// 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 - найчастіша помилка, особливо після перенесення сервісу в новий модуль:

typescript
// 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():

typescript
// 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 користувачів

typescript
// 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(). Контролер просто вказує залежність у конструкторі.

Спільний модуль для доступу до бази даних

typescript
// 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. Одне визначення - багато споживачів.

Динамічний модуль з асинхронною фабрикою

typescript
// 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 під капотом.

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

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

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

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