Пошук уроків, статей та іншого контенту
Спроєктуєте структуру модулів, визначите межі відповідальності та зменшите зв’язаність компонентів.
У NestJS модуль — це не лише спосіб згрупувати файли. Він визначає:
які компоненти належать до певної функціональної області;
які залежності ця область може використовувати;
які сервіси та контролери доступні ззовні;
через який публічний API інші модулі взаємодіють із нею.
У невеликому застосунку можна імпортувати майже все всюди. У великому проєкті такий підхід швидко призводить до:
циклічних залежностей;
сервісів, які відповідають за кілька бізнес-областей;
неконтрольованого використання внутрішніх компонентів;
складного тестування;
змін, які поширюються на велику частину системи.
Добре спроєктований модуль приховує внутрішню реалізацію та надає іншим модулям мінімальний необхідний контракт.
Функціональний модуль має відповідати за одну бізнес-область або один технічний аспект системи.
Приклад поділу:
src/
├── app.module.ts
├── orders/
│ ├── orders.module.ts
│ ├── orders.controller.ts
│ ├── application/
│ └── infrastructure/
├── payments/
│ ├── payments.module.ts
│ ├── payments.service.ts
│ └── infrastructure/
├── users/
│ ├── users.module.ts
│ └── users.service.ts
└── contracts/
└── payments.tsУ цьому прикладі:
OrdersModule керує замовленнями;
PaymentsModule відповідає за платежі;
UsersModule відповідає за користувачів;
AppModule лише збирає застосунок;
contracts містить контракти взаємодії, а не бізнес-логіку.
Межа модуля повинна проходити за відповідальністю, а не за типом файлу. Структура на кшталт controllers/, services/, repositories/ на рівні всього проєкту часто приховує функціональні межі:
src/
├── controllers/
├── services/
├── repositories/
└── dto/У такій структурі складно визначити, які компоненти належать до замовлень, а які — до платежів. Для великого проєкту зазвичай краще групувати код за функціональними модулями.
NestJS ізолює провайдери модуля за замовчуванням. Провайдер стає доступним для інших модулів лише тоді, коли:
він доданий до exports модуля;
модуль, який його експортує, доданий до imports споживача.
@Module({
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}Такий модуль відкриває OrdersService назовні.
Якщо провайдер не додати до exports, він залишається внутрішньою деталлю:
@Module({
providers: [
OrdersService,
OrdersRepository,
],
exports: [OrdersService],
})
export class OrdersModule {}У цьому випадку інші модулі можуть використовувати OrdersService, але не можуть напряму отримати OrdersRepository.
Це дозволяє змінювати внутрішню реалізацію без зміни споживачів:
@Module({
providers: [
OrdersService,
{
provide: OrdersRepository,
useClass: SqlOrdersRepository,
},
],
exports: [OrdersService],
})
export class OrdersModule {}Пізніше SqlOrdersRepository можна замінити на іншу реалізацію, не змінюючи код, який працює з OrdersService.
Для кожного модуля корисно явно відповісти на три запитання:
За що відповідає модуль?
Які залежності йому потрібні?
Які операції він дозволяє виконувати іншим модулям?
Наприклад:
@Module({
imports: [PaymentsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}Тут:
OrdersModule використовує публічний API PaymentsModule;
OrdersController належить до зовнішнього HTTP-шару замовлень;
OrdersService містить прикладну логіку;
назовні експортується лише OrdersService.
Не слід експортувати всі провайдери модуля «на майбутнє». Надмірний exports фактично руйнує інкапсуляцію.
Поганий приклад:
@Module({
providers: [
OrdersService,
OrdersRepository,
OrdersMapper,
OrdersValidator,
],
exports: [
OrdersService,
OrdersRepository,
OrdersMapper,
OrdersValidator,
],
})
export class OrdersModule {}Кожен експорт збільшує кількість дозволених залежностей. Експортуйте лише те, що є частиною публічного API модуля.
Залежності між модулями бажано організовувати в одному напрямку. Наприклад:
HTTP → Orders → Payments
→ UsersOrdersModule може використовувати PaymentsModule, але PaymentsModule не повинен імпортувати OrdersModule, якщо платіжна система не належить до області замовлень.
Циклічна залежність виглядає так:
OrdersModule → PaymentsModule
PaymentsModule → OrdersModuleЦе ознака проблеми в межах відповідальності. Часто цикл виникає, коли два модулі намагаються безпосередньо керувати станом один одного.
Замість циклу варто:
винести спільний контракт;
створити окремий модуль, якому належить спільна відповідальність;
передавати події або команди через чіткий прикладний API;
змінити напрямок залежності.
forwardRef() може допомогти NestJS створити застосунок із циклічною залежністю, але не усуває архітектурну проблему. Це механізм сумісності, а не спосіб проєктування модулів.
Модуль не повинен знати внутрішню реалізацію іншого модуля. Замість конкретного класу можна залежати від токена та інтерфейсу.
Файл src/contracts/payments.ts:
export const PAYMENT_PORT = Symbol('PAYMENT_PORT');
export interface PaymentPort {
charge(input: {
orderId: string;
amount: number;
currency: string;
}): Promise<{
transactionId: string;
}>;
}Контракт описує, що потрібно замовленням для проведення платежу. Він не містить деталей бази даних, HTTP-клієнта чи конкретного платіжного провайдера.
Файл src/payments/payments.service.ts:
import { Injectable } from '@nestjs/common';
import { PaymentPort } from '../contracts/payments';
@Injectable()
export class PaymentsService implements PaymentPort {
async charge(input: {
orderId: string;
amount: number;
currency: string;
}): Promise<{ transactionId: string }> {
// Тут могла б бути інтеграція із зовнішнім платіжним провайдером
return {
transactionId: `tx-${input.orderId}`,
};
}
}Файл src/payments/payments.module.ts:
import { Module } from '@nestjs/common';
import { PAYMENT_PORT } from '../contracts/payments';
import { PaymentsService } from './payments.service';
@Module({
providers: [
PaymentsService,
{
provide: PAYMENT_PORT,
useExisting: PaymentsService,
},
],
exports: [PAYMENT_PORT],
})
export class PaymentsModule {}useExisting означає, що для токена PAYMENT_PORT використовується вже зареєстрований екземпляр PaymentsService, а не створюється другий екземпляр.
Файл src/orders/orders.service.ts:
import { Inject, Injectable } from '@nestjs/common';
import {
PAYMENT_PORT,
PaymentPort,
} from '../contracts/payments';
@Injectable()
export class OrdersService {
constructor(
@Inject(PAYMENT_PORT)
private readonly paymentPort: PaymentPort,
) {}
async createOrder(input: {
orderId: string;
amount: number;
currency: string;
}) {
const payment = await this.paymentPort.charge({
orderId: input.orderId,
amount: input.amount,
currency: input.currency,
});
return {
id: input.orderId,
status: 'paid',
transactionId: payment.transactionId,
};
}
}Файл src/orders/orders.controller.ts:
import { Body, Controller, Post } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
create(
@Body()
body: {
orderId: string;
amount: number;
currency: string;
},
) {
return this.ordersService.createOrder(body);
}
}Файл src/orders/orders.module.ts:
import { Module } from '@nestjs/common';
import { PaymentsModule } from '../payments/payments.module';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [PaymentsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}Файл src/app.module.ts:
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [OrdersModule],
})
export class AppModule {}Файл src/main.ts:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Цей приклад можна запустити в стандартному проєкті NestJS після створення наведених файлів. Запит:
POST http://localhost:3000/orders
Content-Type: application/json
{
"orderId": "order-42",
"amount": 1500,
"currency": "UAH"
}поверне результат на кшталт:
{
"id": "order-42",
"status": "paid",
"transactionId": "tx-order-42"
}OrdersService не знає, що платіж реалізований класом PaymentsService. Реалізацію можна замінити, не змінюючи модуль замовлень:
@Module({
imports: [PaymentsModule],
providers: [
OrdersService,
{
provide: PAYMENT_PORT,
useClass: FakePaymentsService,
},
],
})
export class OrdersModule {}Однак у реальному застосунку не слід одночасно імпортувати PaymentsModule і перевизначати його провайдер без чіткої причини. Для тестів таку заміну зазвичай виконують через TestingModule.
Великий функціональний модуль можна поділити на внутрішні шари:
orders/
├── orders.module.ts
├── orders.controller.ts
├── application/
│ ├── create-order.service.ts
│ └── ports/
│ └── payment.port.ts
├── domain/
│ ├── order.ts
│ └── order-status.ts
└── infrastructure/
├── orders.repository.ts
└── sql-orders.repository.tsМожливий напрямок залежностей:
controller → application → domain
↓
ports/interfaces
↑
infrastructureПрактичне правило:
контролер приймає транспортний запит;
прикладний сервіс координує операцію;
доменна частина містить правила предметної області;
інфраструктура реалізує доступ до бази даних або зовнішніх сервісів;
модуль з'єднує ці частини через DI.
Не кожен модуль потрібно одразу розділяти на багато директорій. Декомпозиція має з'являтися тоді, коли в модулі виникають різні причини для змін або складні залежності.
Core-модуль містить інфраструктуру, яка має одну конфігурацію на весь застосунок:
конфігурацію;
логування;
підключення до бази даних;
клієнти зовнішніх систем;
глобальні адаптери.
Зазвичай такий модуль імпортується в кореневому модулі або реєструється як глобальний, якщо це справді виправдано.
Shared-модуль може містити компоненти, які використовують кілька функціональних модулів. Але він не повинен перетворюватися на «місце для всього спільного».
Поганий кандидат для SharedModule:
OrdersService;
UsersService;
бізнес-логіка платежів;
репозиторії конкретної предметної області;
випадкові утиліти, які використовуються лише одним модулем.
Якщо SharedModule експортує десятки сервісів, він стає прихованим глобальним контейнером залежностей. У результаті межі модулів перестають бути зрозумілими.
До shared-компонентів можуть належати справді загальні технічні абстракції, але кожну з них потрібно перевіряти окремо:
чи не має компонент власної бізнес-відповідальності;
чи потрібен він кільком модулям;
чи не можна залишити його всередині одного функціонального модуля;
чи не створює його експорт зайвої зв'язаності.
@Global() робить експортовані провайдери доступними без явного імпорту модуля.
import { Global, Module } from '@nestjs/common';
@Global()
@Module({
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}Глобальний модуль може бути доречним для фундаментальної інфраструктури, яка потрібна майже всьому застосунку. Але застосовувати його для бізнес-модулів небезпечно.
Недоліки надмірного використання глобальних модулів:
залежність не видно в imports;
модуль складніше використовувати окремо;
тести потребують прихованого глобального налаштування;
складніше аналізувати граф залежностей.
Явний imports зазвичай краще документує архітектуру:
@Module({
imports: [PaymentsModule],
providers: [OrdersService],
})
export class OrdersModule {}Під час проєктування поставте такі запитання:
Якщо сервіс одночасно створює замовлення, надсилає листи та проводить оплату, його потрібно розділити.
OrdersService
→ OrdersRepository
→ PaymentPort
→ NotificationPortКоординація залежностей може залишатися в прикладному сервісі, але кожна відповідальність повинна мати власний компонент або модуль.
Якщо зміна платіжного провайдера вимагає змін у контролерах замовлень, межа між модулями недостатньо ізольована.
Модуль із десятками транзитивних залежностей важко тестувати. Це сигнал переглянути його публічний API та залежності.
Можливо, достатньо експортувати окремий прикладний сервіс або токен, а не відкривати репозиторій, мапер і внутрішні утиліти.
@Module({
providers: [
UsersService,
UsersRepository,
UsersMapper,
],
exports: [
UsersService,
UsersRepository,
UsersMapper,
],
})
export class UsersModule {}Виправлення: експортувати лише публічний сервіс або контракт.
@Module({
providers: [
UsersService,
UsersRepository,
UsersMapper,
],
exports: [UsersService],
})
export class UsersModule {}Якщо OrdersService напряму використовує UsersRepository, модуль замовлень залежить від внутрішньої реалізації модуля користувачів.
Краще використовувати публічну операцію:
usersService.findById(userId);або окремий контракт, якщо модулі мають бути слабше зв'язані.
Назви на кшталт AppService, CommonService або BusinessService часто приховують кілька відповідальностей. Краще називати компоненти за операцією або бізнес-областю:
CreateOrderService;
CancelOrderService;
PaymentsService;
UserProfileService.
SharedModuleНе кожен повторюваний клас є спільною інфраструктурою. Перед винесенням компонента перевірте, чи справді він належить кільком модулям і чи має стабільний загальний контракт.
forwardRef() як основного рішенняЯкщо два модулі імпортують один одного, спочатку потрібно переглянути їхні межі. forwardRef() варто розглядати лише після того, як встановлено, що циклічна взаємодія справді необхідна.
Функціональний модуль не повинен імпортувати AppModule, щоб отримати доступ до іншого компонента. Кореневий модуль має збирати застосунок, а не бути сховищем бізнес-залежностей.
Перед додаванням нового модуля перевірте:
чи має модуль чітку функціональну відповідальність;
чи є його залежності явними в imports;
чи експортується лише необхідний API;
чи не використовують інші модулі його внутрішні провайдери;
чи немає циклічних імпортів;
чи можна замінити зовнішні інтеграції через токени або контракти;
чи має модуль зрозумілу стратегію тестування;
чи не дублює новий модуль відповідальність уже наявного.
Граф модулів повинен бути зрозумілим без читання реалізації всіх сервісів. Якщо для розуміння залежності потрібно переглядати кілька рівнів провайдерів, публічний API модуля, імовірно, сформовано невдало.
Модуль у NestJS є межею відповідальності та інкапсуляції.
Функціональні модулі краще організовувати за бізнес-областями.
Провайдер доступний ззовні лише через exports.
У exports потрібно додавати тільки публічний API модуля.
Напрямок залежностей має бути явним і бажано односпрямованим.
Для слабкого зв'язування використовуйте токени та контракти.
SharedModule і глобальні модулі не повинні ставати сховищем усіх спільних сервісів.
Циклічні залежності зазвичай вказують на неправильні межі модулів.
Кореневий модуль має збирати застосунок, а не містити бізнес-логіку.