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.

A caller bean calls a Bookshelf bean that Spring has replaced with a CGLIB proxy. Inside the proxy the call passes through seven boxes from left to right: Retry at LOWEST_PRECEDENCE minus 5, CircuitBreaker at minus 4, RateLimiter at minus 3, TimeLimiter at minus 2, Bulkhead at minus 1, then two dashed boxes for Timer and the transaction advisor, both at LOWEST_PRECEDENCE, and finally the target method. A blue arrow below shows the call travelling inwards, a red arrow shows the result or exception travelling outwards. Three notes below state that order is a number and not the source order of the annotations, that a fallback runs inside the aspect that declares it and is not a layer of its own, and that Timer and the transaction advisor sit innermost by default

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

@CircuitBreaker

io.github.resilience4j.circuitbreaker.annotation

name (required), configuration (""), fallbackMethod ("")

CircuitBreakerRegistry, resilience4j.circuitbreaker.*

@Retry

io.github.resilience4j.retry.annotation

name (required), configuration (""), fallbackMethod ("")

RetryRegistry, resilience4j.retry.*

@RateLimiter

io.github.resilience4j.ratelimiter.annotation

name (required), configuration (""), fallbackMethod (""), permits (1)

RateLimiterRegistry, resilience4j.ratelimiter.*

@Bulkhead

io.github.resilience4j.bulkhead.annotation

name (required), configuration (""), fallbackMethod (""), type (Bulkhead.Type.SEMAPHORE; the other value is THREADPOOL)

BulkheadRegistry and resilience4j.bulkhead. for SEMAPHORE; ThreadPoolBulkheadRegistry and resilience4j.thread-pool-bulkhead. for THREADPOOL

@TimeLimiter

io.github.resilience4j.timelimiter.annotation

name (required), configuration (""), fallbackMethod ("")

TimeLimiterRegistry, resilience4j.timelimiter.*

@Timer

io.github.resilience4j.micrometer.annotation

name (required), configuration (""), fallbackMethod ("")

TimerRegistry, resilience4j.micrometer.timer.*

The attributes mean the same thing everywhere:

  • name is the instance in the registry and the value of the name tag on every metric. It may be a constant, a property placeholder or a SpEL expression (see Instances missing from the YAML).

  • configuration is the name of a shared configs entry 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 the default one applies.

  • fallbackMethod is the name of a method in the same class that answers when the protected call fails. The resolution rules are in Fallbacks & Composition.

  • permits exists only on @RateLimiter. It was added in 2.3.0 and the Spring 6 aspect that both starters use ignores it (see below).

  • type exists only on @Bulkhead and chooses between the semaphore bulkhead and the thread-pool bulkhead.

Every attribute of every annotation
@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.

A class-level circuit breaker with a method-level override
@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

T, void

Protected

Protected

IllegalStateException on every call

IllegalReturnTypeException on every call

Synchronous; exceptions are thrown to the caller

CompletableFuture<T>, CompletionStage<T>

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

Future<T> (not a CompletionStage)

Treated as synchronous: the failure inside the future is not seen

Treated as synchronous

IllegalStateException on every call

IllegalReturnTypeException on every call

Declare CompletableFuture

Mono<T>, Flux<T>

Protected (needs resilience4j-reactor)

Protected

IllegalStateException on every call

Protected, timeout raised as a Reactor error

The pattern applies on subscription

RxJava 3 Flowable, Single, Maybe, Completable, Observable (the *Source interfaces)

Protected (needs resilience4j-rxjava3 and RxJava on the classpath)

Protected

IllegalStateException on every call

Protected

RxJava 2 works the same with resilience4j-rxjava2

The table is the result of calling each combination against a failing backend and looking at what the pattern recorded:

Checking the type handling of every annotation
@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:

  • Future is not CompletionStage. A method that declares java.util.concurrent.Future is guarded only for the moment it takes to create the future. Declare CompletableFuture (or CompletionStage) so that the aspect can see the failure.

  • @TimeLimiter has no synchronous form. It needs a stage or a reactive type. Run against bodies that take one second with a limit of 100 ms, a CompletableFuture, a Mono, a Flux and a Single all timed out after about 110 ms, while a String and a Future threw IllegalReturnTypeException immediately. The semantics are in Time Limiter.

  • @Bulkhead(type = THREADPOOL) needs a CompletionStage. On any other type it throws IllegalStateException ("ThreadPool bulkhead is only applicable for completable futures") before the body runs; the stub recorded zero invocations. In a run with a CompletableFuture the body ran on a thread named bulkhead-tp-cf-1.

  • Reactive and RxJava support is conditional. The Reactor extension beans are registered only when Reactor and resilience4j-reactor are on the classpath, and the RxJava ones only when RxJava and the matching resilience4j-rxjava* module are. Without them a Mono method is treated like a plain object: the pattern wraps the creation of the Mono, not its subscription. The operators behind these extensions, and Kotlin suspend functions, 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:

A test that fails when the annotations are not backed by aspects
@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:

sequenceDiagram participant C as Caller bean participant P as Proxy (CGLIB subclass) participant A as Resilience4j aspects participant T as Target object C->>P: viaThis() P->>T: viaThis() (no annotation, no advice) T->>T: this.guarded() Note over T: a plain Java call: no proxy, no aspect T-->>C: result, nothing recorded C->>P: guarded() or a call from another bean P->>A: Retry, CircuitBreaker, ... A->>T: guarded() T-->>C: result, recorded and retried
Figure 1. Self-invocation bypasses the proxy
The same method, three ways of calling it
@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:

  1. 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 PriceGateway bean with the annotated fetch() and a PriceService that calls it: in the run the backend was invoked three times and the breaker recorded three failures, exactly as for a direct call.

  2. Inject the proxy (@Autowired @Lazy on a field of the same type) and call through it. It works, and it is a signal that the class has two responsibilities.

  3. Use AopContext.currentProxy() with exposeProxy = 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:

