Опирается на правила:
R-RES-OAS-1…R-RES-OAS-4иR-RES-OAS-X1…R-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.Builderintegration — native в Spring 6.1+. Не нужно прятатьOkHttpClientза Retrofit-конвертер.- Observability через Spring
ObservationAPI автоматически. 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.PENDING≠RegisterStatus.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 interface | R-RES-OAS-X1 | На adapter-методе |
@CircuitBreaker в executeCall<T> helper с string-name | R-RES-OAS-X2 | Аннотация на adapter-методе |
PaymentPort.register возвращает SberRegisterResponse | R-RES-OAS-X3 | Domain RegisterResult + mapper |
| Generated client без mapper-а в adapter | R-RES-OAS-4 | Mapper Plain Java / MapStruct |
| Retrofit2 для нового сервиса | R-RES-OAS-2 | spring-restclient |
| Generated sources коммитятся в git | R-RES-OAS-3 | .gitignore на build/generated/ |
Куда дальше
- Resilience → раздел 9. Связка с OpenAPI generator — нормативные
R-RES-OAS-*. - Per-system isolation — bean-структура per-system.
- Circuit Breaker —
@CircuitBreakerна adapter-методе. - Bulkhead —
@Bulkheadрядом с CB. - Retry —
@Retryрядом с CB и Bulkhead. - Hexagonal Style Guide — порты и адаптеры, границы модулей.
- REST API Style Guide — OpenAPI-first для нашего API.