Опирается на правила: R-HEX-BOOT-1R-HEX-BOOT-3 и R-HEX-BOOT-X1R-HEX-BOOT-X2 из Hexagonal Style Guide → раздел 7. Bootstrap / composition root.

Важно знать

  • app/ — composition root: main.ts, AppModule, типизированный конфиг, Dockerfile. Только это — ничего больше.
  • AppModule биндит все порты на адаптеры через Symbol-токены: незабинженный токен в NestJS падает на старте, не на первом запросе.
  • Никто не зависит от app/ — это закрывающий узел; dependency-cruiser проверяет это правило в CI.
  • NestFactory.create и enableShutdownHooks — только в main.ts; перемещать в core/ или адаптер запрещено.
  • Handlers и domain services в core/ — plain classes без @Injectable; wiring — useFactory-провайдеры в app/ или feature-модулях.
  • ConfigService из @nestjs/config с типизированной схемой Joi — единая точка чтения переменных окружения.
  • Graceful shutdown: enableShutdownHooks() + SIGTERM-обработчик дают адаптерам время завершить in-flight запросы перед остановкой.

app/ — наименьший по объёму, но самый ответственный каталог. core/ и адаптеры — это «кирпичи». app/ — «дом», который собирается из этих кирпичей: точка входа, конфиг, Dockerfile. И ничего больше. Раскрытие правил R-HEX-BOOT-* ниже.

Что живёт в app/

R-HEX-BOOT-1: точный состав composition root.

src/app/
  main.ts                  # NestFactory.create + enableShutdownHooks + listen
  app.module.ts            # AppModule: импорт feature-модулей, глобальные провайдеры
  config/
    config.schema.ts       # Joi-схема переменных окружения
    config.module.ts        # ConfigModule.forRoot с валидацией схемы
  order.module.ts          # feature-модуль: wiring портов Order-домена
  product.module.ts        # feature-модуль: wiring портов Product-домена
Dockerfile
docker-compose.yml

Минимальный 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();

enableShutdownHooks() подписывается на SIGTERM/SIGINT: NestJS вызывает onApplicationShutdown у всех провайдеров, давая адаптерам время закрыть соединения и завершить in-flight запросы. Без него контейнер просто убивает процесс.

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

R-HEX-BOOT-2: AppModule — точка сборки, он не знает о деталях бизнес-логики.

// 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 одного bounded context: биндит порты из core/<bc>/port/out/ на конкретные адаптеры.

Wiring портов по Symbol-токенам

В Node/NestJS интерфейсы TypeScript стираются в runtime. Поэтому каждый outbound-порт в core/<bc>/port/out/ несёт Symbol-токен рядом с интерфейсом — это ключ DI-контейнера.

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

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

OrderModule биндит токен на адаптер. Handlers в core/ — plain classes: useFactory передаёт зависимости явно, без @Injectable на handler'е.

// 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 { ConfirmOrderHandler } from '../../core/order/usecases/confirm-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: OrderRepository, tx: TransactionRunner, clock: Clock) =>
        new CreateOrderHandler(orders, tx, clock),
      inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
    },
    {
      provide: ConfirmOrderHandler,
      useFactory: (orders: OrderRepository, clock: Clock) =>
        new ConfirmOrderHandler(orders, clock),
      inject: [ORDER_REPOSITORY, CLOCK],
    },
  ],
  exports: [CreateOrderHandler, ConfirmOrderHandler],
})
export class OrderModule {}

Незабинженный токен — NestJS бросает исключение на старте, а не на первом запросе. Это свойство важно: ошибка конфигурации обнаруживается при npm run start, а не через час после деплоя на первом входящем запросе.

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

R-HEX-BOOT-1 требует типизированного конфига в app/. Один файл схемы — единственная точка чтения всех переменных окружения.

// 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(),
  SBER_API_URL:       Joi.string().uri().required(),
  SBER_API_KEY:       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: true },
    }),
  ],
  exports: [NestConfigModule],
})
export class ConfigModule {}

abortEarly: true означает: если DB_HOST не задан, Nest падает со списком всех проблем сразу, а не по одной. Адаптеры читают конфиг через ConfigService — не process.env напрямую.

// adapters/out/persistence/pg-order.repository.ts (фрагмент)
constructor(
  @InjectRepository(OrderEntity)
  private readonly repo: Repository<OrderEntity>,
  private readonly config: ConfigService,
) {}

Graceful shutdown: SIGTERM до последнего запроса

enableShutdownHooks() в main.ts активирует lifecycle-хуки NestJS. Адаптеры, которым нужно завершить работу — TypeORM-соединение, Kafka-consumer — реализуют OnApplicationShutdown:

// adapters/out/kafka/product-event.publisher.ts (фрагмент)
import { OnApplicationShutdown } from '@nestjs/common';

export class ProductEventPublisher implements ProductEventPort, OnApplicationShutdown {
  private readonly producer = this.kafka.producer();

  async onApplicationShutdown(_signal: string) {
    await this.producer.disconnect();
  }
}

Kubernetes посылает SIGTERM перед SIGKILL. Период между ними (terminationGracePeriodSeconds, дефолт 30 с) — это окно, в которое NestJS вызывает shutdown-хуки. Без enableShutdownHooks() pod убивается жёстко: in-flight запросы обрываются, Kafka-offsets не коммитятся.

Dependency-cruiser: nobody depends on app/

R-HEX-MOD-5 в терминах Node — правило в .dependency-cruiser.cjs, которое проверяет: ни core/, ни adapters/ не импортируют из app/.

// .dependency-cruiser.cjs (фрагмент)
{ name: 'nobody-depends-on-app', severity: 'error',
  from: { path: '^src/(core|adapters)' },
  to:   { path: '^src/app' } },

В CI:

npx depcruise --validate .dependency-cruiser.cjs src

Падение этого правила означает: кто-то импортировал AppModule или конфиг-схему из core/ или адаптера. PR не мерджится.

Что запрещено

АнтипаттернПравилоЧто взамен
Бизнес-логика или контроллер в app/ (@Controller в app.module.ts)R-HEX-BOOT-X1Контроллер — в adapters/in/http/; логика — в core/<bc>/usecases/
NestFactory.create в core/ или адаптереR-HEX-BOOT-X2Только в app/main.ts; один entrypoint на сервис
Порт не забинжен — токен есть в core/, провайдера нетR-HEX-BOOT-3Каждый Symbol-токен из core/<bc>/port/out/ получает { provide, useClass/useFactory } в feature-модуле
process.env.DB_HOST напрямую в адаптере вместо ConfigServiceR-HEX-BOOT-1ConfigService.get<string>('DB_HOST') через типизированную схему
enableShutdownHooks() не вызван — контейнер убивает процесс жёсткоR-HEX-BOOT-1app.enableShutdownHooks() в main.ts до app.listen()
@Injectable на handler'е в core/ — NestJS-зависимость в coreR-HEX-CORE-3 (+ R-HEX-CORE-X1)Plain class + useFactory-провайдер в feature-модуле

Куда дальше

  • Структура модулей — как app/ собирается из core и адаптеров, полная раскладка папок.
  • Архитектурные тесты — конфигурация dependency-cruiser в CI, примеры правил.
  • Core слой — что разрешено в core/, почему plain classes вместо @Injectable.
  • Ports — Symbol-токены, интерфейсы портов, domain-типы в сигнатурах.
  • Adapters in — контроллер через Dispatcher, маппер DTO → command.
  • Adapters out — биндинг адаптера на токен, mapper domain ↔ DTO внешней системы.
  • Когда переходить на Hexagonal — признаки «пора» и «рано» для NestJS-сервиса.