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 does | Name |
|---|---|
| Saves/loads the aggregate | <X>Repository — for example, OrderRepository |
| Reads a projection (CQRS) | <X>ViewRepository |
| Calls an external HTTP system | <Y>Port — PaymentPort, 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 inadapters/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 throughuseFactoryinapp/— so thatcore/has no imports from NestJS. - An inbound port is not needed as a separate interface: the
Dispatcheralready plays that role. - A port is always an interface, not a class: only an interface lets you substitute the implementation in tests.
What to read next
- Adapters out — who implements the port interface and how it is bound by token.
- Adapters in — how the controller uses the
Dispatcheras the core's entry point. - Composition root — where
AppModuleassembles all the token bindings. - Core layer — the purity of
core/and dependency-cruiser as its guard.