Опирается на правила: R-RES-OAS-1R-RES-OAS-4 и R-RES-OAS-X1R-RES-OAS-X3 из Resilience Style Guide → раздел 9. Связка с OpenAPI generator.

Важно знать

  • Resilience4j-аннотации — на public-методе out-adapter класса, который оборачивает вызов generated client. Не на generated interface, не в helper'е.
  • Generated interface перегенерируется при каждом compileJava — модификации потеряются.
  • Для нового сервиса — target spring-restclient (Spring 6.1+). Native RestClient, Observation API, OTel auto-configure без обвязки.
  • OpenAPI-спека внешнего API — в <system>-client-generator/src/main/resources/openapi/<system>.openapi.yaml. Codegen в build/generated/sources/openapi/, не коммитится.
  • Mapper обязателен между generated DTO и port-интерфейсом. Generated DTO — это транспорт, не доменные типы.
  • Адаптер возвращает domain-типы, не generated DTO. Утечка generated DTO наверх — нарушение границ.

OpenAPI-first для outbound означает: внешний API описан YAML-ом, из YAML генерируется Java-клиент, мы вокруг него пишем адаптер. Главный вопрос — где именно прикреплять resilience-инструменты, и почему именно там. Раскрытие раздела 9 гайда.

Аннотации на public-методе out-adapter

R-RES-OAS-1: @CircuitBreaker / @Bulkhead / @Retry — на нашем out-adapter методе, обёртывающем generated client.

// Generated by openapi-generator (НЕ модифицируем):
public interface SberOrderServicesApi {
    Call<SberRegisterResponse> register(SberRegisterRequest req, String correlationId);
    Call<SberOrderStatusResponse> getOrderStatus(String orderId);
}

// Наш adapter (core/payment/port/out/PaymentPort.java — domain port):
public interface PaymentPort {
    RegisterResult register(RegisterCommand cmd);
    OrderStatus getStatus(OrderId id);
}

// payment-out-adapter/src/main/java/.../SberClientAdapter.java:
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {

    private final SberOrderServicesApi sberApi;
    private final SberRequestMapper mapper;

    @CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
    @Bulkhead(name = "sber")
    @Retry(name = "sber")
    @Override
    public RegisterResult register(RegisterCommand cmd) {
        SberRegisterRequest req = mapper.toApiRequest(cmd);
        SberRegisterResponse resp = executeCall(sberApi.register(req, null));
        return mapper.toDomain(resp);
    }
}

Почему именно здесь:

  • Generated SberOrderServicesApi будет перегенерирован при следующем compileJava — аннотации потеряются.
  • executeCall<T> helper — общая утилита; если повесить аннотации там с backendName строкой как параметром, теряется compile-time check имени.
  • SberClientAdapter.register(...) — это и есть публичная граница порта PaymentPort. На неё имеет смысл вешать resilience: «вызов к Sber» как business-операция.

spring-restclient — для нового кода

R-RES-OAS-2: новые out-adapter генерируем с target spring-restclient.

// <system>-client-generator/build.gradle.kts
openApiGenerate {
    generatorName.set("spring")
    library.set("spring-restclient")
    inputSpec.set("$projectDir/src/main/resources/openapi/sber.openapi.yaml")
    outputDir.set("$buildDir/generated/sources/openapi")
    apiPackage.set("ru.vikulinva.client.sber.api")
    modelPackage.set("ru.vikulinva.client.sber.dto")
    configOptions.set(mapOf(
        "useSpringBoot3" to "true",
        "useJakartaEe" to "true",
        "interfaceOnly" to "true",
        "useBeanValidation" to "true",
        "openApiNullable" to "false"
    ))
}

Что это даёт:

  • RestClient.Builder integration — native в Spring 6.1+. Не нужно прятать OkHttpClient за Retrofit-конвертер.
  • Observability через Spring Observation API автоматически. Spans, метрики, MDC — всё работает без обвязки.
  • Tracing через OTel auto-configure: spring-boot-starter-actuator + opentelemetry-spring-boot-starter — и spans приходят сами.
  • Совместимость с @CircuitBreaker через AOP — без дополнительных wrap'ов.

Для legacy сервисов (Retrofit2 уже в проде) — допустимо оставить. Новые out-adapter — на spring-restclient.

Расположение OpenAPI-спеки и codegen

R-RES-OAS-3: спецификация внешнего API хранится в client-generator модуле.

modules/
├── core/
├── persistence/
├── payment-out-adapter/
│   ├── src/main/java/.../SberClientAdapter.java
│   └── build.gradle.kts                          # depends on sber-client-generator
├── sber-client-generator/
│   ├── src/main/resources/openapi/
│   │   └── sber.openapi.yaml                     # ← spec внешнего API
│   └── build.gradle.kts                          # openApiGenerate task
└── bootstrap/

Что важно:

  • sber.openapi.yaml — копия (или fork) внешнего OpenAPI. Если внешняя система не публикует OpenAPI — пишется руками.
  • build/generated/sources/openapi/.gitignore. Регенерация на каждом compileJava.
  • Версионирование — спека коммитится, build её регенерит. PR со spec-update показывает diff в generated SberOrderServicesApi, можно проверить совместимость.

Mapper между generated DTO и domain

R-RES-OAS-4: между generated client и port — обязательно mapper.

