NestJS — самый «спринговый» из Node-фреймворков: в его центре DI-контейнер и модули, как в Spring. Это и причина выбрать NestJS для UCP-сервиса: он навязывает структуру, а не оставляет её на усмотрение. Понимание двух вещей — модулей и внедрения зависимостей — задаёт всё остальное.
Провайдеры и DI-контейнер
Провайдер — это класс, помеченный @Injectable(), которым управляет контейнер: создаёт его и подставляет туда, где он нужен. Зависимости объявляются в конструкторе по типу, контейнер их разрешает.
import { Injectable } from '@nestjs/common';
@Injectable()
export class ProductRepository {
async find(id: number): Promise<Product | null> { /* ... */ }
}
@Injectable()
export class CreateProductHandler {
constructor(private readonly repo: ProductRepository) {}
async handle(command: CreateProductCommand): Promise<Product> { /* ... */ }
}
CreateProductHandler не создаёт репозиторий сам — он его получает. Контейнер видит тип ProductRepository в конструкторе и подставляет единственный экземпляр. Это и есть слои UCP, связанные через DI: Handler зависит от репозитория, контроллер — от Handler-а.
Модуль как граница
Провайдеры не висят в воздухе — они принадлежат модулю. Модуль (@Module) объявляет, что в него входит и что он отдаёт наружу.
import { Module } from '@nestjs/common';
@Module({
controllers: [ProductController],
providers: [CreateProductHandler, ProductRepository],
exports: [ProductRepository],
})
export class ProductModule {}
providers — что доступно внутри модуля; controllers — его контроллеры; imports — другие модули, чьи экспорты нужны; exports — что этот модуль отдаёт тем, кто его импортирует. Это и есть граница домена: продуктовый модуль не видит внутренностей заказного, пока тот явно что-то не экспортировал. Раскладка UCP-сервиса — модуль на домен (ProductModule, OrderModule), собранные в корневом AppModule.
Scopes провайдеров
По умолчанию провайдер — одиночка (Scope.DEFAULT): один экземпляр на всё приложение. Это правильный выбор почти всегда — провайдеры без состояния переиспользуются.
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContext { /* ... */ }
Scope.REQUEST создаёт экземпляр на каждый запрос — нужно, когда провайдер держит данные конкретного запроса (например, текущего пользователя). Scope.TRANSIENT — новый экземпляр на каждое внедрение. Request-scope удобен, но имеет цену: он «заражает» зависящие от него провайдеры тем же scope и мешает оптимизации, поэтому его берут только по необходимости, а контекст запроса чаще прокидывают явно.
Динамические модули
Когда модуль нужно настраивать при подключении (передать конфигурацию, строку подключения), используют динамический модуль — статический метод, возвращающий описание модуля.
import { DynamicModule, Module } from '@nestjs/common';
@Module({})
export class StorageModule {
static forRoot(options: StorageOptions): DynamicModule {
return {
module: StorageModule,
providers: [{ provide: STORAGE_OPTIONS, useValue: options }, StorageService],
exports: [StorageService],
};
}
}
Это шаблон forRoot/forFeature, который ты видишь у TypeOrmModule, ConfigModule и других: модуль конфигурируется на месте подключения. Свои инфраструктурные модули UCP-сервиса оформляют так же.
Где это в UCP
Модули и DI — это каркас, на котором держатся слои методологии: контроллер, Handler, репозиторий — провайдеры, связанные контейнером, а модуль очерчивает границу домена (Bounded Context на уровне кода). Бизнес-логика живёт в Handler-ах и домене, а не в устройстве модулей — модуль лишь собирает и изолирует. Это тот же контейнер и те же границы, что в Spring-биндинге, только декораторами TypeScript. Когда каркас задан, на него встают контроллеры и весь конвейер запроса — а продукт-инженер держит сервис структурированным без усилий.