В Java Hexagonal-архитектуру охраняет компилятор: если core/ случайно сослался на Spring, сборка упадёт. В Node.js такой защиты нет — TypeScript не запрещает импортировать что угодно откуда угодно. Структура папок без дополнительного инструмента — просто соглашение, которое легко нарушить.
Эта статья объясняет: как именно раскладывать файлы, что куда попадает, и как настроить автоматическую проверку границ между слоями через dependency-cruiser.
Четыре зоны проекта
В Hexagonal-проекте на NestJS весь код делится на четыре зоны внутри src/:
src/
core/ # бизнес-логика — не знает о NestJS, TypeORM, axios
adapters/ # вход (HTTP, Kafka) и выход (БД, внешние API)
app/ # точка сборки — собирает всё вместе
Стрелка зависимостей строго односторонняя:
app → adapters → core
core/ не знает ни об adapters/, ни об app/. adapters/ не знают друг о друге. app/ знает обо всех — он и связывает части вместе.
core/ — бизнес-логика без инфраструктуры
Здесь живёт всё, что относится к предметной области: агрегаты, value-объекты, доменные события, порт-интерфейсы, команды и хендлеры.
core/
order/
aggregate/ # Order, OrderItem
value-object/ # OrderId, Money, CustomerId
event/ # OrderCreatedEvent, OrderConfirmedEvent
port/
out/ # OrderRepository, PaymentPort — интерфейсы + Symbol-токены
usecases/ # CreateOrderCommand, CreateOrderHandler
product/
aggregate/
port/out/
usecases/
shared/
dispatcher.ts # единственная точка входа в core
value-object/ # Money, Clock, TransactionRunner
Главное ограничение core/: здесь не появляются @nestjs/*, typeorm, class-validator, axios. Класс агрегата — это просто TypeScript-класс без сторонних зависимостей:
// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { Money } from '../../shared/value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderCreatedEvent } from '../event/order-created.event';
export class Order {
private constructor(
readonly id: OrderId,
private _totalAmount: Money,
private _status: OrderStatus,
private readonly _events: unknown[],
) {}
static create(customerId: CustomerId, items: OrderItem[], clock: Clock): Order {
const id = new OrderId(randomUUID());
const total = items.reduce((acc, i) => acc.add(i.price), Money.zero());
const order = new Order(id, total, OrderStatus.PENDING, []);
order._events.push(new OrderCreatedEvent(id, customerId, total, clock.now()));
return order;
}
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new OrderAlreadyConfirmedError(this.id);
}
this._status = OrderStatus.CONFIRMED;
}
pullEvents(): unknown[] { return this._events.splice(0); }
}
Ни одного импорта из фреймворка. Такой код можно тестировать без поднятия NestJS.
Хендлеры — plain-классы без @Injectable
Типичная ошибка — поставить @Injectable() на хендлер в core/. Тогда хендлер становится зависимым от @nestjs/common, что нарушает изоляцию.
Хендлеры в core/ — обычные TypeScript-классы. NestJS о них не знает. Связывание происходит в app/ через useFactory-провайдеры:
// core/order/usecases/create-order.handler.ts
export class CreateOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly tx: TransactionRunner,
private readonly clock: Clock,
) {}
async handle(cmd: CreateOrderCommand): Promise<Order> {
const order = Order.create(cmd.customerId, cmd.items, this.clock);
await this.tx.run(async () => { await this.orders.save(order); });
return order;
}
}
Нет @Injectable(). Нет @Inject(). Нет зависимости на @nestjs/common.
adapters/ — вход и выход
Адаптеры — реализация того, что core/ описывает через интерфейсы. Делятся на входящие (in/) и исходящие (out/).
Входящие адаптеры — точки входа в приложение:
adapters/in/
http/ # публичный REST, JWT с user-audience
http-admin/ # REST для админ-панели, JWT с admin-audience
kafka/ # Kafka consumers как точка входа
cli/ # CLI / batch (если есть)
Публичный API и admin-API разделены намеренно. У каждого свой Guard, свой JwtStrategy, свои DTO. Смешивать их в одной папке — значит потерять эту изоляцию.
Исходящие адаптеры — обращения к внешним системам. Каждая система — отдельная папка:
adapters/out/
persistence/ # TypeORM — реализует OrderRepository
sber/ # axios-клиент Sber — реализует PaymentPort
notifications/ # SMS-провайдер — реализует NotificationPort
kafka/ # kafkajs — реализует DomainEventPublisher
Зачем одна папка на одну систему: таймаут, retry, circuit breaker для Sber и для SMS — разные настройки. Один общий HTTP-клиент для всех внешних вызовов — частая ошибка, которая делает невозможным раздельное управление поведением.
app/ — точка сборки
app/ — это место, где всё собирается вместе. Только здесь NestJS-модули биндят порты на реализации:
app/
main.ts # NestFactory.create, enableShutdownHooks
app.module.ts # AppModule: импортирует все feature-модули
config/ # ConfigModule, типизированный конфиг
Dockerfile
Никакой бизнес-логики в app/ нет — только сборка. И никто не импортирует из app/ — это закрывающий узел.
Пример: как хендлер из core/ получает свои зависимости через useFactory:
// app/order.module.ts
@Module({
providers: [
{ provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
{ provide: TX_RUNNER, useClass: PgTransactionRunner },
{ provide: CLOCK, useClass: SystemClock },
{
provide: CreateOrderHandler,
useFactory: (repo, tx, clock) => new CreateOrderHandler(repo, tx, clock),
inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
},
],
exports: [CreateOrderHandler],
})
export class OrderModule {}
CreateOrderHandler не знает, что живёт в NestJS-контейнере. Он получает зависимости через конструктор — как обычный объект.
dependency-cruiser — автоматическая проверка границ
Структура папок без инструмента — соглашение на доверии. Стоит одному разработчику добавить import { DataSource } from 'typeorm' в файл агрегата — и граница нарушена. Тесты пройдут, IDE не скажет ни слова, code review может пропустить.
dependency-cruiser решает это: описываете запрещённые переходы в .dependency-cruiser.cjs, запускаете проверку в CI как обязательный шаг.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'core-pure',
severity: 'error',
from: { path: '^src/core' },
to: {
path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
},
},
{
name: 'adapters-independent',
severity: 'error',
from: { path: '^src/adapters/in' },
to: { path: '^src/adapters/out' },
},
{
name: 'nobody-depends-on-app',
severity: 'error',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' },
},
],
};
Добавьте скрипт в package.json:
{
"scripts": {
"arch:check": "depcruise --validate .dependency-cruiser.cjs src"
}
}
Запускайте npm run arch:check в CI как required check — без зелёного статуса мерж заблокирован. Нарушение выглядит так:
error core-pure: src/core/order/aggregate/order.ts
→ node_modules/@nestjs/common/index.js
Нельзя «не заметить».
Частые ошибки
TypeORM в core/: import { DataSource } from 'typeorm' в агрегате или хендлере — нарушение изоляции. TypeORM относится только к adapters/out/persistence/, маппинг между доменными и ORM-объектами — в *.mapper.ts.
User и admin в одной папке: если публичный и административный REST живут в одном adapters/in/http/, они неизбежно начнут делить Guard-ы и DTO. Разделение на http/ и http-admin/ делает это явным.
@Injectable() на хендлере в core/: хендлер становится зависимым от @nestjs/common. Используйте plain-класс и useFactory-провайдер в app/.
In-адаптер импортирует out-адаптер: контроллер не должен обращаться напрямую к репозиторию. Координация — через Dispatcher → Handler в core/.
Коротко
src/делится на три зоны:core/(бизнес),adapters/(вход/выход),app/(сборка).- Стрелка зависимостей:
app → adapters → core.core/не знает ни о фреймворке, ни об адаптерах. - Хендлеры в
core/— plain-классы без@Injectable(). Связывание черезuseFactoryвapp/. - Каждая внешняя система — отдельная папка в
adapters/out/. Каждый тип входа — отдельная папка вadapters/in/. - dependency-cruiser с тремя правилами (
core-pure,adapters-independent,nobody-depends-on-app) и запуском в CI делает границы принудительными, а не добровольными.
Что почитать дальше
- Ports — Symbol-токены, интерфейс порта, port-исключения в
core/. - Adapters in — NestJS-контроллеры, маппинг DTO → command, Dispatcher.
- Adapters out — реализация port-интерфейсов, TypeORM и axios-адаптеры.
- Core слой — агрегаты, port-интерфейсы и plain-handlers.
- Архитектурные тесты — полный конфиг dependency-cruiser и разбор ошибок.