← назад к разделу

В гексагональной архитектуре весь бизнес-код живёт в core/. Но приложению рано или поздно нужно обращаться наружу: записать данные в базу, вызвать API платёжного шлюза, опубликовать событие в Kafka. Для этого и нужны out-адаптеры — они переводят вызов из доменного мира в язык конкретной внешней системы и несут ответ обратно.

Что такое out-адаптер

Представьте, что core/ — это штаб, который принимает решения. Out-адаптер — исполнитель, который говорит на языке конкретной внешней системы. Штаб отдаёт приказ в своих терминах (register(cmd: RegisterPayment)), а адаптер переводит этот приказ в HTTP-запрос к Сберу, ждёт ответ, переводит обратно в доменный результат и возвращает в штаб. На этом его работа заканчивается.

Три правила out-адаптера:

  • принимает вызов из core/ через port-интерфейс;
  • маппит domain-типы в формат внешней системы и обратно;
  • не принимает никаких бизнес-решений — это дело core/.

Одна внешняя система — одна папка

Если сваливать все исходящие вызовы в один класс, получается хаос: один тайм-аут ломает всех, смена SMS-провайдера затрагивает код рядом с TypeORM, тесты для базы данных тащат за собой заглушки для Kafka.

Правильная структура — одна папка на каждую внешнюю систему:

src/
  adapters/
    out/
      persistence/   # TypeORM: OrderRepository, ProductRepository
      sber/          # axios + Sber API: PaymentPort
      sms/           # HTTP-клиент SMS-провайдера: SmsPort
      kafka/         # kafkajs: OrderEventPublisher
      s3/            # AWS SDK: StoragePort

Что это даёт на практике:

  • Смена SMS-провайдера — правки только в папке sms/, остальные не затронуты.
  • Тайм-ауты и повторные попытки настраиваются отдельно для Sber и для Kafka.
  • Тесты sber/ поднимают заглушку для HTTP, тесты persistence/ — базу данных. Они не пересекаются.

Адаптер реализует port-интерфейс

Port-интерфейс определяется в core/ — это то, что доменный код хочет от внешнего мира. Адаптер этот интерфейс реализует.

// 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>;
}

Обратите внимание на Symbol('PaymentPort'). В JavaScript интерфейсы TypeScript существуют только на этапе компиляции — в runtime их нет. NestJS не может разрешить зависимость по интерфейсу, потому что там нет никаких метаданных. Symbol-токен — это runtime-идентификатор, по которому контейнер найдёт нужную реализацию.

Теперь сам адаптер:

// 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);
    }
  }
}

И биндинг по токену в модуле:

// app/payment.module.ts
@Module({
  providers: [
    SberApiClient,
    SberPaymentMapper,
    { provide: PAYMENT_PORT, useClass: SberPaymentAdapter },
  ],
  exports: [PAYMENT_PORT],
})
export class PaymentModule {}

@Injectable() на адаптере — нормально: он живёт в adapters/out/, не в core/. А вот в core/ NestJS-декораторы не уместны — там должны быть чистые классы без зависимостей от фреймворка.

Маппер — отдельный файл в папке адаптера

Каждый адаптер знает особенности своей системы: у Сбера суммы в копейках, статусы — числа, коды валют по ISO 4217. Этому знанию место в маппере внутри папки sber/, а не в 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 принимает копейки
      currency: 978,                                       // RUB по 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;
  }
}

Маппер — это граница между двумя мирами. На входе — domain-типы (RegisterPayment, PaymentId, Money). На выходе — DTO Сбера, и наоборот. Никакой SberRegisterResponse не должен выходить за пределы папки sber/ — в port-методе всегда возвращается domain-результат.

Persistence-адаптер

База данных — тот же out-адаптер, только система называется «PostgreSQL через TypeORM». Структура та же: папка persistence/, реализация port-интерфейса, маппер.

// 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 знает TypeORM и OrderEntity. В core/ TypeORM не импортируется — там не должно быть ни @InjectRepository, ни Repository<T>, ни entity-классов.

Адаптеры не знают друг о друге

Каждый адаптер знает только свою систему:

  • persistence/ — TypeORM. Ничего не знает об axios или kafkajs.
  • sber/ — axios и Sber API. Ничего не знает о TypeORM.
  • kafka/ — kafkajs. Ничего не знает о Sber или базе данных.

Это не случайно: если SberPaymentAdapter начинает обращаться к TypeOrmOrderRepository, оба становятся неотделимы друг от друга. Всё, что требует работы с несколькими адаптерами одновременно, — это бизнес-логика, и ей место в handler'е внутри core/.

Пример: fallback на другой платёжный шлюз делается в handler'е, не в адаптере:

// 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); // решение «попробовать другой шлюз» — здесь
    }
  }
}

Биндинг двух токенов в модуле:

{
  provide: RegisterPaymentHandler,
  useFactory: (sber: PaymentPort, ok: PaymentPort) =>
    new RegisterPaymentHandler(sber, ok),
  inject: [SBER_PAYMENT_PORT, OK_PAYMENT_PORT],
}

RegisterPaymentHandler — обычный класс без @Injectable(), без импорта @nestjs/common. Это намеренно: core/ не зависит от NestJS.

Частые ошибки

Вернуть DTO внешней системы из port-метода. Если register() возвращает SberRegisterResponse, то core/ начинает зависеть от деталей Sber. При смене провайдера придётся менять core. Правильно: маппер переводит SberRegisterResponse в RegisterResult до возврата.

Разместить бизнес-логику в адаптере. Выглядит безобидно — «маленький if после ответа API». Но если завтра появится второй платёжный шлюз (ok/ рядом с sber/), этот if нужно дублировать или срочно выносить. Логика в core с самого начала решает проблему.

Один адаптер на несколько систем. ProductAdapter implements PaymentPort, SmsPort, StoragePort — три несвязанные системы в одном классе. Тест для платежей затрагивает SMS-код, смена хранилища ломает тест для платежей. Отдельная папка и класс на каждую систему.

Адаптер вызывает другой адаптер. SberPaymentAdapter инжектит TypeOrmOrderRepository — это нарушение изоляции. Handler в core/ инжектит оба порта и координирует их.

Коротко

  • Out-адаптер — «выход» сервиса во внешний мир. Он реализует port-интерфейс из core/, маппит вызов в формат системы и несёт ответ обратно.
  • На каждую внешнюю систему — своя папка adapters/out/<system>/: независимые зависимости, настройки, тесты.
  • В Node/TypeScript интерфейс стирается в runtime — Symbol-токен обязателен, иначе NestJS не разрешит зависимость.
  • Маппер — отдельный файл в папке адаптера. Он знает специфику системы (копейки, числовые коды). В core/ эти детали не проникают.
  • Из port-метода возвращается только domain-результат — никогда DTO внешней системы.
  • Адаптеры не знают друг о друге. Всё, что требует координации двух адаптеров, — это бизнес-логика и место ей в handler'е.
  • Бизнес-логика в адаптере — частая ошибка. Даже «маленький if» со временем потребует переноса в core/.

Что почитать дальше

  • Adapters in — входная сторона: контроллеры, validation, маппинг запросов.
  • Ports — что реализует out-адаптер: Symbol-токены, domain-типы в сигнатурах, port-ошибки.
  • Bootstrap / composition root — как биндятся все порты на адаптеры через useClass / useFactory.
  • Core layer — что живёт в core/: агрегаты, порты, use cases без NestJS-декораторов.