// Domain port (core/):
public interface PaymentPort {
    RegisterResult register(RegisterCommand cmd);
}

// Domain DTO:
public record RegisterCommand(OrderId orderId, Money amount, String idempotencyKey) {}
public record RegisterResult(String externalId, RegisterStatus status, Instant confirmedAt) {}

// Mapper (payment-out-adapter):
@Component
public class SberRequestMapper {
    public SberRegisterRequest toApiRequest(RegisterCommand cmd) {
        return new SberRegisterRequest()
            .orderId(cmd.orderId().value())
            .amount(cmd.amount().value().doubleValue())
            .currency(cmd.amount().currency().getCurrencyCode())
            .idempotencyKey(cmd.idempotencyKey());
    }

    public RegisterResult toDomain(SberRegisterResponse resp) {
        return new RegisterResult(
            resp.getSberOrderId(),
            mapStatus(resp.getStatus()),
            resp.getConfirmedAt() != null ? resp.getConfirmedAt().toInstant() : null
        );
    }

    private RegisterStatus mapStatus(SberStatus s) {
        return switch (s) {
            case CONFIRMED -> RegisterStatus.CONFIRMED;
            case PENDING -> RegisterStatus.PENDING;
            case DECLINED -> RegisterStatus.DECLINED;
        };
    }
}

Зачем mapper:

  • Generated DTO — транспорт. Они меняются вместе с external API. Domain — стабильный.
  • Изоляция от breaking-changes. Внешняя система переименовала поле — меняется один mapper, остальной код не задет.
  • Тип-безопасность. SberStatus.PENDINGRegisterStatus.PENDING (могут разойтись). Mapper делает explicit-перевод.

Plain Java для простого mapping, MapStruct для сложных полей (lists, optional с трансформацией). См. jOOQ → DomainRecordMapper — те же принципы для persistence-mapper'ов.

Что запрещено

Аннотации на generated interface

R-RES-OAS-X1: попытка прилепить @CircuitBreaker к generated SberOrderServicesApi.

// ПЛОХО — мейн классы перегенерирует
public interface SberOrderServicesApi {
    @CircuitBreaker(name = "sber")         // ← компилируется, но регенерация затрёт
    @Retry(name = "sber")
    Call<SberRegisterResponse> register(...);
}

Что произойдёт:

  • ./gradlew clean compileJava пересоздаст SberOrderServicesApi.java из YAML.
  • Аннотации исчезнут. CB перестанет работать.
  • В лучшем случае баг ловится тестом, в худшем — на проде.

Корректно: аннотации на нашем SberClientAdapter.register, который оборачивает generated client.

CB в executeCall helper

R-RES-OAS-X2: общий helper с backendName строкой как параметром.

// ПЛОХО — общий helper с runtime-name
public class ResilienceHelper {
    public <T> T executeWithCb(String backendName, Supplier<T> action) {
        CircuitBreaker cb = CircuitBreakerRegistry.of(...).circuitBreaker(backendName);
        return cb.executeSupplier(action);
    }
}

@Component
public class SberClientAdapter {
    public RegisterResult register(...) {
        return resilienceHelper.executeWithCb("sber", () -> doRegister(...));
        //                                    ^^^^^
        //                                    runtime string, опечатка не ловится компилятором
    }
}

Что не так:

  • Опечатка в имени (sbr вместо sber) → runtime error «unknown circuit breaker» вместо compile-error.
  • AOP не работает — @CircuitBreaker через аннотацию даёт автоматическую интеграцию с Micrometer и trace-attribute. Programmatic CB надо обвязывать руками.
  • Стиль расходится: одни адаптеры через annotation, другие через helper. Code review не ловит несоответствия.

Корректно: аннотация @CircuitBreaker(name = "sber") на adapter-методе. Имя — string-literal, но прицепленный к конкретному методу — review его видит сразу.

Возврат generated DTO из port-метода

R-RES-OAS-X3: PaymentPort.register(...) возвращает SberRegisterResponse.

// ПЛОХО — generated DTO утекло в port
public interface PaymentPort {
    SberRegisterResponse register(RegisterCommand cmd);   // ← Sber-специфичный DTO в core!
}

Что не так:

  • core/ зависит от generated client. Это нарушение Hexagonal — port должен быть в domain, generated client — деталь out-adapter.
  • Смена провайдера невозможна. Если завтра подключаем Yoomoney как fallback, YoomoneyAdapter.register возвращает YoomoneyResponse — другой тип, handler этого не примет.
  • Тесты тяжелее. Чтобы протестировать handler, надо мочить SberRegisterResponse — generated объект с десятками полей.

Корректно: RegisterResult — domain-record в core/. Все adapter'ы (Sber, Yoomoney) маппят свой ответ в один domain-тип.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Аннотации на generated <X>Api interfaceR-RES-OAS-X1На adapter-методе
@CircuitBreaker в executeCall<T> helper с string-nameR-RES-OAS-X2Аннотация на adapter-методе
PaymentPort.register возвращает SberRegisterResponseR-RES-OAS-X3Domain RegisterResult + mapper
Generated client без mapper-а в adapterR-RES-OAS-4Mapper Plain Java / MapStruct
Retrofit2 для нового сервисаR-RES-OAS-2spring-restclient
Generated sources коммитятся в gitR-RES-OAS-3.gitignore на build/generated/

Куда дальше