Visibility and 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:

An annotated interface method on a client bean that is a JDK 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:

Retry and a transaction on one method
@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 read TransactionInterceptor, 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 @Transactional caller. Even with the default order the retry now runs inside the caller’s transaction. The inner @Transactional method 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 threw UnexpectedRollbackException ("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 use Propagation.REQUIRES_NEW on 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:

The effective order of a stacked method
@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 CallNotPermittedException is 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. @Timer and a user aspect without an order both sit at LOWEST_PRECEDENCE, and so do @Transactional and @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).

Explicit orders with gaps, and a user aspect between the breaker and the rate limiter
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:

flowchart TD A["Annotation: name, configuration"] --> B["Resolve name: placeholder, SpEL or constant"] B --> C{"configuration empty?"} C -- "yes" --> D["configKey = resolved name"] C -- "no" --> E["configKey = configuration"] D --> F{"configs has configKey?"} E --> F F -- "yes" --> G["use that configuration"] F -- "no" --> H["use configs.default (silently)"] G --> I{"instance already in the registry?"} H --> I I -- "yes" --> J["return the existing instance, the configuration is ignored"] I -- "no" --> K["create it, register it, bind its metrics"]
Figure 2. How an aspect resolves the instance of an annotation

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

@CircuitBreaker(name = "undeclared") with configs.default.slidingWindowSize: 7

A new instance with window 7: the default configuration.

@CircuitBreaker(name = "undeclared-strict", configuration = "strict")

A new instance with the strict configuration (window 3).

@CircuitBreaker(name = "ledgerx") where configs.ledgerx exists but instances.ledgerx does not

The instance takes the configuration with the same name (window 5): the name doubles as the configuration key.

@CircuitBreaker(name = "declared", configuration = "default") where instances.declared has baseConfig: strict

The existing instance is returned (window 3) and the annotation’s configuration is ignored.

@CircuitBreaker(name = "pricing-typo")

A new instance with the default configuration, silently. A misspelt name does not fail.

@CircuitBreaker(name = "nope-config", configuration = "does-not-exist")

The default configuration, silently. An unknown configuration key does not fail either.

@CircuitBreaker(name = "#root.args[0]", configuration = "strict")

One instance per argument value, each with the strict configuration.

@CircuitBreaker(name = "${g5.price.name}")

The placeholder is resolved against the Spring environment.

@CircuitBreaker(name = "@names.of(#p0)", configuration = "strict")

A SpEL bean reference: it calls the bean’s method with the first parameter.

@Retry, @RateLimiter, @Bulkhead, @TimeLimiter

The default configuration of their own registry (maxAttempts: 4, limitForPeriod: 11, maxConcurrentCalls: 6, timeoutDuration: 1500ms in the run).

@Bulkhead(name = "undeclared-tp", type = THREADPOOL)

The default configuration of resilience4j.thread-pool-bulkhead (maxThreadPoolSize: 2 in the run), not of the semaphore bulkheads.

Instances the YAML never declares
@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 (the circuitbreakers endpoint 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, with registerHealthIndicator: true in configs.default, every dynamically created breaker (including the typo and the per-ISBN ones) showed up in the circuitBreakers health details. Set registerHealthIndicator per instance rather than in default when 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.

The Bookshelf catalog
@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 spring-boot-starter-aspectj (Boot 4) or spring-boot-starter-aop (Boot 3); assert in a test that the aspect bean exists and the target is an AOP proxy.

Calling an annotated method from another method of the same class

Move it into another bean, or call it through an injected proxy or AopContext.currentProxy() with exposeProxy = true.

Annotating a private method

It is never intercepted. Make it package-private, protected or public, and call it from another bean.

Annotating a final method or class

A final method runs on the uninitialised proxy (NullPointerException), a final class fails at startup. Remove final, or on Kotlin enable kotlin-spring.

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 Future<T> instead of CompletableFuture<T>

The aspect treats the method as synchronous and never sees the failure. Return CompletableFuture or CompletionStage.

@TimeLimiter on a synchronous method

IllegalReturnTypeException on every call. Return a stage or a reactive type, or time out in the HTTP client.

@Bulkhead(type = THREADPOOL) on a non-CompletionStage method

IllegalStateException on every call.

@RateLimiter(permits = n) expecting n permits

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 *AspectOrder properties (all of them) or use Decorators.

Setting only one *AspectOrder property

The numbers are absolute and the aspect jumps relative to the defaults of the others. Set all or none.

@Retry and @Transactional on one method with a transactional caller

The first failure marks the shared transaction rollback-only and the caller’s commit throws UnexpectedRollbackException. Make the retried method the outermost boundary or use REQUIRES_NEW.

A typo in name

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.

registerHealthIndicator: true in configs.default with dynamic instances

Every dynamic instance appears in the health details. Enable it per instance.

Class-level annotation with fallbackMethod

The fallback must fit every method and it is an inner layer for a method-level @Retry. Annotate methods.

What changed recently

  • 2.4.0 added resilience4j-spring-boot4, which uses the same resilience4j-spring6 aspects 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 bulkheadAspectOrder bindable from YAML (it had no setter before) and clarified the order defaults to LOWEST_PRECEDENCE - 5 through - 1. The release notes are in the GitHub release; RELEASENOTES.adoc at the tag stops at 2.3.0.

  • Spring Boot 4 renamed the AOP starter to spring-boot-starter-aspectj; the old spring-boot-starter-aop is 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 @Retryable and @ConcurrencyLimit as 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 Ordered and the runs above say the lower value is the outer aspect.