Опирается на правила: R-HEX-AOUT-1R-HEX-AOUT-4 и R-HEX-AOUT-X1R-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
        }
    }
}

Куда дальше