Когда сервис растёт, возникает проблема: бизнес-логика начинает напрямую зависеть от базы данных, HTTP-клиентов и брокеров сообщений. Поменять PostgreSQL на другую БД или добавить второй платёжный шлюз — это затрагивает код в самом ядре. В гексагональной архитектуре такая зависимость разворачивается через ports — интерфейсы, которые ядро описывает само, а адаптеры реализуют снаружи.
Зачем нужны ports
Представьте: у вас есть CreateOrderHandler в core/, и он напрямую вызывает SberPaymentClient. Ядро знает про Сбербанк, его HTTP-схему и формат ошибок. Хотите переключиться на Тинькофф — правите handler. Хотите написать тест без реального платёжного шлюза — сложно.
Port решает это: ядро объявляет интерфейс PaymentPort с методами register и cancel. Handler работает с интерфейсом. Какой именно адаптер стоит за ним — Сбер, Тинькофф или мок для теста — handler не знает и знать не должен.
Интерфейс лежит в ядре, реализация — в папке адаптеров, и зависимость смотрит внутрь: ядро собирается и проверяется тестами, ничего не зная ни про Сбер, ни про HTTP. Плата — лишний файл-интерфейс на каждую внешнюю систему.
Почему в 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 не меняется.
Размер порта: потребность, а не возможности
Самая частая ошибка новичка это толстый порт, повторяющий возможности внешней системы: двадцать методов, потому что «у базы есть двадцать запросов» или «у платёжного шлюза двадцать ручек». Такой интерфейс ничего не изолирует: ядро зависит от всей чужой поверхности, и замена адаптера означает реализацию двадцати методов, из которых используются четыре.
Правило: порт описывает потребность ядра, а не возможности системы. Пишется он от сценариев: какие вопросы ядро задаёт и какие действия просит выполнить. Если метод не вызывается ни одним сценарием, его в порту нет.
// плохо: порт повторяет возможности платёжного шлюза
export interface PaymentPort {
charge(req: ChargeRequest): Promise<PaymentResult>;
refund(req: RefundRequest): Promise<RefundResult>;
status(id: PaymentId): Promise<PaymentStatus>;
search(f: PaymentFilter): Promise<Payment[]>; // нужен только отчёту
updateCustomerCard(c: CustomerId, card: Card): Promise<void>; // вообще другой сценарий
receipt(id: PaymentId): Promise<Receipt>; // нужен только уведомлениям
// ещё четырнадцать методов «на всякий случай»
}
// хорошо: три порта по трём потребностям, у каждого свой вызывающий и свой токен
export interface ChargePort { charge(req: ChargeRequest): Promise<PaymentResult>; } // сценарий оплаты
export interface RefundPort { refund(req: RefundRequest): Promise<RefundResult>; } // сценарий возврата
export interface PaymentStatusPort { status(id: PaymentId): Promise<PaymentStatus>; } // сверка и поддержка
export const CHARGE_PORT = Symbol('ChargePort');
export const REFUND_PORT = Symbol('RefundPort');
export const PAYMENT_STATUS_PORT = Symbol('PaymentStatusPort');
Реализация у всех трёх может быть одна и та же: один класс адаптера реализует три интерфейса и биндится на три токена, если все три ходят в один шлюз. Разделение не про число классов, а про то, что каждый сценарий видит только то, что ему нужно.
Признаки, что порт пора разрезать: методов больше пяти-шести и они относятся к разным сценариям; ни один вызывающий не использует больше двух методов; тест сценария вынужден заглушать методы, которые сценарий не вызывает; порт растёт при каждой новой задаче. Обратная крайность тоже встречается: порт на один метод для каждой мелочи это шум. Разумная гранулярность: порт на связную потребность, обычно два-четыре метода.
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.
Частые ошибки
Порт как абстрактный класс с декораторами NestJS. abstract class PaymentPort с @Injectable() тянет @nestjs/common в core/, а адаптер вынужден наследоваться. Порт это interface плюс Symbol-токен, и ядро не знает о NestJS:
// так делать не нужно
@Injectable()
export abstract class PaymentPort {
abstract register(cmd: RegisterPaymentCommand): Promise<RegisterResult>;
}
// правильно
export interface PaymentPort {
register(cmd: RegisterPaymentCommand): Promise<RegisterResult>;
}
export const PAYMENT_PORT = Symbol('PaymentPort');
null там, где отсутствие это ошибка. Если handler ожидает, что объект обязательно существует, лучше отдельный метод, который бросает доменную ошибку, чем проверка null в каждом вызове:
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>; // отсутствие это нормальный ответ (query)
findRequired(id: OrderId): Promise<Order>; // отсутствие это ошибка (command): бросает OrderNotFoundError
}
Порт лежит в папке адаптера. Порт это контракт ядра, он живёт в core/<bc>/ports/; адаптер его реализует и импортирует из ядра, а не наоборот.
Глубже: порт в тестах: поддельная реализация, заглушка или мокрасширенное
То, ради чего порт и вводят, и то, о чём чаще всего не договариваются. Три способа, и у каждого своё место.
Поддельная реализация в памяти это предпочтительный способ для репозиториев: маленький класс на Map, который честно реализует интерфейс, пишется один раз и переиспользуется во всех тестах ядра.
// core/order/ports/__tests__/in-memory-order.repository.ts
export class InMemoryOrderRepository implements OrderRepository {
private readonly store = new Map<string, Order>();
async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id.value) ?? null;
}
async findRequired(id: OrderId): Promise<Order> {
const order = this.store.get(id.value);
if (!order) throw new OrderNotFoundError(id);
return order;
}
async save(order: Order): Promise<void> {
this.store.set(order.id.value, order);
}
}
Почему это лучше мока: тест читается как сценарий («сохранили заказ, выполнили команду, проверили состояние»), а не как список jest.fn() с ожидаемыми вызовами; тесты не ломаются при изменении способа сохранения; одна поддельная реализация обслуживает десятки тестов. Заглушка с заданным ответом подходит для внешних систем: порт платёжного шлюза в тесте отвечает «одобрено» или «отказано». Мок с проверкой вызова оставляют для случая, когда сам вызов и есть результат: письмо отправлено, событие опубликовано.
Поддельные реализации живут в тестовых файлах ядра, а не в основном коде и не в папке адаптера. И один тест стоит написать на саму подделку: набор проверок контракта порта, который запускают и против подделки, и против настоящего адаптера с Testcontainers. Так тесты ядра не врут.
Коротко
- 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 как страж.