Annotations & Aspect Order
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
The Resilience4j annotations are the shortest way to protect a method, and the easiest way to protect nothing without noticing. They are not magic: each annotation is matched by a Spring AOP aspect, the aspect runs only when the call goes through a Spring proxy, and several aspects on one method nest in an order that is a number, not the order in which you wrote the annotations. This page covers all of it: the attributes of every annotation, the return types each one understands, the proxy pitfalls that turn an annotation into a silent no-op, how the aspects stack and in which order, how to change that order, and what happens when an annotation names an instance that the YAML never declared.
It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October
2026: every behaviour below was run in a test project (the results are quoted, not assumed), and the aspect classes were read at the
v2.4.0 tag. The annotations and the aspects live in resilience4j-annotations and resilience4j-spring6, which both
resilience4j-spring-boot4 and resilience4j-spring-boot3 use, so everything here behaves identically on Spring Boot 3.5.x
(the same checks were repeated there); only the starter artifact, its Java package (io.github.resilience4j.springboot against
io.github.resilience4j.springboot3) and the AOP starter name (spring-boot-starter-aspectj on Boot 4,
spring-boot-starter-aop on Boot 3) differ. The dependencies are in
Getting Started; the semantics of each pattern are in
Circuit Breaker,
Retry,
Rate Limiter,
Bulkhead and
Time Limiter. The fallback resolution rules and the full table of the
order properties are in Fallbacks & Composition; this page links
to them instead of repeating them.
In the running Bookshelf scenario the bookshelf-service calls pricing-service, an ISBN-metadata API,
recommendations-service and reviews-service. Every example below protects one of those calls, and the checks were run against a
stub that counts its invocations, records which aspects are on the call stack, and throws whatever the test asks for.
The six annotations
There are six annotations, in six packages of resilience4j-annotations. Each one is @Retention(RUNTIME), @Documented and may
be placed on a method or on a type (@Target({METHOD, TYPE})). The attributes are almost identical:
| Annotation | Package | Attributes and defaults | Instance and configuration come from |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The attributes mean the same thing everywhere:
-
nameis the instance in the registry and the value of thenametag on every metric. It may be a constant, a property placeholder or a SpEL expression (see Instances missing from the YAML). -
configurationis the name of a sharedconfigsentry to use when the instance does not exist yet. If it is empty, the instance name is used as the configuration name, and if no configuration has that name thedefaultone applies. -
fallbackMethodis the name of a method in the same class that answers when the protected call fails. The resolution rules are in Fallbacks & Composition. -
permitsexists only on@RateLimiter. It was added in 2.3.0 and the Spring 6 aspect that both starters use ignores it (see below). -
typeexists only on@Bulkheadand chooses between the semaphore bulkhead and the thread-pool bulkhead.
@Service
public class AllAttributes {
@CircuitBreaker(name = "pricing", configuration = "external", fallbackMethod = "lastKnownPrice")
public Money circuitBreaker(String isbn) { ... }
@Retry(name = "isbn", configuration = "external", fallbackMethod = "lastKnownPrice")
public Money retry(String isbn) { ... }
@RateLimiter(name = "search", configuration = "external", fallbackMethod = "lastKnownPrice", permits = 1)
public Money rateLimiter(String isbn) { ... }
@Bulkhead(name = "isbn", configuration = "external", fallbackMethod = "lastKnownPrice", type = Bulkhead.Type.SEMAPHORE)
public Money bulkhead(String isbn) { ... }
@Bulkhead(name = "recommendations", configuration = "external", fallbackMethod = "noRecommendations",
type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<List<String>> threadPoolBulkhead(String isbn) { ... }
@TimeLimiter(name = "pricing", configuration = "external", fallbackMethod = "noRecommendations")
public CompletableFuture<List<String>> timeLimiter(String isbn) { ... }
@Timer(name = "pricing", configuration = "external", fallbackMethod = "lastKnownPrice")
public Money timer(String isbn) { ... }
Money lastKnownPrice(String isbn, Exception e) { return new Money(isbn, 1850, true); }
CompletableFuture<List<String>> noRecommendations(String isbn, Exception e) {
return CompletableFuture.completedFuture(List.of());
}
}
Source: Resilience4j: Spring Boot 2 & 3 and the
annotation sources at v2.4.0.
Run on Resilience4j 2.4.0 with a backend that always fails and a retry configuration external of two attempts: every method
returned its fallback value, and the retry method invoked the backend twice, which shows that configuration = "external" was
applied to an instance that the YAML does not declare.
@RateLimiter(permits = n) is ignored
The permits attribute asks for several permits for one call, which is how a heavy operation would be made to cost more than a light one.
In 2.4.0 the RateLimiterAspect of resilience4j-spring6 never reads it: it resolves the instance and calls
executeCheckedSupplier, which takes one permit. Run with @RateLimiter(name = "perm", permits = 5) on an instance with 100
permits per period, the limiter had 99 permits left after one call, not 95. The attribute works only with the programmatic
API (RateLimiter.decorateSupplier(limiter, permits, supplier)), see
Rate Limiter. To make a call cost more with annotations, use a
separate instance with a smaller limitForPeriod.
Method level versus class level
Each aspect’s pointcut is @within(annotation) || @annotation(annotation), so the same annotation is valid on a class (every
method Spring proxies is guarded) and on a method. When both are present on one class, the method annotation wins for that
method and the class annotation is not applied to it.
@Service
@CircuitBreaker(name = "cls", fallbackMethod = "degraded")
public class ClassLevel {
public String a() { return backend.call(); } // guarded by "cls"
public String b() { return backend.call(); } // guarded by "cls"
@CircuitBreaker(name = "cls-method")
public String c() { return backend.call(); } // guarded by "cls-method" only
@Retry(name = "cls-retry")
public String d() { return backend.call(); } // @Retry plus the class-level breaker
String degraded(Throwable t) { return "degraded " + t.getClass().getSimpleName(); }
}
Source: Resilience4j: Spring Boot 2 & 3 and the
CircuitBreakerAspect
source at v2.4.0. Run on Resilience4j 2.4.0 with a failing backend: a() and b() each added one failure to the cls
breaker and returned the fallback; c() added a failure to cls-method, none to cls, and threw the original exception
because the method annotation declares no fallback; d() invoked the backend once, not three times, because the class-level
breaker with a fallback sits inside the @Retry, so the retry saw a success.
The last result is the pitfall of class-level annotations: a class-level fallbackMethod applies to every method of the class
(and must therefore match every method’s parameters), and it is an inner annotation for any method that also has a method-level
@Retry. Prefer method-level annotations when a fallback is involved.
Supported return types
The aspects look at the declared return type of the method, not at the object that comes back. Each aspect asks its list of
extensions whether one can handle the type (Reactor, RxJava 2, RxJava 3), then checks for CompletionStage, and otherwise treats
the method as synchronous.
| Declared return type | @CircuitBreaker, @Retry, @RateLimiter, @Timer |
@Bulkhead (SEMAPHORE) |
@Bulkhead (THREADPOOL) |
@TimeLimiter |
Notes |
|---|---|---|---|---|---|
|
Protected |
Protected |
|
|
Synchronous; exceptions are thrown to the caller |
|
Protected, failure seen when the stage completes |
Permit held until the stage completes |
Protected: the body runs on a pool thread |
Protected |
The method must return a stage even when it fails |
|
Treated as synchronous: the failure inside the future is not seen |
Treated as synchronous |
|
|
Declare |
|
Protected (needs |
Protected |
|
Protected, timeout raised as a Reactor error |
The pattern applies on subscription |
RxJava 3 |
Protected (needs |
Protected |
|
Protected |
RxJava 2 works the same with |
The table is the result of calling each combination against a failing backend and looking at what the pattern recorded:
@CircuitBreaker(name = "cb-sync") public String sync() { return backend.call(); }
@CircuitBreaker(name = "cb-cf") public CompletableFuture<String> cf() { return backend.callAsync(); }
@CircuitBreaker(name = "cb-future") public Future<String> future() { return backend.callAsync(); }
@CircuitBreaker(name = "cb-mono") public Mono<String> mono() { return Mono.fromSupplier(backend::call); }
@CircuitBreaker(name = "cb-flux") public Flux<String> flux() { return Flux.defer(() -> Flux.just(backend.call())); }
@CircuitBreaker(name = "cb-single") public Single<String> single() { return Single.fromCallable(backend::call); }
Source: the aspects
and their AspectExt return-type extensions at v2.4.0 and Resilience4j: Spring Boot 2 & 3.
Run on Resilience4j 2.4.0 with the backend failing: the breaker recorded one failed call for sync, void, cf, cs, mono,
flux and single, and *zero for future. The same matrix with @Retry invoked the backend three times for every type except
Future, where it invoked it once. @RateLimiter took one permit for every type, @Bulkhead held a permit while the body ran
for every type, and @Timer recorded a failed call for every type except Future, where it recorded a success.
Four consequences follow:
-
Futureis notCompletionStage. A method that declaresjava.util.concurrent.Futureis guarded only for the moment it takes to create the future. DeclareCompletableFuture(orCompletionStage) so that the aspect can see the failure. -
@TimeLimiterhas no synchronous form. It needs a stage or a reactive type. Run against bodies that take one second with a limit of 100 ms, aCompletableFuture, aMono, aFluxand aSingleall timed out after about 110 ms, while aStringand aFuturethrewIllegalReturnTypeExceptionimmediately. The semantics are in Time Limiter. -
@Bulkhead(type = THREADPOOL)needs aCompletionStage. On any other type it throwsIllegalStateException("ThreadPool bulkhead is only applicable for completable futures") before the body runs; the stub recorded zero invocations. In a run with aCompletableFuturethe body ran on a thread namedbulkhead-tp-cf-1. -
Reactive and RxJava support is conditional. The Reactor extension beans are registered only when Reactor and
resilience4j-reactorare on the classpath, and the RxJava ones only when RxJava and the matchingresilience4j-rxjava*module are. Without them aMonomethod is treated like a plain object: the pattern wraps the creation of theMono, not its subscription. The operators behind these extensions, and Kotlinsuspendfunctions, are covered in Reactive, Kotlin & RxJava.
Retry and the time limiter schedule their asynchronous work on a ScheduledExecutorService: the aspect creates one with
Runtime.availableProcessors() threads unless a ContextAwareScheduledThreadPoolExecutor bean is present. The starter
registers that bean only when resilience4j.scheduled.executor.core-pool-size is set; it is how context propagators reach
those threads (see Bulkhead for context propagation).
What makes an annotation work
The starter registers an aspect bean for each annotation (circuitBreakerAspect, retryAspect, rateLimiterAspect,
timeLimiterAspect, bulkheadAspect, timerAspect) only if org.aspectj.lang.ProceedingJoinPoint is on the classpath. Spring
then wraps every bean that has a matching annotation in a proxy and the aspects run when something calls the proxy. On Spring Boot 4 the
AspectJ classes come from spring-boot-starter-aspectj; on Boot 3 from spring-boot-starter-aop. Without that
starter the registries, endpoints and metrics still exist but no bean is proxied and every annotation is silently a no-op
(verified on Boot 4.1.1 and Boot 3.5.16; the setup is in
Getting Started). A one-test check makes the failure visible:
@SpringBootTest
class WiringTest {
@Autowired ApplicationContext context;
@Autowired BookshelfCatalog catalog;
@Test
void annotationsAreBackedByAspects() {
assertThat(context.getBeansOfType(CircuitBreakerAspect.class)).hasSize(1);
assertThat(AopUtils.isAopProxy(catalog)).isTrue();
}
}
Source: Spring Framework: Proxying Mechanisms and
Spring Boot: Aspect-Oriented Programming. Run on Spring Boot 4.1.1:
the context held one circuitBreakerAspect bean and the catalog’s class was BookshelfCatalogSpringCGLIB0. Spring Boot
proxies with CGLIB (subclass proxies) by default (spring.aop.proxy-target-class is true), which explains most of the pitfalls below.
Proxy pitfalls
A Spring proxy intercepts a call only when the call reaches the proxy object. Everything else that looks like a call to an annotated method but does not go through the proxy skips the aspects completely and without a message.
Self-invocation
A method that calls another method of the same class calls this, which is the target and not the proxy. An annotation on the
callee is never seen:
@Service
public class SelfCalling {
@Autowired @Lazy SelfCalling self;
public String viaThis() { return this.guarded(); } // bypass
public String viaSelf() { return self.guarded(); } // goes through the proxy
public String viaAopContext() { return ((SelfCalling) AopContext.currentProxy()).guarded(); }
@Retry(name = "self")
@CircuitBreaker(name = "self")
public String guarded() { return backend.call(); }
}
Source: Spring Framework: Understanding AOP Proxies
and the AopContext Javadoc.
Run on Resilience4j 2.4.0 with a backend that always fails and three retry attempts: calling guarded() from outside took three
backend invocations and added three failures to the breaker; viaThis() took one invocation and added no failure; viaSelf()
and viaAopContext() behaved like the direct call (three and three). AopContext.currentProxy() works only when the
application enables @EnableAspectJAutoProxy(exposeProxy = true); without it the call throws IllegalStateException.
Three ways out, in order of preference:
-
Move the guarded method into another bean. This is the cleanest fix and it also gives the downstream its own class. The Bookshelf version is a
PriceGatewaybean with the annotatedfetch()and aPriceServicethat calls it: in the run the backend was invoked three times and the breaker recorded three failures, exactly as for a direct call. -
Inject the proxy (
@Autowired @Lazyon a field of the same type) and call through it. It works, and it is a signal that the class has two responsibilities. -
Use
AopContext.currentProxy()withexposeProxy = true, which couples the code to Spring AOP.
private, protected, package-private and final
CGLIB creates a subclass of the bean and overrides its methods, so only methods a subclass can override are intercepted:
final on an annotated method@Service
public class Limits {
public String callPrivate() { return priv(); }
@CircuitBreaker(name = "lim-private")
private String priv() { return backend.call(); } // never intercepted
@CircuitBreaker(name = "lim-final")
public final String fin() { return backend.call(); } // cannot be overridden
@CircuitBreaker(name = "lim-protected")
protected String prot() { return backend.call(); } // intercepted
@CircuitBreaker(name = "lim-package")
String pkg() { return backend.call(); } // intercepted
@CircuitBreaker(name = "lim-public")
public String pub() { return backend.call(); }
}
Source: Spring Framework: Proxying Mechanisms. Run on Spring
Boot 4.1.1 with a failing backend: pub(), prot() and pkg() (the last two called from a bean in the same package) each
added one failure to their breaker; callPrivate() invoked the backend once and the lim-private breaker stayed at zero failures,
with no log line; fin() threw a NullPointerException and never reached the backend.
The final result deserves attention. A final method cannot be overridden, so the call runs on the proxy object itself,
whose fields were never initialised (Spring creates it without calling a constructor). Spring logs one WARN at startup:
"Public final method […] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK
proxies." Calls to it bypass the aspects and, if the body touches an injected field, fail with a NullPointerException.
A final class is worse for startup: the bean cannot be proxied at all and the context fails with a BeanCreationException
whose root cause is "Cannot subclass final class". A static method is not part of the proxy and is never intercepted.
Kotlin classes and methods are final by default, so a Kotlin Spring Boot project needs the
kotlin-spring (all-open) compiler plugin for annotated beans.
Interfaces and classes
Annotations are matched on the bean’s class, not inherited from an interface. If the bean is an ordinary class that implements an
annotated interface, the interface annotation is ignored under both proxy modes. Run on Spring Boot 4.1.1 with a failing backend,
for a bean QuotesImpl implements Quotes where @CircuitBreaker was on the interface method Quotes.onMethod() and, in a second
interface, on the interface type, the breaker recorded zero failures with spring.aop.proxy-target-class=true and with false;
the bean was not even proxied. Put the annotation on the implementation class or method.
The exception is a bean that is itself a JDK proxy of an annotated interface, which is what an HTTP interface client
(@HttpExchange, see Programmatic Usage) or a Feign client is.
There the aspects look for the annotation on the interface of the proxy:
public interface ProxyClient {
@CircuitBreaker(name = "proxy-client")
String fetch();
}
@Configuration
public class ClientConfig {
@Bean
ProxyClient proxyClient(Backend backend) {
return (ProxyClient) Proxy.newProxyInstance(ProxyClient.class.getClassLoader(), new Class<?>[] {ProxyClient.class},
(proxy, method, args) -> backend.call());
}
}
Source: the AnnotationExtractor.extractAnnotationFromProxy code path in the
AnnotationExtractor
source at v2.4.0. Run on Spring Boot 4.1.1: the breaker proxy-client recorded the failure, with
spring.aop.proxy-target-class set to true and to false. The extractor looks at the interfaces of the proxy class, so with
several interfaces the first one carrying the annotation wins.
@Transactional ordering
@Transactional is also an advisor with an order: @EnableTransactionManagement(order = …) defaults to
Ordered.LOWEST_PRECEDENCE. The five Resilience4j pattern aspects default to LOWEST_PRECEDENCE - 5 to - 1, which is outside the
transaction. A method that carries both @Retry and @Transactional therefore opens a new transaction for every attempt:
@Service
public class Ledger {
@Retry(name = "ledger")
@CircuitBreaker(name = "ledger")
@Transactional
public String book() { return backend.call(); }
}
Source: Spring
Framework: Using @Transactional with AspectJ and advisor ordering and
Spring Framework: Using @Transactional.
Run on Spring Boot 4.1.1 with an H2 data source, a transaction manager that counts its lifecycle and a backend that fails twice
and then succeeds: three transactions began, two rolled back and one committed, and the call stack at the backend read
RetryAspect, CircuitBreakerAspect, TransactionInterceptor from the outside in. That is the behaviour you normally want:
each attempt has a clean transaction and no database connection is held while the retry waits.
Two variations change the picture:
-
Move the transaction outside with
@EnableTransactionManagement(order = 0). The run then showed one transaction (one begin, one commit) around all three attempts, and the stack readTransactionInterceptor,RetryAspect,CircuitBreakerAspect. The retries now run inside one transaction and the connection is held for the whole retry wait. -
Call the retried method from a
@Transactionalcaller. Even with the default order the retry now runs inside the caller’s transaction. The inner@Transactionalmethod joins it and marks it rollback-only on the first failed attempt. In the run the third attempt succeeded and the retry reported success, and then the caller’s commit threwUnexpectedRollbackException("Transaction rolled back because it has been marked as rollback-only"); the transaction manager recorded one begin and one rollback. A retry cannot recover a transaction that was poisoned by the first attempt. Keep the retried method the outermost transactional boundary (call it from a non-transactional method) or usePropagation.REQUIRES_NEWon it. Isolation and locking are in Transaction Isolation & Locking.
Stacking annotations and the effective order
Put several annotations on one method and every aspect matches. They nest according to their order values, not according to the
order in which the annotations appear in the source. In a run, the annotations were declared in the order @Timer, @Bulkhead,
@Audited (a user aspect), @RateLimiter, @CircuitBreaker, @Retry, and the call stack seen from the body of the method read, from the outside
in:
@Timer(name = "stack")
@Bulkhead(name = "stack")
@Audited
@RateLimiter(name = "stack")
@CircuitBreaker(name = "stack")
@Retry(name = "stack")
public String sync() { return backend.call(); }
// the stub records the aspects that are on the stack when the remote call is made
StackWalker.getInstance().forEach(frame -> {
String name = frame.getClassName();
if (name.startsWith("io.github.resilience4j.spring6") && name.endsWith("Aspect")) {
aspects.add(name.substring(name.lastIndexOf('.') + 1));
}
});
Collections.reverse(aspects); // outermost first
Source: Resilience4j: Spring Boot 2 & 3 (the "Aspect order" section) and the
Spring Framework
advice ordering rules. Run on Resilience4j 2.4.0: RetryAspect, CircuitBreakerAspect, RateLimiterAspect, BulkheadAspect,
AuditAspect, TimerAspect. The asynchronous version (@Bulkhead, @TimeLimiter, @RateLimiter, @CircuitBreaker, @Retry on a
CompletableFuture) read RetryAspect, CircuitBreakerAspect, RateLimiterAspect, TimeLimiterAspect, BulkheadAspect.
The effective order is therefore:
Retry( CircuitBreaker( RateLimiter( TimeLimiter( Bulkhead( Timer( method ))))))
Source: the order values read from the aspects at run time, as described above, and the Resilience4j: Spring Boot 2 & 3 "Aspect order" section.
with @Transactional and any aspect without an explicit order at the same innermost position as @Timer. Reading the order values
at run time, they are LOWEST_PRECEDENCE minus 5, 4, 3, 2 and 1 for Retry, CircuitBreaker, RateLimiter, TimeLimiter and Bulkhead
(the thread-pool bulkhead uses the same aspect order as the semaphore one), and LOWEST_PRECEDENCE for @Timer. The table of values
and properties is in
Fallbacks & Composition.
Why this order, with the Bookshelf in mind:
-
Retry outermost repeats the whole inner stack: every attempt takes a rate-limiter permit, a bulkhead permit and is recorded by the breaker. The cost is metrics inflation (one failing request counts as three failed calls in the breaker) and a retry that keeps knocking on an open breaker unless
CallNotPermittedExceptionis ignored; both are shown in Fallbacks & Composition. -
CircuitBreaker next rejects calls fast before they use a permit of the rate limiter or the bulkhead.
-
Bulkhead innermost holds its permit only while the real call runs, not while a retry waits.
-
Timer innermost measures the time of the real call and nothing else.
A fallback is not one of these layers. It is executed by the aspect that declares it, around that aspect’s own execution, so a fallback on the breaker sits inside the retry and the retry never sees a failure (the check is in Fallbacks & Composition). Declare the fallback on the outermost annotation.
Changing the order with *AspectOrder
Each aspect reads its order from a property under its module’s prefix. The names are retryAspectOrder,
circuitBreakerAspectOrder, rateLimiterAspectOrder, timeLimiterAspectOrder and bulkheadAspectOrder (the last one bindable from YAML
only since 2.4.0), and resilience4j.micrometer.timer.timerAspectOrder for @Timer. The values are plain integers and absolute, which has
three practical consequences:
-
There is no room between the defaults. They are consecutive integers, so a user aspect cannot be placed between two of them. To slot your own aspect (logging, tenant context, auditing) between the circuit breaker and the rate limiter, give every Resilience4j aspect an explicit value with gaps.
-
Equal values have no defined order.
@Timerand a user aspect without an order both sit atLOWEST_PRECEDENCE, and so do@Transactionaland@Timer; Spring does not define which of them is outer. -
Set all or none. Setting one value moves that aspect relative to the defaults of the others (see the discussion of the readme.io example in Fallbacks & Composition).
resilience4j:
retry:
retryAspectOrder: 100
circuitbreaker:
circuitBreakerAspectOrder: 200
ratelimiter:
rateLimiterAspectOrder: 300
timelimiter:
timeLimiterAspectOrder: 400
bulkhead:
bulkheadAspectOrder: 500
micrometer:
timer:
timerAspectOrder: 600
@Aspect
@Component
public class AuditAspect implements Ordered {
@Around("@annotation(com.bookshelf.audit.Audited)")
public Object audit(ProceedingJoinPoint pjp) throws Throwable {
return pjp.proceed();
}
@Override
public int getOrder() { return 250; } // after CircuitBreaker (200), before RateLimiter (300)
}
Source: Resilience4j: Spring Boot 2 & 3, the
*ConfigurationProperties
and aspects at v2.4.0 and the
Spring Ordered Javadoc.
Run on Resilience4j 2.4.0 with a stacked method: the stack read RetryAspect, CircuitBreakerAspect, AuditAspect, RateLimiterAspect,
BulkheadAspect, TimerAspect.
The order values are global to the application: one order for every annotated method. To nest differently for a single call,
build that call with Decorators, where the order is explicit (see
Registries, Configuration & Decorators). Spring Framework
7’s own @Retryable (see Spring Framework Native Resilience)
is a different mechanism: its post-processors add the advisor in front of the existing ones, so the native retry is always the outer layer,
whatever the Resilience4j order values are (@EnableResilientMethods(order = …) only orders the post-processors among themselves). In a run with
@Retryable(maxRetries = 2) and @CircuitBreaker on one method, the native retry was the outer layer: the breaker recorded
three failures for the three attempts.
Instances missing from the YAML
An annotation can name an instance that application.yml never declares. The aspect does not fail: it resolves the instance at the
first call and creates it. The resolution is the same in all six aspects:
The code is the same few lines in every aspect (getOrCreateCircuitBreaker and its siblings):
registry.getConfiguration(configKey).orElseGet(registry::getDefaultConfig) followed by registry.circuitBreaker(name, config).
Consequences, each of them run:
| Annotation | Result |
|---|---|
|
A new instance with window 7: the |
|
A new instance with the |
|
The instance takes the configuration with the same name (window 5): the name doubles as the configuration key. |
|
The existing instance is returned (window 3) and the annotation’s |
|
A new instance with the |
|
The |
|
One instance per argument value, each with the |
|
The placeholder is resolved against the Spring environment. |
|
A SpEL bean reference: it calls the bean’s method with the first parameter. |
|
The |
|
The |
@CircuitBreaker(name = "undeclared-strict", configuration = "strict")
public String withConfiguration() { ... }
@CircuitBreaker(name = "#root.args[0]", configuration = "strict")
public String perArgument(String isbn) { ... }
@CircuitBreaker(name = "${bookshelf.price.name}")
public String placeholder() { ... }
@CircuitBreaker(name = "@names.of(#p0)", configuration = "strict")
public String beanExpression(String isbn) { ... }
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 7
minimumNumberOfCalls: 7
strict:
slidingWindowSize: 3
minimumNumberOfCalls: 3
Source: Resilience4j: Spring Boot 2 & 3 and the
DefaultSpelResolver
source at v2.4.0 (a name starting with ${ or { is a placeholder, one starting with is evaluated against the method, one starting with
@ can reference beans). The SpEL root offers #root.args, #p0 or #a0 for the arguments and #root.methodName.
Four things follow from the rule that the registry creates what it is asked for:
-
A typo is a new instance.
@CircuitBreaker(name = "pricing-typo")runs with the default configuration and nobody is told. Check the registry (thecircuitbreakersendpoint lists every instance, declared or not) after changing names. -
Unbounded names are unbounded memory and metric cardinality. A SpEL name built from a request value creates a circuit breaker, its sliding window and its meters for every distinct value, and the registry never removes them. In the run two ISBNs produced two instances, and the registry held ten breakers by the end. Use a bounded set of names (a route or a tenant class), never an identifier.
-
Health indicators follow the default configuration. The health indicator looks up the properties of an instance by name and, for an unlisted name, falls back to
configs.default. In the run, withregisterHealthIndicator: trueinconfigs.default, every dynamically created breaker (including the typo and the per-ISBN ones) showed up in thecircuitBreakershealth details. SetregisterHealthIndicatorper instance rather than indefaultwhen instances are created on demand. -
An existing instance ignores
configuration. The attribute is consulted only when the instance does not exist, so it cannot re-configure a declared instance.
The registries themselves, the baseConfig mechanism and the precedence between YAML, customizers and instances are in
Registries, Configuration & Decorators and
Spring Boot Configuration.
A worked example: the Bookshelf catalog
The catalog combines all the mechanics above in one class: three downstream calls with three different return types, each with the annotations that its failure mode needs and the fallback on the outermost one.
@Service
public class BookshelfCatalog {
@Retry(name = "pricing", fallbackMethod = "lastKnownPrice")
@CircuitBreaker(name = "pricing")
@RateLimiter(name = "pricing")
@Bulkhead(name = "pricing")
public Money price(String isbn) { ... } // synchronous
Money lastKnownPrice(String isbn, Exception e) { return new Money(isbn, 1850, true); }
@CircuitBreaker(name = "recommendations", fallbackMethod = "noRecommendations")
@Bulkhead(name = "recommendations", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<List<String>> recommendations(String isbn) { ... } // needs a CompletionStage
CompletableFuture<List<String>> noRecommendations(String isbn, Throwable t) {
return CompletableFuture.completedFuture(List.of());
}
@TimeLimiter(name = "reviews")
@CircuitBreaker(name = "reviews", fallbackMethod = "ratingOnly")
public Mono<List<String>> reviews(String isbn) { ... } // reactive
Mono<List<String>> ratingOnly(String isbn, Throwable t) { return Mono.just(List.of("rating only")); }
}
/** Not annotated: it only calls the proxied catalog, so every call crosses the proxy. */
@Service
public class BookshelfFacade {
private final BookshelfCatalog catalog;
public BookshelfFacade(BookshelfCatalog catalog) { this.catalog = catalog; }
public Details details(String isbn) {
return new Details(catalog.price(isbn), catalog.recommendations(isbn).join(), catalog.reviews(isbn).block());
}
}
resilience4j:
retry:
instances:
pricing:
maxAttempts: 3
waitDuration: 10ms
circuitbreaker:
instances:
pricing:
slidingWindowSize: 10
recommendations:
slidingWindowSize: 10
reviews:
slidingWindowSize: 10
ratelimiter:
instances:
pricing:
limitForPeriod: 50
limitRefreshPeriod: 1s
timeoutDuration: 0
bulkhead:
instances:
pricing:
maxConcurrentCalls: 10
thread-pool-bulkhead:
instances:
recommendations:
maxThreadPoolSize: 4
coreThreadPoolSize: 2
queueCapacity: 2
timelimiter:
instances:
reviews:
timeoutDuration: 2s
Source: Resilience4j: Spring Boot 2 & 3 and
Resilience4j: Bulkhead. Run on Resilience4j 2.4.0 and Spring Boot 4.1.1. With all
three services healthy, details("9780134685991") returned the price 19.99 (1999 cents, not stale), the recommendation and the review, with one
backend call each. With every downstream failing, price() made three backend calls (three attempts) and returned the stale
1850-cent price from the fallback on the @Retry, recommendations() returned an empty list and reviews() returned the
rating-only list. Each downstream has its own instance names, so a failure of recommendations-service never opens the pricing
breaker. The aspects were the defaults: the facade calls the catalog through the proxy, and the proxy runs
RetryAspect, CircuitBreakerAspect, RateLimiterAspect, BulkheadAspect for price().
Pitfalls
| Pitfall | Fix |
|---|---|
Annotations do nothing and no error appears |
Add |
Calling an annotated method from another method of the same class |
Move it into another bean, or call it through an injected proxy or |
Annotating a |
It is never intercepted. Make it package-private, protected or public, and call it from another bean. |
Annotating a |
A final method runs on the uninitialised proxy ( |
Annotating an interface of a normal bean |
The annotation is ignored. Annotate the implementation. (A bean that is itself a JDK proxy of the annotated interface is the exception.) |
Declaring |
The aspect treats the method as synchronous and never sees the failure. Return |
|
|
|
|
|
The Spring 6 aspect ignores it and takes one. Use a separate instance, or the programmatic API. |
Putting the fallback on the inner annotation |
The retry sees a success and does not retry. Put it on the outermost annotation. |
Reordering annotations in the source to change the nesting |
It does nothing. Change the |
Setting only one |
The numbers are absolute and the aspect jumps relative to the defaults of the others. Set all or none. |
|
The first failure marks the shared transaction rollback-only and the caller’s commit throws |
A typo in |
A new instance with the default configuration is created silently. Verify the registry and the metrics tags. |
A SpEL name built from a request value |
One instance and one set of meters per value, forever. Use a bounded set of names. |
|
Every dynamic instance appears in the health details. Enable it per instance. |
Class-level annotation with |
The fallback must fit every method and it is an inner layer for a method-level |
What changed recently
-
2.4.0 added
resilience4j-spring-boot4, which uses the sameresilience4j-spring6aspects as the Boot 3 starter. The aspects, the annotations and the order defaults are identical on Boot 3.5 and Boot 4.1; only the starter and AOP-starter names differ. -
2.4.0 made
bulkheadAspectOrderbindable from YAML (it had no setter before) and clarified the order defaults toLOWEST_PRECEDENCE - 5through- 1. The release notes are in the GitHub release;RELEASENOTES.adocat the tag stops at 2.3.0. -
Spring Boot 4 renamed the AOP starter to
spring-boot-starter-aspectj; the oldspring-boot-starter-aopis no longer published beyond its 4.0.0-M2 milestone. -
2.3.0 added
@RateLimiter(permits = n). The Spring 6 aspect still ignores it in 2.4.0. -
Spring Framework 7.0 added
@Retryableand@ConcurrencyLimitas native alternatives; they are added in front of the existing advisors, so they are always outer to the Resilience4j aspects (@EnableResilientMethods(order = …)does not change that), and have no fallback method, see Spring Framework Native Resilience. -
The readme.io Spring Boot page still describes the order properties as "higher value, higher priority"; Spring
Orderedand the runs above say the lower value is the outer aspect.
References
-
Resilience4j: Spring Boot 2 & 3 (annotations, fallback methods, aspect order)
-
resilience4j-spring6aspects, return-type extensions and SpEL resolver at v2.4.0