← назад к разделу

HEXAGONAL · PORTS Фаза 29. Архитектурные паттерны сервиса

Обязательно

Ports: как изолировать ядро от внешнего мира

TypeScript · Interfaces · Symbol tokens

Доменные сигнатуры, ошибки, DI и inbound use cases.

1. Порт принадлежит ядру

1 / 5

PaymentPort описывает, что нужно сценарию, а не возможности конкретного SDK. Адаптер реализует его снаружи.

Core use casePaymentPortowned by coreSDK adapter

2. Сигнатуры используют доменные типы

2 / 5

Money, OrderId и PaymentResult не содержат axios response или TypeORM entity. Внешние структуры маппятся.

Port signatureMoney + OrderIdPaymentResultNot allowedAxiosResponseOrderEntity

3. Ошибки получают доменный смысл

3 / 5

PaymentDeclined и PaymentUnavailable описывают результат, а не код SDK. Адаптер переводит timeout и HTTP errors.

SDK errorsError mappertimeout + 4xx + 5xxDomain errors

4. Symbol-токен связывает DI

4 / 5

Интерфейс исчезает после компиляции, Symbol остаётся. Nest provider связывает токен с конкретным адаптером.

PAYMENT_PORTprovide + useClassruntime bindingStripeAdapter

5. Inbound port - сам сценарий

5 / 5

CreateOrderHandler принимает Command и возвращает результат. HTTP и Kafka вызывают один use case, а не отдельные бизнес-пути.

HTTPKafkaCreateOrderHandlerinbound port

Когда сервис растёт, возникает проблема: бизнес-логика начинает напрямую зависеть от базы данных, HTTP-клиентов и брокеров сообщений. Поменять PostgreSQL на другую БД или добавить второй платёжный шлюз — это затрагивает код в самом ядре. В гексагональной архитектуре такая зависимость разворачивается через ports — интерфейсы, которые ядро описывает само, а адаптеры реализуют снаружи.

Зачем нужны ports

Представьте: у вас есть CreateOrderHandler в core/, и он напрямую вызывает SberPaymentClient. Ядро знает про Сбербанк, его HTTP-схему и формат ошибок. Хотите переключиться на Тинькофф — правите handler. Хотите написать тест без реального платёжного шлюза — сложно.

Port решает это: ядро объявляет интерфейс PaymentPort с методами register и cancel. Handler работает с интерфейсом. Какой именно адаптер стоит за ним — Сбер, Тинькофф или мок для теста — handler не знает и знать не должен.

core/order/port/out/ adapters/out/sber/ payment.port.ts объявляет PaymentPort sber-payment.adapter.ts реализует его

Интерфейс лежит в ядре, реализация — в папке адаптеров, и зависимость смотрит внутрь: ядро собирается и проверяется тестами, ничего не зная ни про Сбер, ни про 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 как страж.