Когда сервис растёт, возникает проблема: бизнес-логика начинает напрямую зависеть от базы данных, HTTP-клиентов и брокеров сообщений. Поменять PostgreSQL на другую БД или добавить второй платёжный шлюз — это затрагивает код в самом ядре. В гексагональной архитектуре такая зависимость разворачивается через ports — интерфейсы, которые ядро описывает само, а адаптеры реализуют снаружи.
Зачем нужны ports
Представьте: у вас есть CreateOrderHandler в core/, и он напрямую вызывает SberPaymentClient. Ядро знает про Сбербанк, его HTTP-схему и формат ошибок. Хотите переключиться на Тинькофф — правите handler. Хотите написать тест без реального платёжного шлюза — сложно.
Port решает это: ядро объявляет интерфейс PaymentPort с методами register и cancel. Handler работает с интерфейсом. Какой именно адаптер стоит за ним — Сбер, Тинькофф или мок для теста — handler не знает и знать не должен.
core/ adapters/out/
order/ sber/
port/out/ sber-payment.adapter.ts ← реализует PaymentPort
payment.port.ts ← ...
Почему в NestJS нужен Symbol-токен
TypeScript — язык со статической типизацией, но интерфейсы в нём стираются при компиляции. В JavaScript-рантайме интерфейса PaymentPort не существует. NestJS не может разрешить зависимость по интерфейсу — ему нужен реальный ключ.
Для этого рядом с интерфейсом объявляют Symbol-токен:
// 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('PaymentPort') — уникальное значение в рантайме. Именно оно используется как ключ в DI-контейнере NestJS.
Каждый файл порта экспортирует два именованных экспорта: интерфейс и токен. Всё это живёт в core/<bc>/port/out/.
Структура папки ports
src/
core/
order/
aggregate/order.aggregate.ts
port/out/
order-repository.port.ts # сохранение/загрузка агрегата
order-view-repository.port.ts # read-проекция (для CQRS)
payment.port.ts # внешний платёжный шлюз
notification.port.ts # SMS / email
order-event-publisher.port.ts # исходящие события
Соглашение по именованию:
| Что делает порт | Имя |
|---|---|
| Сохраняет/загружает агрегат | <X>Repository — например, OrderRepository |
| Читает проекцию (CQRS) | <X>ViewRepository |
| Ходит во внешнюю HTTP-систему | <Y>Port — PaymentPort, SmsPort |
| Публикует события напрямую | <Z>EventPublisher |
Repository без суффикса Port — устоявшееся соглашение из DDD. Для всего остального суффикс Port сигнализирует: это контракт к внешней системе.
Как NestJS биндит реализацию по токену
В app/ (или app/<bc>.module.ts) токен связывается с конкретным адаптером:
// app/order.module.ts
{
provide: PAYMENT_PORT,
useClass: SberPaymentAdapter,
}
Handler в core/ — это обычный класс без @Injectable. Чтобы handler получил зависимости, в app/ описывают фабрику:
// app/order.module.ts
{
provide: CreateOrderHandler,
useFactory: (payment: PaymentPort, orders: OrderRepository) =>
new CreateOrderHandler(payment, orders),
inject: [PAYMENT_PORT, ORDER_REPOSITORY],
}
Такой подход позволяет CreateOrderHandler оставаться чистым классом — никаких импортов из @nestjs/common в core/.
Методы порта принимают domain-типы
Это одно из ключевых правил. В сигнатуру порта не попадают DTO внешней системы:
// Правильно — domain-типы
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>;
cancel(paymentId: PaymentId): Promise<void>;
}
// Частая ошибка — DTO платёжного шлюза в core/
export interface PaymentPort {
register(req: SberRegisterRequest): Promise<SberRegisterResponse>;
}
Если ядро принимает SberRegisterRequest, оно знает о формате Сбербанка. При смене шлюза придётся менять handler'ы. Тесты потребуют собирать объекты с полями схемы Сбера, хотя это деталь адаптера.
PaymentPort принимает доменный RegisterPayment (amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в формат Сбера — внутри SberPaymentAdapter в adapters/out/sber/.
Иерархия ошибок порта
Базовые классы ошибок объявлены в core/, конкретные — в адаптере.
// 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 PaymentDeclinedError extends PaymentPortError {
constructor(paymentId: PaymentId, readonly reason: string) {
super(`Payment declined: ${paymentId.value} — ${reason}`);
this.name = 'PaymentDeclinedError';
}
}
В адаптере — конкретный подкласс с деталями конкретного шлюза:
// adapters/out/sber/sber.error.ts
export class SberError extends PaymentPortError {
constructor(message: string, cause: unknown) {
super(message, cause);
this.name = 'SberError';
}
}
Handler в core/ ловит доменный тип ошибки, не зная о SberError:
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);
}
Сменили SberPaymentAdapter на TinkoffPaymentAdapter — handler не меняется.
Inbound port — это UseCase
В классической гексагональной архитектуре есть понятие «входящего порта» — через него внешний мир вызывает ядро. В этом подходе роль входящего порта играет Dispatcher: контроллер передаёт команду через него, а Dispatcher находит нужный handler.
// 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);
return this.mapper.toResponse(order);
}
}
Контроллер не вызывает CreateOrderHandler напрямую — он не зависит от конкретных handler'ов. Подробнее об этом — в Use Case Pattern.
Коротко
- Port — интерфейс в
core/<bc>/port/out/, который описывает что ядру нужно; адаптер вadapters/out/реализует его. - TypeScript-интерфейсы стираются в JS: рядом с каждым интерфейсом нужен Symbol-токен для DI.
- Методы порта принимают domain-типы, не DTO внешней системы; маппинг — внутри адаптера.
- Ошибки: базовые классы в
core/, конкретные подклассы (с деталями шлюза) — в адаптере; handler ловит доменный тип. - Handler — это plain class, без
@Injectable; wiring черезuseFactoryвapp/— чтобы вcore/не было импортов из NestJS. - Inbound port не нужен как отдельный интерфейс:
Dispatcherуже играет эту роль. - Port — всегда интерфейс, не класс: только интерфейс позволяет подменить реализацию в тестах.
Что почитать дальше
- Adapters out — кто реализует port-интерфейс и как биндится по токену.
- Adapters in — как контроллер использует
Dispatcherкак входную точку ядра. - Composition root — где
AppModuleсобирает все биндинги токенов. - Core layer — чистота
core/и dependency-cruiser как страж.