Опирается на правила:
R-HEX-MOD-1…R-HEX-MOD-5иR-HEX-MOD-X1…R-HEX-MOD-X3из Hexagonal Style Guide → раздел 2. Структура модулей.
Важно знать
- В Node нет compile-time изоляции gradle-модулей — её заменяет dependency-cruiser (или eslint-plugin-boundaries) с контрактом в
.dependency-cruiser.cjs, запущенным в CI как required check.src/core/не импортирует@nestjs/*,typeorm,class-validator,axios— никаких инфраструктурных зависимостей. Нарушение ловит правилоcore-pureв dependency-cruiser.- Per-system out:
adapters/out/persistence/,adapters/out/sber/,adapters/out/notifications/— отдельная папка на каждую внешнюю систему.- Per-purpose in:
adapters/in/http/,adapters/in/http-admin/,adapters/in/kafka/— admin отдельно от user: свойGuard, свойJwtStrategy.app/— composition root:main.ts,AppModule, конфиг,Dockerfile. Никто не зависит отapp/— это закрывающий узел.- Стрелка зависимостей строго:
app → adapters → core.coreне зависит от адаптеров. Адаптеры не зависят друг от друга.- NestJS-декораторы (
@Injectable,@Inject) запрещены вcore/— plain-классы, wiring черезuseFactory-провайдеры вapp/или feature-модулях.
В Java Hexagonal держится на gradle-модулях: компилятор не даст импортировать Spring из core/. В Node compile-time изоляции модулей нет — npm workspaces возможны, но для одного сервиса это избыточно. Вместо этого контракт границ описывается в .dependency-cruiser.cjs и проверяется в CI. Без этого файла Hexagonal в Node — конвенция на доверии: кто-то добавит import { DataSource } from 'typeorm' в core/order/aggregate/order.ts и тест пройдёт, IDE промолчит, ревью пропустит. Раскрытие правил R-HEX-MOD-* ниже.
Раскладка src/
R-HEX-MOD-1: стандартное дерево папок NestJS-сервиса на Hexagonal.
src/
core/
order/
aggregate/ # Order, OrderItem
value-object/ # OrderId, Money, CustomerId
event/ # OrderCreatedEvent, OrderConfirmedEvent
port/
out/ # OrderRepository, PaymentPort — интерфейсы + Symbol-токены
usecases/ # CreateOrderCommand, CreateOrderHandler, GetOrderQuery
service/ # OrderDomainService (опционально, для cross-aggregate логики)
product/
aggregate/
port/out/
usecases/
shared/
dispatcher.ts # Dispatcher — единственная точка входа в core
value-object/ # Money, Clock, TransactionRunner
adapters/
in/
http/ # публичный REST (user-facing)
http-admin/ # REST для админ-панели, отдельный Guard
kafka/ # Kafka consumers как entry-point
cli/ # CLI / batch (опционально)
out/
persistence/ # TypeORM — реализация OrderRepository
sber/ # axios-клиент Sber — реализация PaymentPort
notifications/ # SMS / email провайдер
kafka/ # KafkaJS — реализация EventPublisher
app/
main.ts # NestFactory.create + enableShutdownHooks
app.module.ts # AppModule — собирает feature-модули, биндит порты
config/ # ConfigModule, типизированный конфиг
Dockerfile
Минимальный набор для Уровня 3: core/<bc>/, adapters/out/persistence/, adapters/in/http/, app/. Дальше — добавляем папки по мере роста: появился Sber — adapters/out/sber/; появился Kafka-consumer — adapters/in/kafka/.
dependency-cruiser — контракт границ
R-HEX-MOD-2 / R-HEX-MOD-X1: вместо gradle-модулей — .dependency-cruiser.cjs с тремя запретами.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'core-pure',
severity: 'error',
comment: 'core не импортирует инфраструктуру — R-HEX-MOD-2 / R-HEX-CORE-X1',
from: { path: '^src/core' },
to: {
path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
},
},
{
name: 'adapters-independent',
severity: 'error',
comment: 'in-адаптеры не импортируют out-адаптеры — R-HEX-AIN-X4',
from: { path: '^src/adapters/in' },
to: { path: '^src/adapters/out' },
},
{
name: 'nobody-depends-on-app',
severity: 'error',
comment: 'app — закрывающий узел, никто не импортирует его — R-HEX-MOD-5',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' },
},
],
};
Запуск в CI (в package.json):
{
"scripts": {
"arch:check": "depcruise --validate .dependency-cruiser.cjs src"
}
}
PR-пайплайн запускает npm run arch:check как required check — без зелёного статуса мердж заблокирован. Это аналог ArchUnit в Java: не кодревью, а автомат. Одно нарушение падает явным сообщением вида:
error core-pure: src/core/order/aggregate/order.ts
→ node_modules/@nestjs/common/index.js
Нет возможности «не заметить».
core/ — ноль инфраструктурных зависимостей
R-HEX-MOD-2 / R-HEX-CORE-3: core/ зависит только от TS/stdlib и доменных утилит.
// tsconfig.json — path aliases, чтобы core/ не видел ничего лишнего
{
"compilerOptions": {
"paths": {
"core/*": ["src/core/*"]
}
}
}
Что не появляется в package.json зависимостях core/-классов:
@nestjs/common,@nestjs/core,@nestjs/microservices— инфраструктура DI.typeorm— persistence-деталь; маппинг вadapters/out/persistence/*.mapper.ts.class-validator,class-transformer— деталь in-adapter; вcore/нет декораторов на DTO.axios,undici,kafkajs— транспорт; вcore/— только порт-интерфейсы.
Что разрешено:
uuid— генерация идентификаторов.big.js— денежная арифметика без погрешности.- Собственные доменные утилиты без инфраструктурных зависимостей.
Пример чистого агрегата:
// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { CustomerId } from '../value-object/customer-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,
readonly customerId: CustomerId,
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, customerId, 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;
}
get totalAmount(): Money { return this._totalAmount; }
get status(): OrderStatus { return this._status; }
pullEvents(): unknown[] { return this._events.splice(0); }
}
Ни одного импорта из @nestjs/* или typeorm. Зависимость dependency-cruiser (core-pure) зелёная.
Plain-классы в core, wiring в app/
R-HEX-CORE-3: NestJS-декораторы @Injectable() / @Inject() на классах core/ запрещены.
В Java Spring автоматически подбирает beans из classpath через component scan — поэтому @Service в core/ допустим при определённых условиях. В NestJS авто-пикающего стартера нет: если @Injectable() нет, Nest просто не зарегистрирует класс. Но это и не нужно: handler'ы и domain-сервисы в core/ — plain TypeScript-классы, инстанцируются вручную через useFactory-провайдеры в app/ или feature-модулях.
// app/order.module.ts — wiring plain-handler'а
import { Module } from '@nestjs/common';
import { CreateOrderHandler } from 'src/core/order/usecases/create-order.handler';
import {
ORDER_REPOSITORY,
TX_RUNNER,
CLOCK,
} from 'src/core/order/port/out/tokens';
import { TypeOrmOrderRepository } from 'src/adapters/out/persistence/typeorm-order.repository';
import { PgTransactionRunner } from 'src/adapters/out/persistence/pg-transaction-runner';
import { SystemClock } from 'src/adapters/out/clock/system-clock';
@Module({
providers: [
TypeOrmOrderRepository,
PgTransactionRunner,
SystemClock,
{ 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 — plain-класс без декораторов:
// core/order/usecases/create-order.handler.ts
import { OrderRepository } from '../port/out/order-repository';
import { TransactionRunner } from '../../shared/value-object/transaction-runner';
import { Clock } from '../../shared/value-object/clock';
import { CreateOrderCommand } from './create-order.command';
import { Order } from '../aggregate/order';
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);
return this.tx.run(() => this.orders.save(order));
}
}
Нет @Injectable(). Нет @Inject(). Нет зависимости на @nestjs/common. dependency-cruiser доволен.
Per-system out-адаптеры
R-HEX-MOD-3: на каждую внешнюю систему — отдельная папка в adapters/out/.
adapters/out/
persistence/ # TypeORM — PG, реализует OrderRepository, ProductRepository
sber/ # axios-клиент Sber, реализует PaymentPort
notifications/ # SMS-провайдер, реализует NotificationPort
kafka/ # kafkajs, реализует DomainEventPublisher
s3/ # S3-клиент, реализует FileStoragePort
Зачем разделять:
- Изоляция зависимостей.
core/не знает Sber-SDK.persistence/не знает kafkajs. Если меняем SMS-провайдера — правка только вadapters/out/notifications/, остальные папки не трогаются. - Resilience per-system. Таймаут, retry, circuit breaker для Sber и для SMS — разные. Один общий
axios-инстанс для всего исходящего — антипаттерн, правилоR-RES-ISO-1из Resilience Style Guide. - Тестируемость. В интеграционных тестах подменяем конкретный out-адаптер заглушкой через порт-токен. Другие адаптеры не затрагиваются.
Пример биндинга Sber-адаптера на порт:
// adapters/out/sber/sber.module.ts
import { Module } from '@nestjs/common';
import { PAYMENT_PORT } from 'src/core/payment/port/out/payment-port';
import { SberPaymentAdapter } from './sber-payment.adapter';
import { SberHttpClient } from './sber-http.client';
import { SberPaymentMapper } from './sber-payment.mapper';
@Module({
providers: [
SberHttpClient,
SberPaymentMapper,
{ provide: PAYMENT_PORT, useClass: SberPaymentAdapter },
],
exports: [PAYMENT_PORT],
})
export class SberModule {}
Per-purpose in-адаптеры
R-HEX-MOD-4: каждый тип входа — отдельная папка в adapters/in/.
adapters/in/
http/ # публичный REST, JWT с user-audience
http-admin/ # REST для админ-панели, JWT с admin-audience, mTLS
kafka/ # Kafka consumers как entry-point (не простой sync-read)
cli/ # CLI / batch (если есть)
Зачем разделять:
- Разный security-профиль.
http/принимает JWT сaud: user-service;http-admin/—aud: admin-serviceи другойJwtStrategy. Смешать в одной папке — значит потерять изоляцию: один@UseGuards()обслуживает оба контракта. - Независимые DTO-контракты. Публичный API и admin-API могут расходиться по shape. Раздельные папки делают это явным и безопасным.
- Import-контроль. dependency-cruiser (
adapters-independent-правило) не дастhttp/импортироватьhttp-admin/или любойout/-адаптер.
Пример admin-контроллера с отдельным Guard:
// adapters/in/http-admin/product.admin.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AdminJwtGuard } from './guards/admin-jwt.guard';
import { Dispatcher } from 'src/core/shared/dispatcher';
import { GetProductsAdminQuery } from 'src/core/product/usecases/get-products-admin.query';
import { ProductAdminResponseDto } from './dto/product-admin-response.dto';
import { ProductAdminMapper } from './product-admin.mapper';
@Controller('admin/products')
@UseGuards(AdminJwtGuard)
export class ProductAdminController {
constructor(
private readonly dispatcher: Dispatcher,
private readonly mapper: ProductAdminMapper,
) {}
@Get()
async list(): Promise<ProductAdminResponseDto[]> {
const products = await this.dispatcher.dispatch(new GetProductsAdminQuery());
return products.map(p => this.mapper.toAdminDto(p));
}
}
AdminJwtGuard — в adapters/in/http-admin/guards/. Контроллер из adapters/in/http/ не может его импортировать — dependency-cruiser блокирует.
app/ — composition root
R-HEX-MOD-5: app/ собирает всё. Никто не зависит от app/.
app/
main.ts # NestFactory.create, enableShutdownHooks, ValidationPipe
app.module.ts # AppModule: импортирует feature-модули и адаптеры
config/
configuration.ts # типизированный конфиг (ConfigModule.forRoot)
Dockerfile
main.ts минимален — только запуск:
// app/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
AppModule собирает все feature-модули и адаптеры:
// app/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderModule } from './order.module';
import { ProductModule } from './product.module';
import { CustomerModule } from './customer.module';
import { SberModule } from 'src/adapters/out/sber/sber.module';
import { OrderHttpModule } from 'src/adapters/in/http/order-http.module';
import { AdminHttpModule } from 'src/adapters/in/http-admin/admin-http.module';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
TypeOrmModule.forRootAsync({ ... }),
OrderModule,
ProductModule,
CustomerModule,
SberModule,
OrderHttpModule,
AdminHttpModule,
],
})
export class AppModule {}
app/ — это единственный узел, который знает про всех. Он зависит от адаптеров и от core/. Адаптеры не зависят от app/. Это закрывающий узел dependency-cruiser (nobody-depends-on-app-правило).
Стрелка зависимостей
Главное правило, из которого следует всё остальное:
app → adapters → core
app/знает про всех.- Каждый
adapter/знает толькоcore/(через port-интерфейсы и токены). core/не знает никого.- Адаптеры не знают друг друга. Координация — use case в
core/, handler инжектит оба порта.
В числах: если Customer-handler нужен и OrderRepository, и NotificationPort — оба порта инжектируются в CustomerCommandHandler в core/. persistence/ и notifications/ не знают друг о друге.
// core/customer/usecases/register-customer.handler.ts
export class RegisterCustomerHandler {
constructor(
private readonly customers: CustomerRepository,
private readonly notifications: NotificationPort,
private readonly tx: TransactionRunner,
) {}
async handle(cmd: RegisterCustomerCommand): Promise<Customer> {
const customer = Customer.register(cmd.phone, cmd.name);
await this.tx.run(async () => {
await this.customers.save(customer);
await this.notifications.sendWelcome(customer.phone, customer.name);
});
return customer;
}
}
CustomerRepository и NotificationPort — интерфейсы в core/. Реализации (TypeOrmCustomerRepository в persistence/, SmsNotificationAdapter в notifications/) не знают друг о друге.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Полагаться на дисциплину вместо dependency-cruiser | R-HEX-MOD-X1 | .dependency-cruiser.cjs + depcruise в CI как required check |
import { DataSource } from 'typeorm' в core/order/aggregate/order.ts | R-HEX-MOD-X2 / R-HEX-CORE-X2 | TypeORM только в adapters/out/persistence/; маппинг в *.mapper.ts |
User- и admin-контроллеры в одной папке adapters/in/http/ | R-HEX-MOD-X3 | adapters/in/http/ и adapters/in/http-admin/ с отдельными Guards |
@Injectable() на CreateOrderHandler в core/ | R-HEX-CORE-X1 | Plain-класс + useFactory-провайдер в app/order.module.ts |
adapters/in/http/ импортирует adapters/out/persistence/ | R-HEX-AIN-X4 | In-адаптер знает только core/; координация через Dispatcher → Handler |
Бизнес-логика в app/app.module.ts | R-HEX-BOOT-X1 | app/ только собирает модули и биндит порты; логика в core/ |
Куда дальше
- Adapters in — NestJS-контроллеры, маппинг DTO → command, Dispatcher.
- Adapters out — реализация port-интерфейсов, TypeORM и axios-адаптеры.
- Архитектурные тесты —
depcruise --validateв CI: полный конфиг и разбор ошибок. - Bootstrap / Composition root —
AppModule,useFactory-биндинги, конфиг. - Core слой — агрегаты, port-интерфейсы и plain-handlers без NestJS-декораторов.
- Ports — Symbol-токены, интерфейс vs класс, port-исключения в
core/. - Когда переходить на Hexagonal — когда раскладка с dependency-cruiser оправдана.