Spring Boot
Use resilience4cata in a Spring Boot 4 application: annotated beans are protected by the generated code, the
registry is configured from application.yml, and Actuator gets a health indicator, an endpoint and metrics.
The module resilience4cata-spring (available from 0.4.0) is a Spring Boot auto-configuration. It adds
nothing to the call path: there are no proxies, aspects or interceptors. A protected bean is the class that
the annotation processor generated, so a call costs what it costs without Spring.
Requirements: Spring Boot 4.1 or newer (the module is compiled against 4.1.0 and tested on 4.1.1) and Java 21. Spring Boot 3 is not supported.
Install
Add the module and, as for any use of the annotations, the annotation processor
(installation). The module brings resilience4cata-core and
resilience4cata-config.
Gradle
dependencies {
implementation platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
annotationProcessor platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
implementation 'dev.resilience4cata:resilience4cata-spring'
compileOnly 'dev.resilience4cata:resilience4cata-annotations'
annotationProcessor 'dev.resilience4cata:resilience4cata-processor'
implementation 'dev.resilience4cata:resilience4cata-micrometer' // optional: metrics
testImplementation 'dev.resilience4cata:resilience4cata-testkit' // optional: ManualClock
}
Gradle (Kotlin)
dependencies {
val r4cBom = platform("dev.resilience4cata:resilience4cata-bom:0.10.0")
implementation(r4cBom)
annotationProcessor(r4cBom)
implementation("dev.resilience4cata:resilience4cata-spring")
compileOnly("dev.resilience4cata:resilience4cata-annotations")
annotationProcessor("dev.resilience4cata:resilience4cata-processor")
implementation("dev.resilience4cata:resilience4cata-micrometer") // optional: metrics
testImplementation("dev.resilience4cata:resilience4cata-testkit") // optional: ManualClock
}
Maven
<!-- with resilience4cata-bom imported in <dependencyManagement> -->
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-spring</artifactId>
</dependency>
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-annotations</artifactId>
<scope>provided</scope>
</dependency>
<!-- in the maven-compiler-plugin configuration -->
<annotationProcessorPaths>
<path>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-processor</artifactId>
<version>0.10.0</version>
</path>
<!-- annotationProcessorPaths replaces discovery: list the other processors you use too -->
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
Without the annotation processor nothing is generated and the beans stay unprotected: the annotations have
CLASSretention, so nothing at run time can notice they are there.GET /actuator/resilience4catalists the policy instances; an empty list means the processor did not run.
Annotated classes
Annotate the class itself. This is the recommended style in Spring:
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@Retry(maxAttempts = 3, delay = "100ms")
@CircuitBreaker(name = "orders")
public Order find(String id) {
return orders.load(id);
}
public Order findAgain(String id) {
return find(id); // a call on this: still retried
}
}
The processor generates OrderService$Resilient extends OrderService, and the integration makes Spring
instantiate it wherever a bean definition names OrderService (component scanning, @Import,
registerBean). So:
- the bean is still an
OrderService: it is injected by its class, its interfaces,@Qualifier,List<…>, and keeps its name, scope and@Primary; - calls on
this(findAgaincallingfind) go through the policies, unlike Spring AOP and Spring's own@Retryable; - constructor injection works as before: the generated constructors take the
ResilienceRegistry(injected by type) followed by the original parameters, with their names and annotations (@Autowiredon one of several constructors,@Qualifier,@Value; see annotated classes). Field and setter injection and@PostConstructare inherited.
A @Bean method that returns new OrderService(...) would produce an unprotected bean, so the startup fails
with R4C-SPR-001: register the class instead, or return
new OrderService$Resilient(registry, ...) from the method.
Proxies over the class. The generated class is not final, so Spring can still put a class-based proxy
around it (@Transactional, @Cacheable, @Async, scoped beans). The proxy is outside the policies. For
transactions that would mean one transaction for every attempt, and after a failed attempt marks it
rollback-only, a later successful attempt fails to commit with UnexpectedRollbackException. So a method that
is both retried or hedged and transactional (@Transactional of Spring or Jakarta, with a propagation that
joins or starts a transaction) stops the startup with R4C-SPR-004. Fix it
by declaring the policy on an interface the bean implements (the interface wrapper goes outside the
transaction proxy: one transaction per attempt), or by moving @Transactional to another bean that the
retried method calls. If one shared transaction is what you want, set
resilience4cata.spring.allow-retry-inside-transaction=true. @CircuitBreaker, @Timeout, @RateLimiter
and @Bulkhead run the call once and are not checked. The generated override repeats the annotations of the
method it overrides, so a method-level Jakarta @Transactional (which Spring reads on the invoked method only)
applies and is checked like Spring's.
Annotated interfaces
@Retry(maxAttempts = 3)
@Timeout("2s")
public interface StockClient {
int available(String sku);
}
@Component
public class HttpStockClient implements StockClient {
@Override
public int available(String sku) {
return 42; // an HTTP call in a real application
}
}
A bean that implements an annotated interface is replaced, once initialized, by
Resilient.of(StockClient.class, bean, registry). Inject the interface:
@Component
public class StockReport {
private final StockClient stock; // the interface: the bean is StockClient$Resilient
public StockReport(StockClient stock) {
this.stock = stock;
}
public String line(String sku) {
return sku + ": " + stock.available(sku);
}
}
- The bean's type becomes the wrapper, which implements only
StockClient: injectingHttpStockClientfails, as with JDK proxies, and the startup report says to inject the interface. - The wrapper is applied after every other post-processor, so it wraps their proxies: a retried call through a
@Transactionalproxy gets a new transaction per attempt. - Calls inside
HttpStockClientonthisare not protected (the wrapper is outside the object). - A bean that implements two annotated interfaces, neither extending the other, fails the startup with
R4C-SPR-003. A bean that already is a generated class (a@Beanmethod that returnsResilient.of(...)itself) is left as it is.
Configuration
The policies read the application's properties under resilience4cata, with the keys of the
configuration reference in kebab-case or camelCase:
resilience4cata:
defaults:
retry:
delay: 50ms
circuit-breaker:
minimum-calls: 20
configs:
slow:
retry.delay: 500ms
instances:
orders:
circuit-breaker.failure-rate-threshold: 25
"[dev.resilience4cata.spring.guide.StockClient#available(java.lang.String)]":
timeout.value: 3s
- Instance ids other than letters, digits,
_and-(the derived<type>#<method>(<parameter types>)) go in brackets, as Spring writes map keys:"[com.acme.StockClient#available(java.lang.String)]"in YAML,resilience4cata.instances[com.acme.StockClient#available(java.lang.String)].timeout.value=3sin.properties. - Lists (
retry-on,abort-on,record-on,ignore-on) can be YAML lists or comma-separated values. - Placeholders (
${…}) are resolved. - Precedence is Spring's: command-line arguments and test properties over system properties over environment
variables over
application-<profile>.ymloverapplication.yml. Environment variables use theR4C_orRESILIENCE4CATA_names ofresilience4cata-config. Aresilience4cata.propertiesfile on the class path is still read, below all of them. - Profiles: Spring profile documents (
application-prod.yml,spring.config.activate.on-profile) are the natural way; resilience4cata's ownprofiles.<name>.*keys keep working too (resilience4cata.profiles.active: ${spring.profiles.active:}ties them together). - Configuration is read once, at startup, like everywhere else in resilience4cata: there is no refresh.
Validation at startup. An unknown key or a bad value stops the startup with every problem at once, with the file and line where it was set:
***************************
APPLICATION FAILED TO START
***************************
Description:
Invalid resilience4cata configuration (1 problem):
[R4C-CFG-001] unknown key 'resilience4cata.defaults.retry.max-atempts' (class path resource [application.yml] - 4:20). Did you mean 'resilience4cata.defaults.retry.max-attempts'? ...
Once the singletons exist, every resilience4cata.instances.<id>.* key must match a policy instance, or the
startup fails with R4C-CFG-013. With lazy beans or pipelines built later, set
resilience4cata.spring.verify-instances=warn (the default under spring.main.lazy-initialization=true).
The module ships Spring Boot configuration metadata for the defaults keys and the switches below, which IDEs
that read it use to complete and document them.
Customizers and listeners
A ResilienceRegistryCustomizer bean adjusts the registry builder, for example a named config in code:
@Bean
ResilienceRegistryCustomizer slowBackends() {
return builder -> builder.retry("slow", retry -> retry.maxAttempts(5).delay(Duration.ofSeconds(1)));
}
Customizers run in @Order, before the properties, so the properties win: configs.slow.retry.delay above
overrides the customizer's delay and keeps its maxAttempts. A defaults.<policy>.* key replaces a
customizer's <policy>Defaults(...) for that policy.
Every EventListener bean is registered for the event type in its generic signature (a raw listener gets every
event):
@Bean
EventListener<CircuitBreakerEvent.StateTransition> breakerAlerts(Alerts alerts) {
return transition -> alerts.send(transition.instance() + " is now " + transition.to());
}
Listeners run on the thread that caused the event (events). A listener bean must not depend on a protected bean: the registry needs the listener, and the protected bean needs the registry.
A Ticker bean (the testkit's ManualClock) becomes the registry's clock. An application that defines its own
ResilienceRegistry bean replaces the auto-configured one (customizers and listener beans are then not
applied); the ResilienceConfig bean is still there to build it with config.applyTo(builder).
Metrics, health and the endpoint
- Metrics: with
resilience4cata-micrometeron the class path and aMeterRegistrybean (Spring Boot Actuator's), the Micrometer meters are registered automatically. Successful calls publish no events, so they cost nothing. There are no per-call observations (ObservationRegistry): one per call would allocate and read the clock on every success. - Health: the
resilience4catahealth indicator reports every circuit breaker (state, failure and slow-call rates, calls in the window). It stays UP with open breakers: an open breaker is the application protecting itself from a failing dependency, not a reason to restart it or take it out of load balancing. List the breakers that should turn it DOWN (UNKNOWN while half open) inresilience4cata.spring.health.down-when-open(*for all). It is not part of the liveness or readiness groups unless you add it. - Endpoint:
GET /actuator/resilience4catareturnsregistry.snapshot(), andGET /actuator/resilience4cata/{instance}the policies of one instance id. It is read-only and keeps no event history. Expose it like any endpoint:management.endpoints.web.exposure.include=health,resilience4cata.
Switches
| Property | Default | Effect |
|---|---|---|
resilience4cata.spring.wrap |
true |
Replace beans of annotated interfaces by their wrapper |
resilience4cata.spring.exclude |
empty | Bean names to leave alone: no subclass, no wrapper, no checks |
resilience4cata.spring.allow-retry-inside-transaction |
false |
Allow @Retry/@Hedge on transactional methods of annotated classes (one transaction for every attempt) instead of failing with R4C-SPR-004 |
resilience4cata.spring.verify-instances |
fail (warn with lazy initialization) |
fail, warn or off for instance keys that match nothing |
resilience4cata.spring.health.down-when-open |
empty | Breaker ids (or *) that turn the health DOWN while open |
resilience4cata.spring.metrics.enabled |
true |
Register the Micrometer meters |
management.health.resilience4cata.enabled |
true |
The health indicator |
Tests
Declare a ManualClock bean: retry pauses, bulkhead and rate limiter waits and timeouts follow it, so a test
never sleeps (testing guide).
@TestConfiguration
static class TestClock {
@Bean
ManualClock clock() {
return new ManualClock(); // retry pauses, waits and timeouts follow it instead of the system clock
}
}
@Autowired
OrderService orders;
@Autowired
ManualClock clock;
@Autowired
OrderRepository repository; // fails the first loads it is told to
@Test
void retriesWithoutSleeping() {
repository.failures.set(2);
assertThat(orders.find("42")).isEqualTo(new Order("42"));
assertThat(clock.elapsed()).isPositive(); // the two pauses, taken on the manual clock
}
Test doubles of protected beans:
@MockitoBeanreplaces the bean with the bare mock, without the policies, for an annotated interface or class.@MockitoSpyBeanon an annotated interface injects the bare spy too, soverify(spy)works; on an annotated class the spy keeps the policies.- A test context shared by several test classes shares one registry, so an open breaker stays open across them:
reset it with
registry.circuitBreaker(id).reset(), or use@DirtiesContext.
AOT and native images
Spring AOT (the processing step of a native image build, or of an AOT-optimized JVM application) works:
- the swap of annotated classes happens while the bean definitions are processed, so the generated code calls
new OrderService$Resilient(...)directly, and the post-processor that swapped them is left out of the optimized context; - interface beans are still wrapped at run time with
Resilient.of, which findsStockClient$Resilientby name. The annotation processor writes GraalVM reachability metadata for every generated class (META-INF/native-image/dev.resilience4cata.generated/…), so no Spring runtime hints are needed; - properties are read at run time, but conditions are fixed at build time, as everywhere in Spring AOT:
resilience4cata.spring.metrics.enabled,management.health.resilience4cata.enabled, the endpoint's exposure, andresilience4cata.spring.excludefor annotated classes.
Spring Boot 4 needs GraalVM 25 or newer for native images. The project's build checks AOT processing on the JVM; native images themselves are not built by its CI.
Limits
- Annotated interfaces change the bean type to the wrapper: inject the interface. Calls on
thisinside the implementation are not protected. One annotated interface per bean. @Beanmethods must return the generated class themselves; a full@Configurationclass with@Beanmethods cannot be annotated (R4C-SPR-002).- With annotated classes, class-based proxies such as
@Transactionalrun outside the policies; a retried or hedged transactional method fails the startup (R4C-SPR-004) unless allowed. The check reads the class files, so a native image skips it: it runs in the JVM build and tests. - Methods that the constructor calls on
thisrun before the policies are set up (annotated classes). - No per-call observations, no event history in the endpoint, no hot reload of the configuration.