Когда сервис хочет сохранить данные в базу, отправить SMS или вызвать стороннее API — ему нужна «рука», которая дотянется наружу и сделает это. В Hexagonal Architecture такие «руки» называют out-адаптерами. Разберём, что это такое и как их правильно устроить.
Проблема: код знает слишком много
Представьте, что бизнес-логика оплаты вызывает Sber API напрямую:
class PaymentService {
private final SberOrderServicesApi sberApi; // Sber-клиент прямо в бизнес-коде
public void register(Order order) {
var req = new SberRegisterRequest();
req.setAmount(order.amount() * 100); // в копейках — Sber-специфика
sberApi.register(req, null);
}
}
Что тут плохо:
- Чтобы поменять платёжную систему — надо переписывать бизнес-логику.
- Чтобы написать тест — надо поднимать мок Sber API внутри теста на бизнес-правила.
- Детали Sber (копейки, числовые коды валют) расползаются по всему коду.
Hexagonal Architecture отвечает на это: пусть бизнес-логика знает только абстракцию (PaymentPort), а конкретика — кто такой Sber и как с ним разговаривать — живёт в отдельном модуле. Этот модуль и есть out-адаптер.
Что такое out-адаптер
Out-адаптер — это реализация port-интерфейса, который ядро (core) объявило как «мне нужна внешняя зависимость».
Схема:
core/ → объявляет PaymentPort (интерфейс)
sber-out-adapter/ → реализует PaymentPort (знает про Sber API)
Ядро работает только с PaymentPort. Ему всё равно, кто за ним стоит — Sber, OdnaKassa или мок в тесте. Это и есть суть изоляции.
Один модуль — одна внешняя система
Каждой внешней системе соответствует отдельный gradle-модуль:
persistence/ # база данных (PostgreSQL через jOOQ)
sber-out-adapter/ # платёжная система Sber
sms-out-adapter/ # SMS-провайдер
kafka-out-adapter/ # публикация событий в Kafka
s3-out-adapter/ # хранилище файлов
scheduler-out-adapter/ # планировщик задач
Почему не один общий модуль «для всего внешнего»:
Изоляция зависимостей. sber-out-adapter тянет Sber SDK. sms-out-adapter — SDK вашего SMS-провайдера. Если завтра меняете SMS-провайдера, правите один модуль — остальные не пересобираются.
Изолированная настройка отказоустойчивости. Circuit Breaker, таймаут, политика повторов настраиваются отдельно для каждой системы. Общий HTTP-клиент на всё исходящее означает, что сбой Sber может замедлить отправку SMS.
Изолированные метрики. Метрики payment_sber_* и sms_smsc_* — разные. Смешивать их в одном модуле неудобно и вводит в заблуждение.
Изолированные тесты. WireMock для Sber поднимается в тестах sber-out-adapter, WireMock для SMS — в тестах sms-out-adapter. Не один огромный мок для всего.
Как выглядит адаптер
// sber-out-adapter/.../SberClientAdapter.java
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort { // implements — интерфейс из core/
private final SberOrderServicesApi sberApi; // Sber-клиент
private final SberMapper 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 — достаточно @Component и implements PaymentPort. В handler'е в core инжектится PaymentPort, Spring подкладывает SberClientAdapter.
Задача адаптера чётко ограничена: принять domain-вызов → смаппить в формат системы → вызвать систему → смаппить ответ обратно → вернуть domain-результат. Всё.
Маппер — переводчик между мирами
Детали внешней системы (Sber считает деньги в копейках, использует числовые коды валют, возвращает числовые статусы) — это деталь адаптера. Они не должны просачиваться в ядро.
Для перевода между domain-объектами и Sber-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()); // рубли → копейки
req.setCurrency(978); // 978 = RUB в формате Sber
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);
};
}
}
Маппер знает всё о Sber-специфике. Ядро — ничего. Вот в чём смысл.
Для простых конверсий подходит MapStruct. Если есть нетривиальная логика (конверсия единиц, enum-маппинг со switch-выражением, вычисления) — лучше обычный Java-класс: он понятнее и проще дебажить.
Что адаптер знает, а что нет
Каждый адаптер знает только свою технологию и не знает про остальные:
| Адаптер | Знает | Не знает |
|---|---|---|
persistence/ | jOOQ, HikariCP, PostgreSQL | Sber API, Kafka |
sber-out-adapter/ | Sber SDK, RestClient, Resilience4j | PostgreSQL, Kafka |
kafka-out-adapter/ | KafkaTemplate, сериализаторы | Sber, PostgreSQL |
Это явно закрепляется в build.gradle.kts каждого модуля — зависимости прописаны явно, и Gradle не даст sber-out-adapter случайно дотянуться до jOOQ.
Частые ошибки
Ошибка 1: внешнее DTO в port-методе
// Плохо — Sber DTO утекает в интерфейс ядра
public interface PaymentPort {
SberRegisterResponse register(RegisterCommand cmd); // ← Sber-специфика в контракте
}
Ядро не должно знать, что такое SberRegisterResponse. Port возвращает domain-объекты — RegisterResult, а не Sber DTO.
Ошибка 2: бизнес-логика в адаптере
// Плохо — адаптер решает бизнес-вопросы
@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()); // ← побочный эффект — не адаптерное дело
}
return mapper.toDomain(response);
}
Лимит в 100 000 — это бизнес-правило, оно живёт в ядре (handler или aggregate). Если завтра появится OdnaKassa, тот же лимит придётся копировать. А sendNotification — это вызов другого port'а, что делает только handler в core. Адаптер не решает, что делать после ответа системы — он маппит и возвращает.
Ошибка 3: один адаптер на несколько систем
// Плохо — три несвязанные системы в одном классе
public class UniversalIntegrationAdapter
implements PaymentPort, SmsPort, StoragePort { ... }
Circuit Breaker нельзя настроить по-разному для трёх систем в одном классе. Сбой Sber кладёт и SMS, и хранилище. Тесты превращаются в тесты «бог-класса». Изоляция исчезает.
Ошибка 4: адаптер инжектирует другой адаптер
// Плохо — адаптеры зависят друг от друга
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final OdnaKassaAdapter odnaKassaAdapter; // ← нарушение изоляции
}
Все адаптеры зависят только от core/, а не друг от друга. Если бизнес-сценарий требует «попробовать Sber, при отказе — OdnaKassa» — это use case в ядре:
// core/.../FallbackPaymentHandler.java
@UseCaseHandler
@RequiredArgsConstructor
public class FallbackPaymentHandler {
private final SberPaymentPort sberPort;
private final OdnaKassaPaymentPort okPort;
public Payment handle(RegisterPaymentCommand cmd) {
try {
return sberPort.register(cmd);
} catch (PaymentPortException e) {
return okPort.register(cmd); // логика выбора — в ядре, не в адаптере
}
}
}
Коротко
- Out-адаптер — реализация port-интерфейса из ядра; переводит domain-вызов в конкретную технологию (HTTP, SQL, Kafka) и обратно.
- Один модуль — одна внешняя система. Это даёт изолированные зависимости, настройку отказоустойчивости, метрики и тесты.
- Mapper в адаптере знает все детали внешней системы (форматы, коды, единицы); ядро ничего этого не видит.
- Адаптер только маппит и вызывает. Бизнес-правила — в ядре; координация нескольких систем — тоже в ядре.
- Port-интерфейс возвращает domain-объекты, никогда DTO внешней системы.
- Адаптеры не зависят друг от друга. Если нужна оркестрация — это use case в core.
Что почитать дальше
- Ports в Hexagonal Architecture — что implements out-адаптер и как объявлять port-интерфейсы.
- In-адаптеры — симметричная сторона: как входящие запросы попадают в ядро.
- Repository pattern в jOOQ — конкретный out-адаптер (persistence) с jOOQ.