Опирается на правила:
R-HEX-BOOT-1…R-HEX-BOOT-3иR-HEX-BOOT-X1…R-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 напрямую в адаптере вместо ConfigService | R-HEX-BOOT-1 | ConfigService.get<string>('DB_HOST') через типизированную схему |
enableShutdownHooks() не вызван — контейнер убивает процесс жёстко | R-HEX-BOOT-1 | app.enableShutdownHooks() в main.ts до app.listen() |
@Injectable на handler'е в core/ — NestJS-зависимость в core | R-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-сервиса.