← Back to the section

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, the persistence/ 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/.
  • 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.