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

Когда сервис хочет сохранить данные в базу, отправить 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, 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.

Частые ошибки

Ошибка 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.

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