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. Когда каркас задан, на него встают контроллеры и весь конвейер запроса — а продукт-инженер держит сервис структурированным без усилий.