← Back to the section

As a service grows, a problem arises: the business logic starts to depend directly on the database, HTTP clients, and message brokers. Switching PostgreSQL for another DB or adding a second payment gateway touches code in the very core. In hexagonal architecture such a dependency is inverted through ports — interfaces that the core describes itself and that adapters implement from the outside.

Why ports are needed

Imagine: you have a CreateOrderHandler in core/, and it calls SberPaymentClient directly. The core knows about Sber, its HTTP schema, and its error format. Want to switch to Tinkoff — you edit the handler. Want to write a test without a real payment gateway — that is hard.

A port solves this: the core declares an interface PaymentPort with the methods register and cancel. The handler works with the interface. Which adapter stands behind it — Sber, Tinkoff, or a mock for a test — the handler does not know and should not know.

core/                         adapters/out/
  order/                        sber/
    port/out/                     sber-payment.adapter.ts  ← implements PaymentPort
      payment.port.ts   ←         ...

Why a Symbol token is needed in NestJS

TypeScript is a statically typed language, but its interfaces are erased at compile time. At the JavaScript runtime the PaymentPort interface does not exist. NestJS cannot resolve a dependency by interface — it needs a real key.

For this, a Symbol token is declared alongside the interface:

// 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') is a unique value at runtime. It is exactly what is used as a key in the NestJS DI container.

Each port file exports two named exports: the interface and the token. All of this lives in core/<bc>/port/out/.

The structure of the ports folder

src/
  core/
    order/
      aggregate/order.aggregate.ts
      port/out/
        order-repository.port.ts       # saving/loading the aggregate
        order-view-repository.port.ts  # read projection (for CQRS)
        payment.port.ts                # external payment gateway
        notification.port.ts           # SMS / email
        order-event-publisher.port.ts  # outbound events

Naming convention:

What the port doesName
Saves/loads the aggregate<X>Repository — for example, OrderRepository
Reads a projection (CQRS)<X>ViewRepository
Calls an external HTTP system<Y>PortPaymentPort, SmsPort
Publishes events directly<Z>EventPublisher

Repository without the Port suffix is an established convention from DDD. For everything else the Port suffix signals: this is a contract to an external system.

How NestJS binds the implementation by token

In app/ (or app/<bc>.module.ts) the token is tied to a concrete adapter:

// app/order.module.ts
{
  provide: PAYMENT_PORT,
  useClass: SberPaymentAdapter,
}

The handler in core/ is a plain class without @Injectable. So that the handler receives its dependencies, a factory is described in app/:

// app/order.module.ts
{
  provide: CreateOrderHandler,
  useFactory: (payment: PaymentPort, orders: OrderRepository) =>
    new CreateOrderHandler(payment, orders),
  inject: [PAYMENT_PORT, ORDER_REPOSITORY],
}

This approach lets CreateOrderHandler stay a pure class — no imports from @nestjs/common in core/.

Port methods take domain types

This is one of the key rules. An external system's DTO does not get into a port signature:

// Correct — domain types
export interface PaymentPort {
  register(cmd: RegisterPayment): Promise<RegisterResult>;
  cancel(paymentId: PaymentId): Promise<void>;
}
// A common mistake — the payment gateway's DTO in core/
export interface PaymentPort {
  register(req: SberRegisterRequest): Promise<SberRegisterResponse>;
}

If the core accepts SberRegisterRequest, it knows about Sber's format. When the gateway changes you will have to change the handlers. Tests will require assembling objects with the fields of Sber's schema, even though that is an adapter detail.

PaymentPort accepts a domain RegisterPayment (amount, orderId, description) and returns a domain RegisterResult (paymentId, redirectUrl). The mapping into Sber's format is inside SberPaymentAdapter in adapters/out/sber/.

The port's error hierarchy

The base error classes are declared in core/, the concrete ones in the 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 PaymentDeclinedError extends PaymentPortError {
  constructor(paymentId: PaymentId, readonly reason: string) {
    super(`Payment declined: ${paymentId.value} — ${reason}`);
    this.name = 'PaymentDeclinedError';
  }
}

In the adapter — a concrete subclass with the details of a specific gateway:

// adapters/out/sber/sber.error.ts
export class SberError extends PaymentPortError {
  constructor(message: string, cause: unknown) {
    super(message, cause);
    this.name = 'SberError';
  }
}

The handler in core/ catches the domain error type without knowing about 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);
}

Swap SberPaymentAdapter for TinkoffPaymentAdapter — the handler does not change.

An inbound port is a UseCase

In classic hexagonal architecture there is the notion of an "inbound port" — the outside world calls the core through it. In this approach the role of the inbound port is played by the Dispatcher: the controller passes a command through it, and the Dispatcher finds the right 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);
  }
}

The controller does not call CreateOrderHandler directly — it does not depend on concrete handlers. More on this in Use Case Pattern.

In short

  • A port is an interface in core/<bc>/port/out/ that describes what the core needs; an adapter in adapters/out/ implements it.
  • TypeScript interfaces are erased in JS: each interface needs a Symbol token alongside it for DI.
  • Port methods take domain types, not an external system's DTO; the mapping is inside the adapter.
  • Errors: base classes in core/, concrete subclasses (with the gateway's details) in the adapter; the handler catches the domain type.
  • A handler is a plain class, without @Injectable; wiring is through useFactory in app/ — so that core/ has no imports from NestJS.
  • An inbound port is not needed as a separate interface: the Dispatcher already plays that role.
  • A port is always an interface, not a class: only an interface lets you substitute the implementation in tests.
  • Adapters out — who implements the port interface and how it is bound by token.
  • Adapters in — how the controller uses the Dispatcher as the core's entry point.
  • Composition root — where AppModule assembles all the token bindings.
  • Core layer — the purity of core/ and dependency-cruiser as its guard.