How the default aspect order can break your resilience and caching strategy
While working on a Spring Boot 4 service using Java 25, I wanted to protect my external API calls as these can go down every now and then.
For this I used Resilience4j @CircuitBreaker and @Retry annotations, and put a @Cacheable on top.
Three annotations stacked on a single method, what could possibly go wrong?
It turns out that the order in which they are applied by default is probably not the one you want. This makes your circuit breaker count every retry attempt as a separate failure. And once the circuit opens it even blocks data that is sitting right there in your cache. Here is what happens by default, and how I changed it.
Three annotations, one method
@Cacheable("products")
@Retry(name = "externalApi")
@CircuitBreaker(name = "externalApi")
public ProductDto getProduct(String id) {
// RestClient call to the external API
}
Each annotation is handled by a Spring AOP aspect that wraps your method call.
You can think of this like the layers of an onion.
A call goes through the layers from the outside in, until it reaches your method.
The result (or exception) then travels back out through those same layers in reverse order.
The order of the annotations in your Java source code has zero impact on this wrapping.
Instead, the order value of each aspect decides which layer is on the outside.
The default order
The Resilience4j documentation describes its default order:
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( Function ) ) ) ) ).
What it does not tell you is where Spring’s @Cacheable ends up.
Out of the box, these are the order values (lower value means an outer layer):
Retry LOWEST_PRECEDENCE - 5 (outside)
CircuitBreaker LOWEST_PRECEDENCE - 4
Cache LOWEST_PRECEDENCE (inside)
The cache ends up on the inside because @EnableCaching uses Ordered.LOWEST_PRECEDENCE by default.
So your call is wrapped like this:
Retry( CircuitBreaker( Cache( getProduct() ) ) )
That has some side effects I didn’t like:
-
Every retry attempt goes through the circuit breaker. One failing request with 3 attempts counts as 3 failed calls. This means the circuit opens a lot sooner than you might expect.
-
An open circuit throws a
CallNotPermittedException. The retry sits on the outside, so it catches that exception. With the default retry config, which retries on every exception, it waits and tries again while the circuit is still open. -
When the circuit is open, the call never reaches the cache. Data that is perfectly available in memory is not served.
The order I wanted
I wanted the cache on the outside, then the circuit breaker, and the retry closest to the actual HTTP call:
Cache( CircuitBreaker( Retry( getProduct() ) ) )
That takes three settings:
resilience4j.circuitbreaker.circuit-breaker-aspect-order=1
resilience4j.retry.retry-aspect-order=2
@Configuration
@EnableCaching(order = 0)
public class CacheConfig {
}
The Resilience4j docs say that a higher value means a higher priority. That "priority" means the aspect sits closer to your method (on the inside), not that it runs first (on the outside).
With this order:
-
A cache hit is returned straight away, even when the circuit is open.
-
The retry does all its attempts first. Only when the last one fails does the circuit breaker record a single failure, so the failure rate is based on requests, not attempts.
-
An open circuit rejects the call before the retry even starts, so there is no pointless waiting.
Conclusion
Stacking resilience and caching annotations is easy, but relying on their default aspect order can quietly break your strategy. So write the order you want down explicitly in your configuration and back it up with tests. The next developer reading your code will thank you.