← назад к разделу

Когда приложение разбито на слои — core/ с бизнес-логикой и adapters/ с инфраструктурой — возникает вопрос: кто их собирает вместе? Кто говорит «этот порт обслуживает вот этот адаптер»? Это делает Composition Root — специальная точка сборки, которая знает обо всех слоях сразу и соединяет их при старте. В NestJS это папка app/.

Что такое Composition Root

Представьте конструктор: отдельные детали лежат в коробках, а сборка происходит строго в одном месте — на столе с инструкцией. В шестиугольной архитектуре «стол» — это Composition Root.

Без выделенного места сборки каждый слой начинает «знать» о других: core/ импортирует адаптеры, адаптеры тащат за собой AppModule — граница размывается. Composition Root решает это: только он один зависит от всего, все остальные зависят только от своего слоя.

Что живёт в app/

Папка app/ — это точка входа и только она. Её содержимое строго ограничено:

src/app/
  main.ts                  # запуск приложения
  app.module.ts            # корневой модуль
  config/
    config.schema.ts       # схема переменных окружения
    config.module.ts       # подключение конфига
  order.module.ts          # wiring Order-домена
  product.module.ts        # wiring Product-домена
Dockerfile
docker-compose.yml

Бизнес-логика и контроллеры сюда не идут. Контроллер живёт в adapters/in/http/, логика — в core/<bc>/usecases/. Если в app/ появился @Controller — это сигнал, что что-то пошло не так.

main.ts: точка запуска

main.ts — единственное место, где создаётся приложение:

// src/app/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

NestFactory.create и enableShutdownHooks вызываются только здесь. Если перенести создание приложения в core/ или в адаптер — это нарушает изоляцию слоёв. У сервиса одна точка входа.

AppModule собирает feature-модули

AppModule не знает о деталях бизнес-логики — он просто собирает feature-модули:

// src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { OrderModule } from './order.module';
import { ProductModule } from './product.module';

@Module({
  imports: [
    ConfigModule,
    OrderModule,
    ProductModule,
  ],
})
export class AppModule {}

Каждый feature-модуль отвечает за wiring одного домена: биндит порты из core/<bc>/port/out/ на конкретные адаптеры.

Wiring портов через Symbol-токены

Это центральная механика сборки. В TypeScript интерфейсы не существуют в runtime — они стираются при компиляции. Поэтому DI-контейнер NestJS не может «найти» интерфейс по его имени.

Решение: каждый outbound-порт несёт рядом с собой Symbol-токен — уникальный ключ для DI-контейнера:

// core/order/port/out/order-repository.ts
export const ORDER_REPOSITORY = Symbol('OrderRepository');

export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByIdForUpdate(id: OrderId): Promise<Order>;
  save(order: Order): Promise<void>;
}

Токен ORDER_REPOSITORY — это «имя», по которому NestJS найдёт нужную реализацию. Feature-модуль биндит токен на конкретный адаптер:

// src/app/order.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ORDER_REPOSITORY } from '../../core/order/port/out/order-repository';
import { TX_RUNNER } from '../../core/shared/port/out/transaction-runner';
import { CLOCK } from '../../core/shared/port/out/clock';
import { PgOrderRepository } from '../../adapters/out/persistence/pg-order.repository';
import { PgTransactionRunner } from '../../adapters/out/persistence/pg-transaction.runner';
import { SystemClock } from '../../adapters/out/clock/system.clock';
import { CreateOrderHandler } from '../../core/order/usecases/create-order.handler';
import { OrderEntity } from '../../adapters/out/persistence/order.entity';

@Module({
  imports: [TypeOrmModule.forFeature([OrderEntity])],
  providers: [
    { provide: ORDER_REPOSITORY, useClass: PgOrderRepository },
    { provide: TX_RUNNER,        useClass: PgTransactionRunner },
    { provide: CLOCK,            useClass: SystemClock },
    {
      provide: CreateOrderHandler,
      useFactory: (orders, tx, clock) => new CreateOrderHandler(orders, tx, clock),
      inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
    },
  ],
  exports: [CreateOrderHandler],
})
export class OrderModule {}

Обратите внимание: CreateOrderHandler — обычный класс, без @Injectable. Зависимости ему передаёт useFactory явно. Так core/ остаётся независимым от NestJS.

