Опирается на правила: R-HEX-PORT-1R-HEX-PORT-4 и R-HEX-PORT-X1R-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>RepositoryCRUD агрегата (OrderRepository)
Persistence read-projection<X>ViewRepositoryRead-DTO для CQRS
Внешняя HTTP-система<Y>PortPaymentPort, 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-3Plain class; wiring через useFactory в app/; @Inject(TOKEN) через явный параметр конструктора
Нет Symbol-токена, только интерфейсR-HEX-PORT-1export 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 — признаки, что стоит переходить, и признаки, что рано.