Annotated classes
Annotate a non-final class instead of an interface, and the processor generates a subclass whose overrides apply the policies, so calls the object makes to itself are protected too.
Annotate the class
public class StockService {
private final Warehouse warehouse;
public StockService(Warehouse warehouse) {
this.warehouse = warehouse;
}
@Retry(maxAttempts = 3, delay = "50ms")
public int stock(String sku) {
return warehouse.count(sku);
}
public int total(List<String> skus) {
int total = 0;
for (String sku : skus) {
total += stock(sku); // self-invocation: goes through stock()'s retry
}
return total;
}
}
The processor writes StockService$Resilient extends StockService. It overrides only the methods that have policies (here stock), each ending with super.stock(...). total is not overridden, but its call to stock goes through the override, because that is what this.stock(...) dispatches to.
Create it
Resilient.create takes the class, the registry, and the arguments of one of the class's constructors:
StockService stock = Resilient.create(StockService.class, registry, warehouse);
int total = stock.total(List.of("a", "b")); // each stock(sku) call is retried
For every non-private constructor of the class, the generated subclass has one with a ResilienceRegistry first and the same parameters after it. Calling it directly does the same as Resilient.create, checked by the compiler:
StockService stock = new StockService$Resilient(registry, warehouse); // checked by the compiler
Resilient.create picks the constructor the way the compiler would, exact matches before varargs. With no fitting constructor it fails with R4C-RT-009, with more than one (for example null passed to overloaded constructors) with R4C-RT-010; both messages suggest the new …$Resilient(registry, …) call to use instead.
Constructors and dependency injection
Each mirrored constructor keeps the parameter names of the original and copies its annotations and the annotations of each parameter, so a dependency injection container that builds StockService$Resilient sees the same metadata as on StockService: @Autowired or @Inject on one of several constructors, @Qualifier("…"), @Named("…") or @Value("${…}") on a parameter.
public class ReportService {
@Autowired
public ReportService(@Qualifier("reportsDb") DataSource dataSource, @Value("${reports.limit}") int limit) { … }
…
}
public class ReportService$Resilient extends ReportService {
@org.springframework.beans.factory.annotation.Autowired
public ReportService$Resilient(dev.resilience4cata.ResilienceRegistry registry,
@org.springframework.beans.factory.annotation.Qualifier("reportsDb") javax.sql.DataSource dataSource,
@org.springframework.beans.factory.annotation.Value("${reports.limit}") int limit) {
super(dataSource, limit);
…
}
}
- Only annotations kept in class files (retention
CLASSorRUNTIME) are copied;SOURCEannotations, such as Lombok's, are not. Type-use annotations (@Nullablefrom JSpecify) are not repeated either. - The
ResilienceRegistryparameter has no annotation: containers inject it by type, so the application needs exactly oneResilienceRegistrybean. - If a parameter of the class's constructor is itself named
registry, the generated one is namedr4c$registry. Parameters namedjavaordevare renamedr4c$p<index>, because those names would hide the packages that the generated constructor uses. @ConstructorPropertieslists one name per parameter, so the generated copy starts with the registry's.- The parameter names are those of the source, so a container that matches beans by parameter name (Spring, compiled with
-parameters) sees the same names on both classes.
The overrides do the same for methods: each one repeats the annotations of the method it overrides and of its parameters, with the same rules, so annotations that frameworks read only on the invoked method (Jakarta's @Transactional under Spring, CDI or Micronaut interceptor bindings, @Deprecated) are not lost. resilience4cata's own annotations and the bundles that carry them are not repeated, and neither are the class's type-level annotations (@Inherited ones reach the subclass anyway), except with the compiler option -Ar4c.inject=micronaut, which repeats them and adds @Replaces(C.class) so that Micronaut creates C$Resilient in place of C (Micronaut guide).
A container that builds beans from @Inject constructors on its own (Micronaut does) sees the mirrored @Inject too, so it may create both C and C$Resilient. The Micronaut integration reports that (R4C-MN-004); with other containers, make sure only C$Resilient is registered.
Interface or class?
| Annotated interface | Annotated class | |
|---|---|---|
| Created with | Resilient.of(Api.class, impl, registry) |
Resilient.create(Impl.class, registry, args...) |
| The implementation | Anything: hand-written, generated, a lambda | Must be a non-final class you own |
this.other() inside the implementation |
Not intercepted | Intercepted |
| Annotations that count | The interface's only | The class's, its superclasses' and its interfaces' |
| Cost | Generated code, one field read to reach the delegate | Generated code that calls super directly |
Measured On AWS Graviton3 (
c7g.xlarge), a retry whose first attempt succeeds costs 1.0 ns in the class style against 1.2 ns in the interface style; with one retry, 5.6 ns against 6.5 ns. Benchmarks
A class is processed only if it declares a resilience annotation itself: on the class, on one of its methods, or through a bundle. A class that only implements an annotated interface gets no subclass; wrap it with Resilient.of(Api.class, impl, registry), which applies the interface's policies and does not intercept self-invocation.
Annotations on the class, its superclasses and its interfaces
The class style merges every annotation that applies to a method, attribute by attribute. Lowest precedence first:
- the interface's type-level annotations,
- the interface method's annotations,
- the type-level annotations of the declaring superclasses, top-down,
- the class's own type-level annotations,
- the method's annotations in the class (an override in a subclass beats the superclass's method).
@Retry(delay = "10ms", jitter = Jitter.NONE)
public interface OrderApi {
@Retry(maxAttempts = 2)
String status(String id);
@Retry(maxAttempts = 3)
String quote(String id);
}
public class OrderService implements OrderApi {
final AtomicInteger calls = new AtomicInteger();
@Override
@Retry(maxAttempts = 4) // wins over the interface method's 2; the delay still comes from OrderApi
public String status(String id) {
calls.incrementAndGet();
throw new IllegalStateException("orders down");
}
@Override // not annotated here: OrderApi's three attempts apply
public String quote(String id) {
calls.incrementAndGet();
throw new IllegalStateException("quotes down");
}
@Fallback(method = "cachedPrice")
public String price(String id) {
throw new IllegalStateException("pricing down");
}
protected String cachedPrice(String id, IllegalStateException e) { // not private: the subclass calls it
return "cached price of " + id;
}
}
Resilient.create(OrderService.class, registry) gives status 4 attempts, quote 3, both with the interface's 10 ms delay, and price its fallback. The same object wrapped with Resilient.of(OrderApi.class, ...) would see only OrderApi's annotations: 2 attempts for status.
A type-level annotation on the class applies to every method the subclass can override, inherited ones included, except:
- final methods, which are skipped;
toString,equals,hashCodeand the otherObjectmethods, which take only annotations written on the method itself.
Instance ids use the annotated class: com.acme.OrderService#status(java.lang.String).
Restrictions
All of these are compile errors, with a code and a fix in the error reference:
| What | Code |
|---|---|
The class is final |
R4C-SUB-001 |
The class is sealed |
R4C-SUB-002 |
The class is abstract |
R4C-SUB-003 |
The class is private, or nested in a private class |
R4C-SUB-004 |
| The class is an inner (non-static nested) class | R4C-SUB-005 |
Every constructor is private |
R4C-SUB-006 |
A final method is annotated at method level (in the class, a superclass or an interface) |
R4C-SUB-007 |
| An annotated method is package-private in a superclass from another package | R4C-SUB-008 |
A static or private method is annotated |
R4C-APT-002 |
| The type is an enum or a record | R4C-APT-001 |
Also:
- Fallback siblings are called on
this, so they must not beprivate(and not package-private in a superclass from another package).protectedworks. - Methods the constructor calls on
thisrun before the policies are set up, because the subclass's fields are assigned aftersuper(...)returns. Don't annotate methods that constructors call. - The generated subclass is not
final, so frameworks can proxy it (CGLIB for@Transactional, scoped proxies). Its overrides call the policies; a subclass of your own that overrides them again bypasses them. Extend your own class, not the generated one. Resilient.createrejects an interface (R4C-RT-007) and a type that cannot have a generated subclass (R4C-RT-008).
With Lombok
If Lombok generates the constructor or the methods of an annotated class, Lombok must run before resilience4cata: declare it first on the processor path (installation).
Async methods
Methods of an annotated class may return CompletionStage<T> or CompletableFuture<T>, with the same rules as in interfaces (async methods).