Важная деталь: если токен объявлен в core/, но нет соответствующего провайдера в feature-модуле — NestJS бросает исключение при старте приложения, а не при первом запросе. Ошибка конфигурации обнаруживается сразу при npm run start, а не через час после деплоя.

Типизированный конфиг

Вместо process.env.DB_HOST напрямую — единая схема с валидацией при старте:

// src/app/config/config.schema.ts
import * as Joi from 'joi';

export const configSchema = Joi.object({
  PORT:        Joi.number().default(3000),
  DB_HOST:     Joi.string().required(),
  DB_PORT:     Joi.number().default(5432),
  DB_NAME:     Joi.string().required(),
  DB_USER:     Joi.string().required(),
  DB_PASSWORD: Joi.string().required(),
});
// src/app/config/config.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { configSchema } from './config.schema';

@Module({
  imports: [
    NestConfigModule.forRoot({
      isGlobal: true,
      validationSchema: configSchema,
      validationOptions: { abortEarly: false },
    }),
  ],
  exports: [NestConfigModule],
})
export class ConfigModule {}

abortEarly: false — NestJS собирает все ошибки валидации и выводит их разом, а не останавливается на первой отсутствующей переменной. Если при деплое забыли DB_PASSWORD и SBER_API_KEY — увидите обе ошибки сразу.

Адаптеры читают конфиг через ConfigService, не напрямую из окружения — это ограничивает чтение переменных одним уровнем.

Graceful shutdown

enableShutdownHooks() в main.ts не просто «красивость». Kubernetes при остановке пода сначала посылает SIGTERM, а уже потом через 30 секунд — SIGKILL. В этот промежуток NestJS вызывает onApplicationShutdown у всех провайдеров.

Адаптер, которому нужно завершить работу — TypeORM-соединение или Kafka-consumer — реализует этот интерфейс:

// adapters/out/kafka/product-event.publisher.ts
import { OnApplicationShutdown } from '@nestjs/common';

export class ProductEventPublisher implements OnApplicationShutdown {
  async onApplicationShutdown(_signal: string) {
    await this.producer.disconnect();
  }
}

Без enableShutdownHooks() под убивается жёстко: незавершённые запросы обрываются, Kafka-офсеты не фиксируются.

Правило «никто не зависит от app/»

Это центральное ограничение Composition Root: core/ и adapters/ ничего не знают об app/. Только app/ знает о них — не наоборот.

В проектах с dependency-cruiser это проверяется автоматически:

// .dependency-cruiser.cjs
{ name: 'nobody-depends-on-app', severity: 'error',
  from: { path: '^src/(core|adapters)' },
  to:   { path: '^src/app' } },

Если кто-то импортировал AppModule или конфиг-схему из core/ — CI падает. Это защищает от постепенного размывания границ.

Частые ошибки

Бизнес-логика в app/. Кажется удобным написать маленький обработчик прямо в AppModule. Потом к нему обращаются из других мест — и граница стёрта. Логика идёт в core/<bc>/usecases/.

@Injectable на handler'е в core/. Это делает core/ зависимым от NestJS. Хендлеры — обычные классы, зависимости передаёт useFactory в feature-модуле.

process.env напрямую в адаптере. Переменные окружения читает только ConfigService через типизированную схему. Прямое чтение разбрасывает «точки входа» конфига по всему коду.

enableShutdownHooks() не вызван. Приложение завершается жёстко. При высокой нагрузке это означает потерю данных.

Коротко

  • app/ — единственная точка сборки. В ней живут main.ts, AppModule, конфиг и feature-модули. Ничего больше.
  • TypeScript-интерфейсы стираются в runtime, поэтому порты несут Symbol-токены — ключи для DI-контейнера NestJS.
  • Незабинженный токен обнаруживается при старте, не при первом запросе — ошибка конфигурации видна сразу.
  • Хендлеры в core/ — обычные классы без @Injectable. Зависимости им передаёт useFactory в feature-модуле.
  • Конфиг читается через ConfigService с Joi-схемой, abortEarly: false — все пропущенные переменные видны разом.
  • enableShutdownHooks() даёт адаптерам время корректно завершить работу при остановке контейнера.
  • core/ и adapters/ не импортируют из app/ — это нарушение проверяет dependency-cruiser в CI.

Что почитать дальше