Когда сервис хочет сохранить данные в базу, отправить 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().amount()
.multiply(BigDecimal.valueOf(100)).intValue()); // в копейках — Sber-специфика
req.setCurrency(643); // числовой код валюты — тоже Sber-специфика
sberApi.register(req, null);
}
}
Что тут плохо:
- Чтобы поменять платёжную систему — надо переписывать бизнес-логику.
- Чтобы написать тест — надо поднимать мок Sber API внутри теста на бизнес-правила.
- Детали Sber (копейки, числовые коды валют) расползаются по всему коду.
Hexagonal Architecture отвечает на это: пусть бизнес-логика знает только абстракцию (PaymentPort), а конкретика — кто такой Sber и как с ним разговаривать — живёт в отдельном модуле. Этот модуль и есть out-адаптер.
Что такое out-адаптер
Кто-то должен знать протокол Сбера, формат его ошибок и адрес тестового стенда — но не ядро. Этот «кто-то» и есть out-адаптер: реализация port-интерфейса, который ядро (core) объявило как «мне нужна внешняя зависимость».
Схема:
Контракт принадлежит ядру, а знание про Sber — отдельному модулю. Поменялась платёжная система — переписывается один адаптер, бизнес-правила остаются нетронутыми, а в тесте на его место встаёт заглушка.
Ядро работает только с 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/ | планировщик задач |
Одна оговорка к этой таблице, важная для понимания: persistence/ устроен иначе, чем остальные адаптеры, хотя формально стоит в том же списке. Разница не в названии, а в трёх вещах.
| Хранилище | Адаптер к внешней системе | |
|---|---|---|
| Транзакция | участвует в транзакции сценария | своей транзакции нет вовсе |
| Что возвращает | агрегат целиком, который потом меняют | результат операции (значение) |
| Отказ | означает «наша база недоступна», это авария | означает «сосед недоступен», это ожидаемо |
| Повтор | обычно не нужен (или на уровне соединения) | нужен, и с ним нужна идемпотентность |
| Блокировки и версии | его забота (режим выборки, версия агрегата) | понятия не имеет |
| Преобразование | запись базы ↔ агрегат, в обе стороны | наш запрос → чужой формат → наш результат |
| Срок ожидания | десятки миллисекунд | секунды, и он обязателен |
Отсюда практический вывод: не переносите приёмы хранилища на внешние системы и наоборот. Репозиторий не оборачивают в защиту от каскадных отказов (падение своей базы не лечится размыканием: работать всё равно нечем), а платёжный клиент не участвует в транзакции и не отдаёт «агрегат на изменение». Общее у них ровно одно — интерфейс объявлен в ядре, а реализация снаружи.
Почему не один общий модуль «для всего внешнего»:
Изоляция зависимостей. 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
@CircuitBreaker(name = "sber") // отказоустойчивость — на методе адаптера
@Retry(name = "sber")
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
}
}
private void executeCall(Runnable call) { // для вызовов без результата
executeCall(() -> {
call.run();
return null;
});
}
}
Про @CircuitBreaker и @Retry из Resilience4j стоит сказать отдельно — важно, на каком уровне они висят. Не на порте: порт живёт в ядре, а ядро про Resilience4j ничего не знает. И не на handler'е: handler может дёрнуть три разные системы, а размыкать надо ту одну, что отвалилась. Аннотации ставят на методе адаптера, и имя экземпляра (name = "sber") связывает их с настройками в application.yml — свой порог отказов, свой таймаут, своя пауза между повторами у каждой внешней системы.
Перегрузок две не для красоты. cancel у Sber ничего не возвращает, а лямбда без результата под Supplier<T> не подходит — компилятор такой вызов не пропустит. Поэтому рядом стоит вариант на Runnable, и оба заворачивают ошибку одинаково.
Spring автоматически подберёт SberClientAdapter как реализацию PaymentPort — достаточно @Component и implements PaymentPort. В handler'е в core инжектится PaymentPort, Spring подкладывает SberClientAdapter.
Работает это ровно до второй реализации. Как только рядом появится OdnaKassaClientAdapter implements PaymentPort, сервис не поднимется: Spring увидит два бина одного типа и упадёт с NoUniqueBeanDefinitionException. Лечится это именами — каждому адаптеру дают своё, а в месте внедрения пишут, какой нужен:
@Component("sberPaymentPort")
public class SberClientAdapter implements PaymentPort { ... }
@Component("odnaKassaPaymentPort")
public class OdnaKassaClientAdapter implements PaymentPort { ... }
Где нужна одна конкретная реализация — её просят по имени через @Qualifier. Где нужна «основная» — одну из реализаций помечают @Primary, и она достаётся всем, кто имя не назвал. К этому мы вернёмся ниже, когда будем разбирать запасной платёжный шлюз.
Задача адаптера чётко ограничена: принять 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(toKopecks(cmd.amount().amount()));
req.setCurrency(643); // 643 — код рубля по ISO 4217
req.setDescription(cmd.description());
return req;
}
private int toKopecks(BigDecimal rubles) {
return rubles
.multiply(BigDecimal.valueOf(100))
.setScale(0, RoundingMode.HALF_UP) // дробные копейки округляем явно
.intValueExact(); // не влезло в int — упадём здесь, а не в банке
}
public RegisterResult toDomain(SberRegisterResponse response) {
return new RegisterResult(
new PaymentId(response.getOrderId()),
URI.create(response.getFormUrl()),
mapStatus(response.getStatus())
);
}
private PaymentStatus mapStatus(Integer sberStatus) {
if (sberStatus == null) {
throw new SberException("Sber returned no status", null);
}
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-класс: он понятнее и проще дебажить.
Таймауты, повторы и идемпотентность исходящего вызова
Отказоустойчивость названа причиной разделять модули, и ни одной настройки не показано. Это половина работы адаптера к внешней системе, и здесь же живёт самая дорогая ошибка — двойной платёж.
Срок ожидания — обязателен и он не один. Клиент без срока ожидания однажды повиснет навсегда, заняв поток и держа пользователя. Сроков два, и их путают:
# sber-out-adapter/src/main/resources/application-sber.yml
sber:
base-url: https://api.example.com
connect-timeout: 2s # сколько ждём установления соединения
read-timeout: 10s # сколько ждём ответа после отправки запроса
Ориентиры: срок соединения — секунды (сеть либо есть, либо нет), срок чтения — от требований к своему ответу. Правило, которое часто забывают: сумма сроков ожидания на пути запроса не должна превышать ваш собственный срок ответа. Если вы обещаете ответить за 3 секунды, а платёжный клиент ждёт 10, клиент уже ушёл, а вы всё ещё держите ресурсы.
Повтор — опасная настройка, и вот почему. Таймаут означает «ответ не пришёл», а не «операция не выполнилась». Провайдер мог принять платёж и не успеть ответить. Повтор без защиты создаёт второй платёж — и это не редкость, а обычное следствие сетевого сбоя на длинном запросе.
Отсюда правило, которое стоит считать жёстким: повторять можно только идемпотентные операции. Практически:
| Операция | Повтор без защиты | Что нужно |
|---|---|---|
| Чтение («статус платежа») | безопасно | ничего |
| Изменяющая операция с ключом идемпотентности | безопасно | передавать ключ |
| Изменяющая операция без ключа | опасно | не повторять; выяснять состояние |
Как выглядит ключ идемпотентности. Значение, одинаковое для всех попыток одной операции, которое провайдер запоминает и на повтор возвращает прежний результат:
@Override
public RegisterResult register(PaymentRequest request) {
var externalRequest = mapper.toExternal(request);
externalRequest.setIdempotencyKey(request.idempotencyKey()); // выводится из заказа, не случайный
try {
return mapper.toDomain(api.register(externalRequest));
} catch (HttpServerErrorException | ResourceAccessException e) { // временное
throw new PaymentGatewayUnavailable(e); // доменное исключение: решает ядро
} catch (HttpClientErrorException e) { // окончательный отказ
throw mapper.toDomainError(e); // тоже доменное, но другое
}
}
Ключ выводится из смысла операции («оплата заказа 4521»), а не генерируется при каждом вызове: иначе у каждой попытки он свой, и идемпотентности нет, хотя код выглядит правильным.
Если провайдер ключей не поддерживает — три пути, и все хуже, поэтому их надо знать заранее: запрос состояния перед повтором («есть ли платёж по такому заказу?»), сверка постфактум (регулярное сравнение своих записей с их выпиской — для денег обязательна независимо от всего остального) и не повторять автоматически: таймаут переводит операцию в состояние «неизвестно» и её разбирает человек или отдельный процесс. Последнее звучит слабо и для необратимых операций честнее автоматического повтора.
Пауза с разбросом. Повторы идут не сразу и не в одну секунду: растущая пауза плюс случайная добавка, иначе при восстановлении провайдера все накопившиеся запросы ударят одновременно. Ориентир — две-три попытки, а не десять: за десять попыток вы либо дождётесь, либо уже давно перестали быть нужны вызывающему.
Размыкание при массовых отказах. Когда провайдер лежит, бессмысленно держать потоки в ожидании: механизм размыкания перестаёт звать его после порога ошибок и быстро отвечает отказом, периодически пробуя снова. Настраивается для каждой внешней системы отдельно — ровно то, о чём говорит раздел про изоляцию модулей. И важная деталь: размыкание должно отдавать доменное исключение вида «поставщик недоступен», чтобы ядро могло принять решение (отложить, показать отказ, выбрать другого поставщика), а не техническую ошибку библиотеки.
Что при этом обязательно измеряется: доля ошибок по каждой внешней системе, время ответа по перцентилям, число повторов и состояние размыкателя. Без этих метрик настройки сроков и повторов подбираются на ощупь.
Как адаптер тестируется
Отдельный модуль на внешнюю систему заводят в том числе ради тестов — и стоит показать, как они выглядят, потому что это половина ценности разделения.
Тест адаптера отвечает на один вопрос: правильно ли мы переводим туда и обратно. Не «работает ли провайдер» и не «верна ли бизнес-логика»: поднимается локальный сервер-заглушка, ему задаётся ответ, который реально присылает провайдер, и проверяется, что на выходе получился нужный доменный результат.
class SberClientAdapterTest {
@RegisterExtension
static WireMockExtension gateway = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort()).build();
private SberClientAdapter adapter;
@BeforeEach
void setUp() {
adapter = new SberClientAdapter(clientFor(gateway.baseUrl()), new SberMapper());
}
@Test
void successfulRegistration_isMappedToDomainResult() {
gateway.stubFor(post("/payment/rest/register.do")
.willReturn(okJson("""
{"orderId":"70906e55-7114","formUrl":"https://pay/70906e55","errorCode":"0"}
""")));
RegisterResult result = adapter.register(paymentRequest());
assertThat(result.externalId()).isEqualTo(new ExternalPaymentId("70906e55-7114"));
assertThat(result.paymentUrl()).isEqualTo(URI.create("https://pay/70906e55"));
}
@Test
void businessRejection_becomesDomainError() {
gateway.stubFor(post("/payment/rest/register.do")
.willReturn(okJson("""
{"errorCode":"1","errorMessage":"Заказ с таким номером уже обработан"}
""")));
assertThatThrownBy(() -> adapter.register(paymentRequest()))
.isInstanceOf(PaymentAlreadyRegistered.class); // доменное, не чужое
}
@Test
void networkFailure_becomesUnavailable() {
gateway.stubFor(post("/payment/rest/register.do")
.willReturn(aResponse().withFault(CONNECTION_RESET_BY_PEER)));
assertThatThrownBy(() -> adapter.register(paymentRequest()))
.isInstanceOf(PaymentGatewayUnavailable.class);
}
}
Что обязательно покрывают тестами адаптера, и это список, который стоит держать:
- Успешный ответ → правильный доменный результат.
- Ответ с бизнес-отказом (провайдер ответил
200, но внутри код ошибки — очень частый случай!) → правильное доменное исключение. - Технические отказы:
5xx, обрыв соединения, срок ожидания → исключение «недоступно», а не что-то другое. - Неожиданный формат ответа (поле пропало, тип изменился) → понятная ошибка, а не невнятное исключение разбора.
- Проверка исходящего запроса: что мы отправили именно то, что нужно, включая ключ идемпотентности. Это делается проверкой на заглушке («запрос с таким телом пришёл ровно один раз»).
- Число повторов при временном отказе — тем же подсчётом запросов на заглушке.
Для хранилища тест другой: заглушка не годится, нужна настоящая база в контейнере. Проверяют то же самое по смыслу — преобразование в обе стороны — плюс то, чего у HTTP нет: что сохранение и чтение дают тот же агрегат, что коллекция внутри агрегата сохраняется целиком (и исчезнувшие строки удаляются), что проверка версии работает, что запрос действительно использует индекс. Разбор — в статье про интеграционные тесты.
Чего в тестах адаптера быть не должно: бизнес-сценариев (они проверяются в ядре на поддельных реализациях портов) и обращений к настоящему провайдеру. Второе иногда делают отдельным набором тестов «по требованию» против тестового контура провайдера — полезно раз в спринт, но не в обычном прогоне: чужой контур отвечает медленно и падает не по вашей вине.
Что адаптер знает, а что нет
Каждый адаптер знает только свою технологию и не знает про остальные:
| Адаптер | Знает | Не знает |
|---|---|---|
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.
Откуда берётся клиент чужого API
В примере используется SberOrderServicesApi, и вопрос «откуда он взялся» решает, где живут структуры чужого формата и что именно преобразует преобразователь. Вариантов три.
1. Сгенерирован из описания API поставщика. Если поставщик даёт машинное описание (OpenAPI, protobuf), клиент и структуры генерируются при сборке. Это лучший вариант: описание поставщика меняется — сборка падает или генерирует новые классы, и расхождение видно сразу. Структуры при этом сгенерированные, и это важно: их нельзя править руками и нельзя тащить в ядро (они меняются вместе с чужим описанием).
Практическая деталь: генерацию кладут в тот же модуль адаптера (или в отдельный модуль-генератор, если клиент используется несколькими адаптерами), а результат генерации не коммитят — он собирается заново. Описание поставщика фиксируют по версии в репозитории: иначе сборка начинает зависеть от того, что поставщик опубликовал сегодня.
2. Написан руками. Когда описания нет или оно неверно (частый случай у старых интеграций): свой класс с тремя методами на нужные операции и свои записи для запроса и ответа. Больше работы, зато полный контроль: вы описываете только то, что используете, и неожиданное поле в ответе не ломает разбор.
3. Взят из библиотеки поставщика. Официальная библиотека избавляет от разбора протокола и часто уже содержит повторы и подписи запросов. Цена: она тянет свои зависимости (иногда конфликтующие с вашими — вот тут и спасает отдельный модуль), навязывает свою модель ошибок и живёт по своему расписанию обновлений.
От выбора зависит объём работы преобразователя. В первом и третьем случаях структуры чужие и подробные — преобразователь делает настоящий перевод: берёт нужные поля, сводит чужие коды к своим, теряет лишнее. Во втором случае вы сами описываете структуры и можете сразу сделать их узкими — тогда преобразователь короче, но часть работы уже сделана при написании клиента.
Что не меняется ни в одном варианте: структуры чужого формата не покидают модуль адаптера. Порт возвращает доменный результат, и это то, что отличает адаптер от «клиента, торчащего наружу».
Преобразователь: генерировать или писать руками
«Для простых конверсий подходит MapStruct» — брошено одной фразой, а решение имеет последствия, и на границе с чужой системой они особенно неприятные.
Как работает генерация преобразователей. Библиотека сопоставляет поля по именам и генерирует код при сборке. Для преобразования «наша запись → наша запись» это удобно: меньше ручного кода, ошибки сопоставления видны при сборке.
Почему на границе с внешней системой чаще пишут руками. Три причины, и они практические:
- Молчаливое сопоставление по имени скрывает изменение чужого контракта. Поставщик переименовал поле или изменил его смысл (сумма была в рублях, стала в копейках) — имена всё ещё совпадают или перестали совпадать, а генератор в лучшем случае промолчит, в худшем поставит
null. Ручной преобразователь тоже не заметит смены смысла, но он читается: в ревью видноmoney.rubles()противamountMinor, и вопрос задаётся. - На границе почти всегда нужна не перекладка, а решение. Свести четыре чужих статуса в два своих, разобрать строку с датой в чужом формате, понять, что
errorCode: "0"означает успех, собрать сумму из двух полей. Это не сопоставление имён, а код — и его всё равно придётся писать, только внутри генерируемого преобразователя, где он выглядит чужеродно. - Явная потеря полей — цель, а не недостаток. Из восьмидесяти чужих полей берём семь. Генератор при этом будет предупреждать о несопоставленных полях (или придётся их перечислять), а ручной код просто не упоминает лишнее.
Когда генерация оправдана даже на границе: структуры действительно похожи и большие (тридцать полей один в один — например, при переносе своей же модели между сервисами), и есть настройка «падать при несопоставленном поле» — тогда изменение чужого контракта ломает сборку, а это ровно то, что нужно.
Практическая раскладка, которую выбирают команды: внутри сервиса (наша запись ↔ наша запись, запись базы ↔ агрегат) — генерация, потому что полей много и они совпадают; на границе с чужой системой — руками, потому что там не перекладка, а перевод. И в обоих случаях преобразователь покрыт тестами: для генерации — тестом на реальном примере ответа, для ручного — тем же. Преобразователь без теста — то место, где молча появляется null в проде.
Частые ошибки
Ошибка 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 — Spring-аннотаций в ядре нет
@RequiredArgsConstructor
public class FallbackPaymentHandler
implements UseCaseHandler<RegisterPaymentCommand, RegisterResult> {
private final PaymentPort primaryPort;
private final PaymentPort fallbackPort;
@Override
public RegisterResult handle(RegisterPaymentCommand cmd) {
try {
return primaryPort.register(cmd.registerCommand());
} catch (PaymentPortException e) {
return fallbackPort.register(cmd.registerCommand()); // выбор — в ядре, не в адаптере
}
}
}
Обратите внимание: порт один — PaymentPort, а полей два. Ядро и не должно знать, что за ними Sber и OdnaKassa, для него это просто «основной шлюз» и «запасной». Кто именно окажется в каком поле, решают при сборке бинов в bootstrap/ — там же, где выбирали между @Qualifier и @Primary:
// bootstrap/.../PaymentConfig.java
@Bean
FallbackPaymentHandler fallbackPaymentHandler(
@Qualifier("sberPaymentPort") PaymentPort primary,
@Qualifier("odnaKassaPaymentPort") PaymentPort fallback) {
return new FallbackPaymentHandler(primary, fallback);
}
Чем это отличается от Circuit Breaker в адаптере: тот решает техническую задачу — перестать долбить упавший сервис и быстро вернуть ошибку, — а FallbackPaymentHandler принимает бизнес-решение, куда нести деньги, когда основной провайдер лёг. Первое живёт в адаптере, второе — в ядре, и одно не заменяет другое.
Поменять порядок шлюзов местами — правка одной строки в конфигурации, ядро при этом не трогают вообще.
Глубже: outbox и идемпотентность на стороне адаптероврасширенное
Статья про in-адаптеры говорит «события в outbox не появятся», и слово outbox стоит объяснить в терминах модулей, потому что в гексагональной раскладке у него два конца.
Запись. Port EventPublisher в ядре объявляет одно: опубликовать событие. Реализует его не Kafka-адаптер, а persistence-адаптер: событие пишется строкой в таблицу outbox той же транзакцией, что и агрегат, и это единственный способ не потерять событие при откате и не опубликовать его при откате. Ядро при этом не знает ни о таблице, ни о брокере.
Доставка. Отдельный компонент, relay, читает outbox пачками с блокировкой строк (FOR UPDATE SKIP LOCKED, чтобы две реплики не отправили одно событие дважды), отправляет в брокер через Kafka-адаптер и помечает отправленным после подтверждения брокера. Живёт он в bootstrap или в отдельном модуле, потому что зависит и от persistence, и от брокера, а ядро от него не зависит вовсе.
Приём. Доставка «хотя бы один раз» означает дубли, и их гасит in-адаптер сообщений: таблица обработанных идентификаторов в той же транзакции, что и команда, которую он отправляет в ядро. Ядро остаётся идемпотентным по смыслу (повторное подтверждение уже подтверждённого заказа ничего не делает), а адаптер отсекает повторы по идентификатору до ядра. Механика с кодом разобрана в статье про синхронизацию через события в CQRS и в статье про паттерны через AMQP; здесь важно, где что живёт: запись в persistence-адаптере, доставка в bootstrap, отсечение дублей в in-адаптере.
Коротко
- Out-адаптер — реализация port-интерфейса из ядра; переводит domain-вызов в конкретную технологию (HTTP, SQL, Kafka) и обратно. Один модуль — одна внешняя система. Это даёт изолированные зависимости, настройку отказоустойчивости, метрики и тесты.
- Mapper в адаптере знает все детали внешней системы (форматы, коды, единицы); ядро ничего этого не видит. Адаптер только маппит и вызывает. Бизнес-правила — в ядре; координация нескольких систем — тоже в ядре.
- Port-интерфейс возвращает domain-объекты, никогда DTO внешней системы. Адаптеры не зависят друг от друга. Если нужна оркестрация — это use case в core.
- Outbox в раскладке: port публикации реализует persistence-адаптер записью в таблицу той же транзакцией, relay в bootstrap доставляет в брокер с блокировкой строк, in-адаптер сообщений отсекает дубли до ядра.
- Хранилище — особый адаптер: участвует в транзакции, отдаёт агрегат на изменение, его отказ означает аварию; приёмы внешних систем (повторы, размыкание) на него не переносят и наоборот.
- Сроков ожидания два, и их сумма не должна превышать свой срок ответа; повторять можно только идемпотентные операции, ключ выводят из смысла операции, а без поддержки ключей остаются запрос состояния, сверка или ручной разбор.
- Тест адаптера проверяет перевод в обе стороны на заглушке: успех, бизнес-отказ внутри успешного ответа, технические отказы, неожиданный формат, содержимое исходящего запроса и число повторов.
- Клиент чужого API бывает сгенерированным (описание фиксируют по версии), написанным руками или из библиотеки поставщика; структуры чужого формата не покидают модуль адаптера.
- Внутри сервиса преобразователи удобно генерировать, на границе с чужой системой их пишут руками, потому что там не перекладка полей, а перевод с потерей лишнего; и в обоих случаях они покрыты тестами.
Что почитать дальше
- Ports в Hexagonal Architecture — что implements out-адаптер и как объявлять port-интерфейсы.
- In-адаптеры — симметричная сторона: как входящие запросы попадают в ядро.
- Repository pattern в jOOQ — конкретный out-адаптер (persistence) с jOOQ.
- Паттерны отказоустойчивости - откуда берутся сроки ожидания, повторы и размыкание, которые адаптер настраивает у себя.