В гексагональной архитектуре весь бизнес-код живёт в 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-декораторов.