Опирается на правила:
R-HEX-PORT-1…R-HEX-PORT-4иR-HEX-PORT-X1…R-HEX-PORT-X4из Hexagonal Style Guide → раздел 4. Ports.
Важно знать
- Outbound port — интерфейс + Symbol-токен в
core/<bc>/port/out/. Интерфейсы TypeScript стираются в рантайме — без токена NestJS DI не может разрешить зависимость.- Имена:
<X>Repository(persistence write),<X>ViewRepository(CQRS read-side),<Y>Port(внешние HTTP-системы),<Z>EventPublisher(события без outbox).- Методы порта оперируют domain-типами, не DTO внешней системы.
- Port-ошибки объявлены в
core/(PaymentPortError); конкретные подклассы (SberError) — в out-adapter. Handler ловит базовый класс.- Inbound port = UseCase. Отдельного «InboundPort»-интерфейса нет —
Dispatcherуже играет эту роль.- Port — всегда интерфейс, не класс. Класс убивает подмену в тестах.
- Handler и domain-классы в
core/— plain classes без@Injectable; wiring черезuseFactoryвapp/.
В Hexagonal архитектуре стрелка зависимостей: app → adapters → core. Чтобы это работало, core/ не знает про адаптеры. Но core нужно ходить в БД, дёргать платёжку, эмитить события. Решение — port-интерфейсы в core/. Core описывает что ему нужно, adapter в своей папке реализует этот контракт. На старте Nest разрешает токены через AppModule и подкладывает нужную реализацию.
Где живёт port и как он называется
R-HEX-PORT-1: outbound port — интерфейс + Symbol-токен в core/<bc>/port/out/.
src/
core/
order/
aggregate/order.aggregate.ts
port/out/
order-repository.port.ts # persistence (агрегат)
order-view-repository.port.ts # read-проекция (CQRS)
payment.port.ts # внешняя HTTP-система
notification.port.ts # SMS / email
order-event-publisher.port.ts # исходящие события (если нет outbox)
Конвенция имён:
| Тип порта | Имя | Назначение |
|---|---|---|
| Persistence write | <X>Repository | CRUD агрегата (OrderRepository) |
| Persistence read-projection | <X>ViewRepository | Read-DTO для CQRS |
| Внешняя HTTP-система | <Y>Port | PaymentPort, SmsPort, StoragePort |
| Исходящие события (без outbox) | <Z>EventPublisher | Публикация напрямую |
<X>Repository без суффикса Port — историческое соглашение из DDD; «repository» самоговорящее. Для всего остального — суффикс Port: видно, что это контракт к внешней системе.
Каждый файл порта экспортирует два именованных экспорта — интерфейс и токен:
// core/order/port/out/payment.port.ts
export const PAYMENT_PORT = Symbol('PaymentPort');
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>;
cancel(paymentId: PaymentId): Promise<void>;
}
Токен Symbol уникален в рантайме. В app/ он используется как ключ DI:
// app/order.module.ts
{
provide: PAYMENT_PORT,
useClass: SberPaymentAdapter,
}
В handler токен указывается через @Inject:
// core/order/usecases/create-order.handler.ts — plain class, без @Injectable
export class CreateOrderHandler {
constructor(
@Inject(PAYMENT_PORT) private readonly payment: PaymentPort,
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
) {}
}
Но так как @Inject из @nestjs/common, а core/ запрещает NestJS-импорты (R-HEX-CORE-3), wiring выносится в useFactory в app/:
// app/order.module.ts
{
provide: CreateOrderHandler,
useFactory: (payment: PaymentPort, orders: OrderRepository) =>
new CreateOrderHandler(payment, orders),
inject: [PAYMENT_PORT, ORDER_REPOSITORY],
}
Handler остаётся чистым — никаких @nestjs/* в core/.
Методы порта оперируют domain-типами
R-HEX-PORT-2: ни DTO внешней системы, ни TypeORM-Entity в сигнатуру не попадают.
// ХОРОШО
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>; // domain-тип
cancel(paymentId: PaymentId): Promise<void>; // domain Value Object
}
// ПЛОХО
export interface PaymentPort {
register(req: SberRegisterRequest): Promise<SberRegisterResponse>; // DTO Сбера
}
Проблемы плохого варианта:
core/знает про Сбер. Переходим на другую платёжку — правим везде, гдеSberRegisterRequestупомянут, включая все handler'ы.- Тесты handler'ов требуют собирать
SberRegisterRequestсо всеми полями схемы Сбера, хотя это деталь адаптера. - Маппинг просочился в core: JSON-аннотации, camelCase/snake_case — это знание о Сбер-API, не о домене.
PaymentPort принимает доменный RegisterPayment (amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в Сбер-DTO живёт в SberPaymentAdapter внутри adapters/out/sber/.
Port-ошибки — иерархия
R-HEX-PORT-3: базовые классы ошибок объявлены в core/, конкретные — в out-adapter.
// core/payment/port/out/payment-port.error.ts
export class PaymentPortError extends Error {
constructor(message: string, readonly cause?: unknown) {
super(message);
this.name = 'PaymentPortError';
}
}
export class PaymentNotFoundError extends PaymentPortError {
constructor(paymentId: PaymentId) {
super(`Payment not found: ${paymentId.value}`);
this.name = 'PaymentNotFoundError';
}
}
export class PaymentDeclinedError extends PaymentPortError {
constructor(paymentId: PaymentId, readonly reason: string) {
super(`Payment declined: ${paymentId.value} — ${reason}`);
this.name = 'PaymentDeclinedError';
}
}
В out-adapter — конкретный подкласс с деталями Сбера:
// adapters/out/sber/sber.error.ts
import { PaymentPortError } from '../../../core/payment/port/out/payment-port.error';
export class SberError extends PaymentPortError {
constructor(message: string, cause: unknown) {
super(message, cause);
this.name = 'SberError';
}
}
// adapters/out/sber/sber-payment.adapter.ts
export class SberPaymentAdapter implements PaymentPort {
async register(cmd: RegisterPayment): Promise<RegisterResult> {
try {
const response = await this.httpClient.post('/register', this.mapper.toRequest(cmd));
return this.mapper.toDomain(response);
} catch (err) {
throw new SberError('Ошибка регистрации платежа в Сбере', err);
}
}
}
Handler в core ловит доменный тип ошибки, не специфический:
// core/order/usecases/create-order.handler.ts
async handle(cmd: CreateOrderCommand): Promise<Order> {
let paymentResult: RegisterResult;
try {
paymentResult = await this.payment.register(new RegisterPayment(cmd.amount, cmd.orderId));
} catch (err) {
if (err instanceof PaymentDeclinedError) {
throw new OrderPaymentDeclinedError(cmd.orderId, err.reason);
}
throw new PaymentSystemUnavailableError(err);
}
return this.orders.save(Order.create(cmd, paymentResult));
}
Сменили SberPaymentAdapter на TinkoffPaymentAdapter — handler не меняется. Меняется только конкретный подкласс в out-adapter.
Inbound port = UseCase
R-HEX-PORT-4: отдельный «InboundPort»-интерфейс не нужен. В UCP UseCase + Handler — это вход в core, а Dispatcher играет роль того, что в классическом Hexagonal называют InboundPort.
// adapters/in/http/order.controller.ts
@Controller('orders')
export class OrderController {
constructor(private readonly dispatcher: Dispatcher) {}
@Post()
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
const cmd = this.mapper.toCommand(dto);
const order = await this.dispatcher.dispatch(cmd); // ← inbound-вход в core
return this.mapper.toResponse(order);
}
}
Controller не зовёт CreateOrderHandler напрямую. Он зовёт Dispatcher, который сам находит нужный handler по типу команды. Controller знает только про Dispatcher — зависимость от каждого конкретного handler'а не нужна.
Подробнее об UseCase и Dispatcher — в Use Case Pattern.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
payment.port.ts лежит в adapters/out/sber/ | R-HEX-PORT-X1 | Порт — контракт core, живёт в core/<bc>/port/out/; adapter — реализация в своей папке |
register(req: SberRegisterRequest): Promise<SberRegisterResponse> в интерфейсе порта | R-HEX-PORT-X2 | Сигнатура через domain-типы; маппинг в DTO — внутри out-adapter |
findById(): Promise<Order \| null> где null = ошибка | R-HEX-PORT-X3 | Бросай OrderNotFoundError с domain-meaning; null — только если отсутствие допустимо по логике |
export class PaymentPort { abstract register(...): ... } | R-HEX-PORT-X4 | Только interface; класс нельзя подменить в тесте без сложного мокирования |
@Injectable() на handler'е в core/ | R-HEX-CORE-3 | Plain class; wiring через useFactory в app/; @Inject(TOKEN) через явный параметр конструктора |
| Нет Symbol-токена, только интерфейс | R-HEX-PORT-1 | export const PAYMENT_PORT = Symbol('PaymentPort') рядом с интерфейсом; TS-интерфейсы стираются в JS-рантайме |
Куда дальше
- Adapters out — кто реализует port-интерфейс и как биндится по токену.
- Adapters in — как NestJS-контроллер использует
Dispatcherкак inbound-вход. - Composition root — где
AppModuleсобирает все биндинги токенов. - Core layer — чистота
core/и dependency-cruiser как guard. - Module structure — раскладка папок и контракт
.dependency-cruiser.cjs. - Architecture tests —
depcruise --validateв CI как обязательный check. - When to use Hexagonal — признаки, что стоит переходить, и признаки, что рано.