Опирается на правила: R-HEX-AOUT-1R-HEX-AOUT-4 и R-HEX-AOUT-X1R-HEX-AOUT-X4 из Hexagonal Style Guide → раздел 6. Adapters out.

Важно знать

  • На каждую внешнюю систему — своя папка adapters/out/<system>/: persistence/, sber/, sms/, kafka/, s3/.
  • Адаптер реализует port-интерфейс из core/ и биндится на его Symbol-токен через useClass / useFactory в app/.
  • В Node интерфейсы TypeScript стираются в runtime — токен (Symbol) обязателен; без него Nest не разрешит зависимость.
  • Маппер domain ↔ DTO внешней системы — отдельный файл в папке адаптера; в core он не проникает.
  • Out-adapter знает только свою инфраструктуру: persistence/ — TypeORM, sber/ — axios/Sber-DTO, kafka/ — kafkajs. Между собой адаптеры не знакомы.
  • Один адаптер реализует порты одного домена и одной системы. Координация двух адаптеров — use case в core/.
  • Бизнес-логика в out-adapter запрещена: адаптер мапит и вызывает, решает handler в core/.
  • DTO внешней системы не возвращается из port-метода — только domain-результат.

Out-adapter — это «выход» сервиса во внешний мир. Он принимает domain-вызов (paymentPort.register(cmd)), маппит его в формат внешней системы (Sber REST-запрос), вызывает её, получает ответ, маппит обратно в domain (RegisterResult). На этом ответственность заканчивается. Никакой интерпретации бизнес-результата, никаких side-effects в сторону других адаптеров.

Per-system папки

R-HEX-AOUT-1: на каждую внешнюю систему — отдельная папка adapters/out/<system>/.

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

Почему именно так:

  • Изоляция зависимостей. sber/ подключает axios и Sber-SDK; kafka/kafkajs. Смена SMS-провайдера — правка в одной папке, остальные не затронуты.
  • Per-system resilience. Тайм-ауты, retry, Circuit Breaker настраиваются отдельно для каждой системы. Это R-RES-ISO-1: общий axios-инстанс на все исходящие вызовы — единая точка отказа.
  • Per-system observability. Метрики payment_sber_* и sms_* — разные; не смешивать в одном адаптере.
  • Изолированные тесты. Nock/WireMock для Sber поднимается только в тестах sber/; не тащит за собой TypeORM-схему или Kafka.

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

R-HEX-AOUT-2: класс адаптера реализует интерфейс из core/<bc>/port/out/ и биндится на его Symbol-токен.

// 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>;
}
// adapters/out/sber/sber-payment.adapter.ts
import { Injectable } from '@nestjs/common';
import { PaymentPort } from '../../../core/payment/port/out/payment-port';
import { RegisterPayment } from '../../../core/payment/usecases/register-payment.command';
import { RegisterResult } from '../../../core/payment/port/out/register-result';
import { PaymentId } from '../../../core/payment/value-object/payment-id';
import { SberApiClient } from './sber-api.client';
import { SberPaymentMapper } from './sber-payment.mapper';
import { SberPaymentError } from './sber-payment.error';

@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 — биндинг по Symbol-токену
import { Module } from '@nestjs/common';
import { PAYMENT_PORT } from '../core/payment/port/out/payment-port';
import { SberPaymentAdapter } from '../adapters/out/sber/sber-payment.adapter';
import { SberApiClient } from '../adapters/out/sber/sber-api.client';
import { SberPaymentMapper } from '../adapters/out/sber/sber-payment.mapper';

@Module({
  providers: [
    SberApiClient,
    SberPaymentMapper,
    { provide: PAYMENT_PORT, useClass: SberPaymentAdapter },
  ],
  exports: [PAYMENT_PORT],
})
export class PaymentModule {}

@Injectable() на адаптере допустим — адаптер живёт в adapters/out/, не в core/. В core/ NestJS-декораторы запрещены (R-HEX-CORE-3). Symbol-токен обязателен: интерфейс PaymentPort стирается TypeScript-компилятором и недоступен в runtime.

Маппер в адаптере

R-HEX-AOUT-3: отдельный класс переводит между domain-типами (сигнатура port-метода) и DTO внешней системы.

// adapters/out/sber/sber-payment.mapper.ts
import { Injectable } from '@nestjs/common';
import { RegisterPayment } from '../../../core/payment/usecases/register-payment.command';
import { RegisterResult } from '../../../core/payment/port/out/register-result';
import { PaymentId } from '../../../core/payment/value-object/payment-id';
import { PaymentStatus } from '../../../core/payment/value-object/payment-status';
import { SberRegisterRequest } from './dto/sber-register-request';
import { SberRegisterResponse } from './dto/sber-register-response';

