← назад к разделу

Когда сервис хочет сохранить данные в базу, отправить 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) объявило как «мне нужна внешняя зависимость».

Схема:

core/ sber-out-adapter/ объявляет PaymentPort реализует порт, знает про Sber API

Контракт принадлежит ядру, а знание про 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. Не один огромный мок для всего.

ядро persistence/ свой пул и SQL sber-out-adapter/ свой SDK и срок sms-out-adapter/ свой размыкатель kafka-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-результат. Всё.

вызов ядра RegisterCommand маппер в формат Sber Sber API запрос по сети маппер в домен результат RegisterResult

Вся работа адаптера от вызова ядра до доменного результата: два перевода и один поход наружу.

Маппер — переводчик между мирами

Детали внешней системы (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, PostgreSQLSber API, Kafka
sber-out-adapter/Sber SDK, RestClient, Resilience4jPostgreSQL, Kafka
kafka-out-adapter/KafkaTemplate, сериализаторыSber, PostgreSQL

Это явно закрепляется в build.gradle.kts каждого модуля — зависимости прописаны явно, и Gradle не даст sber-out-adapter случайно дотянуться до jOOQ.

Откуда берётся клиент чужого API

В примере используется SberOrderServicesApi, и вопрос «откуда он взялся» решает, где живут структуры чужого формата и что именно преобразует преобразователь. Вариантов три.

1. Сгенерирован из описания API поставщика. Если поставщик даёт машинное описание (OpenAPI, protobuf), клиент и структуры генерируются при сборке. Это лучший вариант: описание поставщика меняется — сборка падает или генерирует новые классы, и расхождение видно сразу. Структуры при этом сгенерированные, и это важно: их нельзя править руками и нельзя тащить в ядро (они меняются вместе с чужим описанием).

Практическая деталь: генерацию кладут в тот же модуль адаптера (или в отдельный модуль-генератор, если клиент используется несколькими адаптерами), а результат генерации не коммитят — он собирается заново. Описание поставщика фиксируют по версии в репозитории: иначе сборка начинает зависеть от того, что поставщик опубликовал сегодня.

2. Написан руками. Когда описания нет или оно неверно (частый случай у старых интеграций): свой класс с тремя методами на нужные операции и свои записи для запроса и ответа. Больше работы, зато полный контроль: вы описываете только то, что используете, и неожиданное поле в ответе не ломает разбор.

3. Взят из библиотеки поставщика. Официальная библиотека избавляет от разбора протокола и часто уже содержит повторы и подписи запросов. Цена: она тянет свои зависимости (иногда конфликтующие с вашими — вот тут и спасает отдельный модуль), навязывает свою модель ошибок и живёт по своему расписанию обновлений.

От выбора зависит объём работы преобразователя. В первом и третьем случаях структуры чужие и подробные — преобразователь делает настоящий перевод: берёт нужные поля, сводит чужие коды к своим, теряет лишнее. Во втором случае вы сами описываете структуры и можете сразу сделать их узкими — тогда преобразователь короче, но часть работы уже сделана при написании клиента.

Что не меняется ни в одном варианте: структуры чужого формата не покидают модуль адаптера. Порт возвращает доменный результат, и это то, что отличает адаптер от «клиента, торчащего наружу».

Преобразователь: генерировать или писать руками

«Для простых конверсий подходит MapStruct» — брошено одной фразой, а решение имеет последствия, и на границе с чужой системой они особенно неприятные.

Как работает генерация преобразователей. Библиотека сопоставляет поля по именам и генерирует код при сборке. Для преобразования «наша запись → наша запись» это удобно: меньше ручного кода, ошибки сопоставления видны при сборке.

Почему на границе с внешней системой чаще пишут руками. Три причины, и они практические:

  1. Молчаливое сопоставление по имени скрывает изменение чужого контракта. Поставщик переименовал поле или изменил его смысл (сумма была в рублях, стала в копейках) — имена всё ещё совпадают или перестали совпадать, а генератор в лучшем случае промолчит, в худшем поставит null. Ручной преобразователь тоже не заметит смены смысла, но он читается: в ревью видно money.rubles() против amountMinor, и вопрос задаётся.
  2. На границе почти всегда нужна не перекладка, а решение. Свести четыре чужих статуса в два своих, разобрать строку с датой в чужом формате, понять, что errorCode: "0" означает успех, собрать сумму из двух полей. Это не сопоставление имён, а код — и его всё равно придётся писать, только внутри генерируемого преобразователя, где он выглядит чужеродно.
  3. Явная потеря полей — цель, а не недостаток. Из восьмидесяти чужих полей берём семь. Генератор при этом будет предупреждать о несопоставленных полях (или придётся их перечислять), а ручной код просто не упоминает лишнее.

Когда генерация оправдана даже на границе: структуры действительно похожи и большие (тридцать полей один в один — например, при переносе своей же модели между сервисами), и есть настройка «падать при несопоставленном поле» — тогда изменение чужого контракта ломает сборку, а это ровно то, что нужно.

Практическая раскладка, которую выбирают команды: внутри сервиса (наша запись ↔ наша запись, запись базы ↔ агрегат) — генерация, потому что полей много и они совпадают; на границе с чужой системой — руками, потому что там не перекладка, а перевод. И в обоих случаях преобразователь покрыт тестами: для генерации — тестом на реальном примере ответа, для ручного — тем же. Преобразователь без теста — то место, где молча появляется 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 бывает сгенерированным (описание фиксируют по версии), написанным руками или из библиотеки поставщика; структуры чужого формата не покидают модуль адаптера.
  • Внутри сервиса преобразователи удобно генерировать, на границе с чужой системой их пишут руками, потому что там не перекладка полей, а перевод с потерей лишнего; и в обоих случаях они покрыты тестами.

Что почитать дальше