Опирается на правила:
R-HEX-AOUT-1…R-HEX-AOUT-4иR-HEX-AOUT-X1…R-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, StoragePort | R-HEX-AOUT-X3 | Отдельная папка и класс на каждую систему и домен |
SberPaymentAdapter инжектит TypeOrmOrderRepository | R-HEX-AOUT-X4 | Handler в core/ инжектит оба порта и координирует |
SberRegisterRequest в сигнатуре port-метода | R-HEX-PORT-X2 | Port оперирует только 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 — признаки, что пора, и признаки, что рано.