@Injectable()
export class SberPaymentMapper {
  toApi(cmd: RegisterPayment): SberRegisterRequest {
    return {
      orderNumber: cmd.orderId.value,
      amount: Math.round(cmd.amount.amountDecimal * 100), // Sber — в копейках
      currency: 978,                                       // 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;
  }
}

Маппер знает специфику Sber: копейки, числовые коды валют, числовые статусы. Всё это — деталь sber/, не утекает в core/. Domain-типы (PaymentStatus, PaymentId, Money) — объекты из core/, которые маппер использует как целевой формат.

Persistence-адаптер

adapters/out/persistence/ — частный случай out-adapter'а. Реализует OrderRepository, ProductRepository через TypeORM.

// adapters/out/persistence/order/order.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderRepository } from '../../../core/order/port/out/order-repository';
import { Order } from '../../../core/order/aggregate/order';
import { OrderId } from '../../../core/order/value-object/order-id';
import { OrderNotFoundError } from '../../../core/order/error/order-not-found.error';
import { OrderEntity } from './entity/order.entity';
import { OrderMapper } from './mapper/order.mapper';

@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> {
    const entity = this.mapper.toEntity(order);
    await this.repo.save(entity);
  }
}
// adapters/out/persistence/mapper/order.mapper.ts
import { Injectable } from '@nestjs/common';
import { Order } from '../../../../core/order/aggregate/order';
import { OrderId } from '../../../../core/order/value-object/order-id';
import { CustomerId } from '../../../../core/customer/value-object/customer-id';
import { OrderEntity } from '../entity/order.entity';

@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 (@InjectRepository, Repository<OrderEntity>). В core/ TypeORM не импортируется — это enforce'ится правилом core-pure в .dependency-cruiser.cjs.

Что adapter знает и не знает

R-HEX-AOUT-4:

  • persistence/ знает TypeORM, @nestjs/typeorm. Не знает axios, kafkajs, Sber-DTO.
  • sber/ знает axios, Sber-DTO, SberApiClient. Не знает TypeORM, Kafka.
  • kafka/ знает kafkajs, @nestjs/microservices. Не знает TypeORM, Sber.
  • sms/ знает HTTP-клиент SMS-провайдера. Не знает остальных.

Dependency-cruiser enforce'ит это правилом adapters-independent: adapters/in/* и adapters/out/* не импортируют друг друга — все зависят от core/.

Что запрещено

АнтипаттернПравилоЧто взамен
Адаптер возвращает SberRegisterResponse из port-методаR-HEX-AOUT-X1Маппер в адаптере переводит в RegisterResult до возврата
if (response.status === 4) { sendSms(...) } в адаптереR-HEX-AOUT-X2Логика — в handler'е в core/; адаптер только мапит
ProductAdapter implements PaymentPort, SmsPort, StoragePortR-HEX-AOUT-X3Отдельная папка и класс на каждую систему и домен
SberPaymentAdapter инжектит TypeOrmOrderRepositoryR-HEX-AOUT-X4Handler в core/ инжектит оба порта и координирует
SberRegisterRequest в сигнатуре port-методаR-HEX-PORT-X2Port оперирует только domain-типами (RegisterPayment)
TypeORM-Entity (OrderEntity) как тип в core/R-HEX-CORE-X4Маппер в persistence-адаптере переводит Entity ↔ domain-агрегат

Разбор самого частого: бизнес-логика в адаптере появляется как «маленькое if после ответа». Выглядит безобидно, пока систем одна. Когда появляется второй payment-провайдер (ok/ рядом с sber/), логику нужно дублировать или выносить — и выносить приходится в core/, где ей место с самого начала.

Координация двух адаптеров — handler в core/:

// core/payment/usecases/register-payment.handler.ts
export class RegisterPaymentHandler {
  constructor(
    @Inject(SBER_PAYMENT_PORT) private readonly sber: PaymentPort,
    @Inject(OK_PAYMENT_PORT)   private readonly ok:   PaymentPort,
  ) {}

  async handle(cmd: RegisterPayment): Promise<RegisterResult> {
    try {
      return await this.sber.register(cmd);
    } catch {
      return this.ok.register(cmd);   // fallback-логика живёт в core, не в адаптере
    }
  }
}

Куда дальше

  • Adapters in — симметричная сторона для входов: контроллеры, mappers, class-validator.
  • Ports — что реализует out-adapter: Symbol-токены, domain-типы в сигнатурах, port-ошибки.
  • Bootstrap / composition root — app/: как биндятся все порты на адаптеры через useClass / useFactory.
  • Module structure — полная раскладка папок и контракт dependency-cruiser.
  • Архитектурные тесты — depcruise --validate в CI: как enforce'ится чистота core/ и независимость адаптеров.
  • Core layer — что живёт в core/: агрегаты, порты, use cases без NestJS-декораторов.
  • Когда переходить на Hexagonal — признаки, что пора, и признаки, что рано.