In hexagonal architecture all the business code lives in core/. But sooner or later the application needs to reach outside: write data to a database, call a payment gateway's API, publish an event to Kafka. That is what out adapters are for — they translate a call from the domain world into the language of a specific external system and carry the response back.
What an out adapter is
Imagine that core/ is the headquarters that makes decisions. The out adapter is the executor that speaks the language of a specific external system. Headquarters issues an order in its own terms (register(cmd: RegisterPayment)), and the adapter translates that order into an HTTP request to Sber, waits for the response, translates it back into a domain result, and returns it to headquarters. That is where its job ends.
Three rules of an out adapter:
- it accepts a call from
core/through a port interface; - it maps domain types into the external system's format and back;
- it makes no business decisions — that is the job of
core/.
One external system, one folder
If you dump all outgoing calls into a single class, you get chaos: one timeout breaks everyone, switching the SMS provider touches code next to TypeORM, database tests drag along Kafka stubs.
The right structure is one folder per external system:
src/
adapters/
out/
persistence/ # TypeORM: OrderRepository, ProductRepository
sber/ # axios + Sber API: PaymentPort
sms/ # SMS provider's HTTP client: SmsPort
kafka/ # kafkajs: OrderEventPublisher
s3/ # AWS SDK: StoragePort
What this gives you in practice:
- Switching the SMS provider means changes only inside the
sms/folder; the rest are untouched. - Timeouts and retries are configured separately for Sber and for Kafka.
- The
sber/tests spin up an HTTP stub, thepersistence/tests spin up a database. They do not overlap.
The adapter implements a port interface
The port interface is defined in core/ — it is what the domain code wants from the outside world. The adapter implements that interface.
// core/payment/port/out/payment-port.ts
export const PAYMENT_PORT = Symbol('PaymentPort');
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>;
cancel(paymentId: PaymentId): Promise<void>;
}
Note the Symbol('PaymentPort'). In JavaScript, TypeScript interfaces exist only at compile time — at runtime they are gone. NestJS cannot resolve a dependency by interface because there is no metadata there. A Symbol token is a runtime identifier by which the container finds the right implementation.
Now the adapter itself:
// adapters/out/sber/sber-payment.adapter.ts
import { Injectable } from '@nestjs/common';
import { PaymentPort } from '../../../core/payment/port/out/payment-port';
import { SberApiClient } from './sber-api.client';
import { SberPaymentMapper } from './sber-payment.mapper';
@Injectable()
export class SberPaymentAdapter implements PaymentPort {
constructor(
private readonly sberApi: SberApiClient,
private readonly mapper: SberPaymentMapper,
) {}
async register(cmd: RegisterPayment): Promise<RegisterResult> {
try {
const request = this.mapper.toApi(cmd);
const response = await this.sberApi.register(request);
return this.mapper.toDomain(response);
} catch (err) {
throw new SberPaymentError('Sber register failed', err);
}
}
async cancel(paymentId: PaymentId): Promise<void> {
try {
await this.sberApi.cancel(paymentId.value);
} catch (err) {
throw new SberPaymentError('Sber cancel failed', err);
}
}
}
And the token binding in the module:
// app/payment.module.ts
@Module({
providers: [
SberApiClient,
SberPaymentMapper,
{ provide: PAYMENT_PORT, useClass: SberPaymentAdapter },
],
exports: [PAYMENT_PORT],
})
export class PaymentModule {}
@Injectable() on the adapter is fine: it lives in adapters/out/, not in core/. But in core/ NestJS decorators are out of place — there you should have pure classes with no framework dependencies.
The mapper — a separate file in the adapter's folder
Each adapter knows the quirks of its own system: Sber's amounts are in kopecks, statuses are numbers, currency codes follow ISO 4217. That knowledge belongs in the mapper inside the sber/ folder, not in core/.
// adapters/out/sber/sber-payment.mapper.ts
@Injectable()
export class SberPaymentMapper {
toApi(cmd: RegisterPayment): SberRegisterRequest {
return {
orderNumber: cmd.orderId.value,
amount: Math.round(cmd.amount.amountDecimal * 100), // Sber expects kopecks
currency: 978, // RUB per ISO 4217
description: cmd.description,
};
}
toDomain(response: SberRegisterResponse): RegisterResult {
return new RegisterResult(
new PaymentId(response.orderId),
new URL(response.formUrl),
this.mapStatus(response.orderStatus),
);
}
private mapStatus(sberStatus: number): PaymentStatus {
const map: Record<number, PaymentStatus> = {
0: PaymentStatus.REGISTERED,
1: PaymentStatus.AUTHORIZED,
2: PaymentStatus.DEPOSITED,
3: PaymentStatus.CANCELLED,
};
const status = map[sberStatus];
if (status === undefined) {
throw new SberPaymentError(`Unknown Sber status: ${sberStatus}`);
}
return status;
}
}
The mapper is the boundary between two worlds. On the way in — domain types (RegisterPayment, PaymentId, Money). On the way out — Sber DTOs, and vice versa. No SberRegisterResponse should ever leave the sber/ folder — a port method always returns a domain result.
The persistence adapter
A database is the same kind of out adapter, only the system is called "PostgreSQL via TypeORM". The structure is the same: a persistence/ folder, an implementation of a port interface, a mapper.
// adapters/out/persistence/order/order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly repo: Repository<OrderEntity>,
private readonly mapper: OrderMapper,
) {}
async findById(id: OrderId): Promise<Order> {
const entity = await this.repo.findOne({ where: { id: id.value } });
if (!entity) throw new OrderNotFoundError(id);
return this.mapper.toDomain(entity);
}
async save(order: Order): Promise<void> {
await this.repo.save(this.mapper.toEntity(order));
}
}
// adapters/out/persistence/mapper/order.mapper.ts
@Injectable()
export class OrderMapper {
toDomain(entity: OrderEntity): Order {
return Order.reconstitute({
id: new OrderId(entity.id),
customerId: new CustomerId(entity.customerId),
status: entity.status,
createdAt: entity.createdAt,
});
}
toEntity(order: Order): OrderEntity {
const entity = new OrderEntity();
entity.id = order.id.value;
entity.customerId = order.customerId.value;
entity.status = order.status;
entity.createdAt = order.createdAt;
return entity;
}
}
TypeOrmOrderRepository knows TypeORM and OrderEntity. TypeORM is not imported in core/ — there should be no @InjectRepository, no Repository<T>, no entity classes there.
Adapters do not know about each other
Each adapter knows only its own system:
persistence/— TypeORM. Knows nothing about axios or kafkajs.sber/— axios and the Sber API. Knows nothing about TypeORM.kafka/— kafkajs. Knows nothing about Sber or the database.
This is no accident: if SberPaymentAdapter starts reaching into TypeOrmOrderRepository, the two become inseparable. Anything that requires working with several adapters at once is business logic, and its place is in a handler inside core/.
Example: a fallback to another payment gateway is done in a handler, not in an adapter:
// core/payment/usecases/register-payment.handler.ts
export class RegisterPaymentHandler {
constructor(
private readonly sber: PaymentPort,
private readonly ok: PaymentPort,
) {}
async handle(cmd: RegisterPayment): Promise<RegisterResult> {
try {
return await this.sber.register(cmd);
} catch {
return this.ok.register(cmd); // the decision to "try another gateway" is here
}
}
}
Binding the two tokens in the module:
{
provide: RegisterPaymentHandler,
useFactory: (sber: PaymentPort, ok: PaymentPort) =>
new RegisterPaymentHandler(sber, ok),
inject: [SBER_PAYMENT_PORT, OK_PAYMENT_PORT],
}
RegisterPaymentHandler is a plain class with no @Injectable(), no import of @nestjs/common. This is deliberate: core/ does not depend on NestJS.
Common mistakes
Returning an external system's DTO from a port method. If register() returns SberRegisterResponse, then core/ starts depending on Sber's details. When the provider changes, you will have to change the core. Correct: the mapper translates SberRegisterResponse into RegisterResult before returning.
Placing business logic in the adapter. It looks harmless — "a small if after the API response". But if a second payment gateway appears tomorrow (ok/ next to sber/), that if has to be duplicated or urgently extracted. Logic in the core from the start solves the problem.
One adapter for several systems. ProductAdapter implements PaymentPort, SmsPort, StoragePort — three unrelated systems in one class. A payments test touches the SMS code, switching storage breaks the payments test. One folder and class per system.
An adapter calling another adapter. SberPaymentAdapter injects TypeOrmOrderRepository — this violates isolation. A handler in core/ injects both ports and coordinates them.
In short
- An out adapter is the service's "exit" into the outside world. It implements a port interface from
core/, maps the call into the system's format, and carries the response back. - One folder per external system —
adapters/out/<system>/: independent dependencies, settings, tests. - In Node/TypeScript the interface is erased at runtime — a Symbol token is mandatory, otherwise NestJS cannot resolve the dependency.
- The mapper is a separate file in the adapter's folder. It knows the system's specifics (kopecks, numeric codes). These details do not seep into
core/. - A port method returns only a domain result — never the external system's DTO.
- Adapters do not know about each other. Anything requiring coordination of two adapters is business logic, and its place is in a handler.
- Business logic in the adapter is a common mistake. Even a "small if" will eventually need to move into
core/.
What to read next
- Adapters in — the inbound side: controllers, validation, request mapping.
- Ports — what the out adapter implements: Symbol tokens, domain types in signatures, port errors.
- Bootstrap / composition root — how all ports are bound to adapters through
useClass/useFactory. - Core layer — what lives in
core/: aggregates, ports, use cases without NestJS decorators.