Опирается на правила:
R-HEX-AOUT-1…R-HEX-AOUT-4иR-HEX-AOUT-X1…R-HEX-AOUT-X4из Hexagonal Style Guide → раздел 6. Adapters out.
Важно знать
- На каждую внешнюю систему — свой
*-out-adapterмодуль:persistence/(PG),sber-out-adapter/,sms-out-adapter/,kafka-out-adapter/,s3-out-adapter/.- Adapter implements port-интерфейс из
core/. Port — контракт, adapter — реализация.- Mapper в adapter'е переводит между domain (port-сигнатура) и generated DTO внешней системы.
- Out-adapter знает свою инфраструктуру (jOOQ / RestClient / Kafka), НЕ знает другие адаптеры.
- Один adapter implements один port конкретного домена. Per-system isolation (
R-RES-ISO-1).- Generated DTO внешней системы в port-методе — запрещено. Только domain-результат.
- Бизнес-логика в out-adapter — запрещена. Адаптер мапит и вызывает, не решает.
- Out-adapter, инжектящий другой out-adapter — антипаттерн. Координация — это use case в core/.
Out-adapter — это «выход» из сервиса во внешний мир. Он принимает domain-вызов (paymentPort.register(cmd)), маппит в формат внешней системы (Sber API request), вызывает её через HTTP/SQL/Kafka, получает ответ, маппит обратно в domain (RegisterResult). На этом его ответственность заканчивается — никакой бизнес-логики, никакого «if статус Sber'а такой, то делаем то». Раскрытие правил R-HEX-AOUT-* ниже.
Per-system модули
R-HEX-AOUT-1: на каждую внешнюю систему — отдельный gradle-модуль.
persistence/ # implements <X>Repository через jOOQ (PG)
sber-out-adapter/ # implements PaymentPort через Sber REST API
ok-out-adapter/ # implements PaymentPort через OdnaKassa (если есть)
sms-out-adapter/ # implements SmsPort
kafka-out-adapter/ # implements EventPublisher (direct send, без outbox)
s3-out-adapter/ # implements StoragePort
scheduler-out-adapter/ # @Scheduled tasks, дёргающие port-методы
redis-out-adapter/ # implements CachePort (если используется)
Почему именно так:
- Изоляция dependency.
sber-out-adapterзависит от Sber-SDK;sms-out-adapter— от SMSC-SDK. Если завтра меняем SMS-провайдера, правка в одном модуле, остальные не пересобираются. - Per-system resilience. OkHttpClient, Circuit Breaker, Bulkhead, Retry — настраиваются отдельно для каждой системы. Это
R-RES-ISO-1в Resilience Style Guide: общий HTTP-пул на всё исходящее = единый point of failure. - Per-system observability. Метрики
payment_sber_*иsms_smsc_*— разные. Не смешивать. - Per-system testability. WireMock для Sber поднимается в тестах sber-out-adapter; WireMock для SMSC — в тестах sms-out-adapter. Не один большой WireMock-фейк для всего сразу.
Adapter implements port
R-HEX-AOUT-2: класс адаптера реализует port-интерфейс из core/.
// sber-out-adapter/.../SberClientAdapter.java
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort { // ← interface из core/
private final SberOrderServicesApi sberApi; // ← generated client
private final SberMapper mapper; // ← mapper из этого же модуля
@Override
public RegisterResult register(RegisterCommand cmd) {
var apiRequest = mapper.toApi(cmd);
var response = executeCall(sberApi.register(apiRequest, null));
return mapper.toDomain(response);
}
@Override
public void cancel(PaymentId paymentId) {
executeCall(sberApi.cancel(paymentId.value(), null));
}
private <T> T executeCall(Supplier<T> call) {
try {
return call.get();
} catch (FeignException e) {
throw new SberException("Sber call failed", e); // ← подкласс PaymentPortException
}
}
}
Spring подберёт SberClientAdapter как реализацию PaymentPort (потому что implements + @Component). В handler'е в core инжектится PaymentPort — Spring подкладывает SberClientAdapter.
Если бы в проекте было два port-implementor'а (SberClientAdapter и OdnaKassaAdapter оба implements PaymentPort), требовался бы @Qualifier или дополнительная логика выбора. На практике в одном сервисе обычно один implementor — выбор делается на уровне profile / runtime config.
Mapper в adapter'е
R-HEX-AOUT-3: отдельный класс, переводящий между domain и generated DTO.
// sber-out-adapter/.../SberMapper.java
@Component
public class SberMapper {
public SberRegisterRequest toApi(RegisterCommand cmd) {
var req = new SberRegisterRequest();
req.setOrderNumber(cmd.orderId().value().toString());
req.setAmount(cmd.amount().amount().multiply(BigDecimal.valueOf(100)).intValue()); // ← Sber в копейках
req.setCurrency(978); // ← Sber: 978 = RUB
req.setDescription(cmd.description());
return req;
}
public RegisterResult toDomain(SberRegisterResponse response) {
return new RegisterResult(
new PaymentId(response.getOrderId()),
URI.create(response.getFormUrl()),
mapStatus(response.getStatus())
);
}
private PaymentStatus mapStatus(Integer sberStatus) {
return switch (sberStatus) {
case 0 -> PaymentStatus.REGISTERED;
case 1 -> PaymentStatus.AUTHORIZED;
case 2 -> PaymentStatus.DEPOSITED;
case 3 -> PaymentStatus.CANCELLED;
default -> throw new SberException("Unknown Sber status: " + sberStatus, null);
};
}
}
Что важно:
- Mapper знает Sber-API specifics. Копейки vs рубли, числовые коды валют, специфичные статусы. Это всё деталь Sber-out-adapter, не утекает в core.
- Mapper plain Java или MapStruct — те же соображения, что в Mapping record ↔ domain в jOOQ. Если есть assemble-логика, enum-конверсия, побочные вычисления — plain Java. Если простой копир один-в-один — MapStruct.
- Двусторонний.
toApi(cmd)для запроса иtoDomain(response)для ответа.
Что adapter знает и не знает
R-HEX-AOUT-4:
persistence/знает jOOQ, HikariCP, JdbcTemplate (если бы был — но мы его не используем,R-JOOQ-REPO-X3). Не знает Sber, Kafka.sber-out-adapter/знает Spring RestClient/OkHttp, Sber-DTO, Resilience4j. Не знает PG, Kafka.kafka-out-adapter/знает KafkaTemplate, Kafka serializers. Не знает Sber, PG.
Это явно прописано в build.gradle.kts каждого модуля — зависимости явные, gradle проверит.
Что запрещено
R-HEX-AOUT-X1: out-adapter возвращает generated DTO через port-метод.
// ПЛОХО
public interface PaymentPort {
SberRegisterResponse register(RegisterCommand cmd); // ← Sber DTO в сигнатуре
}
Что не так — см. Ports → R-HEX-PORT-X2. Core не должен знать Sber.
R-HEX-AOUT-X2: бизнес-логика в out-adapter.
// ПЛОХО
@Override
public RegisterResult register(RegisterCommand cmd) {
if (cmd.amount().compareTo(Money.of(100_000)) > 0) { // ← бизнес-правило
throw new PaymentTooLargeException(cmd.amount());
}
var response = sberApi.register(mapper.toApi(cmd), null);
if (response.getStatus() == 4) { // ← интерпретация бизнес-результата
sendNotification(cmd.orderId()); // ← side-effect
}
return mapper.toDomain(response);
}
Что не так:
- Логика разъезжается. «100K — лимит» — это бизнес-правило, оно должно быть в
Order.create(...)или handler'е, не в Sber-adapter. Если завтра мы хотим тот же лимит при платеже через OdnaKassa, придётся копировать. - Side-effects в adapter'е.
sendNotification— это другой port, его дёргает handler. Adapter дёргать другой adapter — нарушение симметрии. - Adapter не тестируется как «штука для мокания Sber». Тесты должны проверять только маппинг и сетевые сбои, не «что произойдёт после ответа Sber'а».
Adapter мапит, вызывает, маппит обратно. Бросает port-exception на сетевую/системную ошибку. Решает что делать дальше — handler в core.
R-HEX-AOUT-X3: один out-adapter implements несколько ports разных доменов.
// ПЛОХО
public class UniversalIntegrationAdapter
implements PaymentPort, SmsPort, StoragePort { // ← три разных контракта
// ...
}
Что не так:
- Resilience не разделить. Circuit Breaker, Bulkhead настраиваются на класс/метод; если три domain'а в одном — Sber-сбой положит SMS и storage заодно.
- Per-system observability разрушена. Метрики смешиваются.
- Тестируется как «бог-класс». При любом изменении ломаются тесты всех трёх доменов.
Per-system isolation: один adapter = один (или несколько связанных) port'ов одного домена и одной системы.
R-HEX-AOUT-X4: out-adapter знает другой out-adapter.
// ПЛОХО
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final OdnaKassaAdapter odnaKassaAdapter; // ← один adapter инжектит другой
}
Что не так — нарушение симметрии Hexagonal. Все адаптеры зависят от core/, не друг от друга.
Если бизнес-сценарий требует «попробовать Sber, при отказе — OdnaKassa», это use case в core:
// core/.../FallbackPaymentHandler.java
@UseCaseHandler
@RequiredArgsConstructor
public class FallbackPaymentHandler {
private final SberPaymentPort sberPort; // если разделили на два port'а
private final OdnaKassaPaymentPort okPort;
public Payment handle(RegisterPaymentCommand cmd) {
try {
return sberPort.register(cmd);
} catch (PaymentPortException e) {
return okPort.register(cmd); // ← логика выбора в core, не в adapter
}
}
}
Куда дальше
- Hexagonal Style Guide → раздел 6. Adapters out — нормативные формулировки.
- Ports — что implements out-adapter.
- Adapters in — симметричная сторона для входов.
- Repository pattern в jOOQ — конкретный out-adapter (persistence) с jOOQ.
- Resilience Style Guide → R-RES-ISO-1 — per-system isolation, почему один HTTP-пул на всё — антипаттерн.