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

Когда сервис растёт, возникает проблема: бизнес-логика начинает напрямую зависеть от базы данных, 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>PortPaymentPort, 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 как страж.