Micronaut
Use resilience4cata in a Micronaut 5 application: annotated beans are protected by the generated code, the
registry is configured from application.yml, and Micronaut's management module gets a health indicator, an
endpoint and metrics.
The module resilience4cata-micronaut (available from 0.5.0) adds nothing to the call path: there is no
@Around advice and no interceptor. A protected bean is the class that the annotation processor generated, so
a call costs what it costs without Micronaut.
Requirements: Micronaut 5.2 or newer (the module is compiled against Micronaut core 5.2.0 and tested on
platform 5.2.2) and Java 25, which Micronaut 5 itself requires. This module is the one exception to
resilience4cata's Java 21 baseline: it is compiled with --release 25; every other module stays on Java 21.
Micronaut 4 is not supported.
Install
Add the module and, as for any use of the annotations, the annotation processor
(installation). The module brings resilience4cata-core and
resilience4cata-config. The compiler option -Ar4c.inject=micronaut makes the generated subclasses of
annotated classes Micronaut beans (annotated classes); it is not needed if you only annotate
interfaces.
Gradle
plugins {
id 'io.micronaut.application' version '5.0.2' // adds micronaut-inject-java to the processor path
}
dependencies {
implementation platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
annotationProcessor platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
implementation 'dev.resilience4cata:resilience4cata-micronaut'
compileOnly 'dev.resilience4cata:resilience4cata-annotations'
annotationProcessor 'dev.resilience4cata:resilience4cata-processor'
implementation 'dev.resilience4cata:resilience4cata-micrometer' // optional: metrics
implementation 'io.micronaut:micronaut-management' // optional: health and endpoint
testImplementation 'dev.resilience4cata:resilience4cata-testkit' // optional: ManualClock
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.add('-Ar4c.inject=micronaut') // annotated classes become Micronaut beans
}
Gradle (Kotlin)
plugins {
id("io.micronaut.application") version "5.0.2" // adds micronaut-inject-java to the processor path
}
dependencies {
val r4cBom = platform("dev.resilience4cata:resilience4cata-bom:0.10.0")
implementation(r4cBom)
annotationProcessor(r4cBom)
implementation("dev.resilience4cata:resilience4cata-micronaut")
compileOnly("dev.resilience4cata:resilience4cata-annotations")
annotationProcessor("dev.resilience4cata:resilience4cata-processor")
implementation("dev.resilience4cata:resilience4cata-micrometer") // optional: metrics
implementation("io.micronaut:micronaut-management") // optional: health and endpoint
testImplementation("dev.resilience4cata:resilience4cata-testkit") // optional: ManualClock
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-Ar4c.inject=micronaut") // annotated classes become Micronaut beans
}
Maven
<!-- with micronaut-parent as the parent, and resilience4cata-bom imported in <dependencyManagement> -->
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-micronaut</artifactId>
</dependency>
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-annotations</artifactId>
<scope>provided</scope>
</dependency>
<!-- in the maven-compiler-plugin configuration -->
<annotationProcessorPaths combine.children="append">
<!-- micronaut-parent already lists micronaut-inject-java: append ours -->
<path>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-processor</artifactId>
<version>0.10.0</version>
</path>
</annotationProcessorPaths>
<compilerArgs combine.children="append">
<arg>-Ar4c.inject=micronaut</arg>
</compilerArgs>
Without Micronaut's Gradle plugin, put io.micronaut:micronaut-inject-java on annotationProcessor yourself,
with the Micronaut platform BOM. The order of the two processors does not matter: Micronaut's never claims
annotations and ours claims only its own, so each sees everything (the module's build compiles the same sources
with both orders and compares the output). Lombok, if you use it, goes first, as Micronaut's own documentation
says (with Lombok).
Without the annotation processor nothing is generated and the beans stay unprotected: the annotations have
CLASSretention, so nothing at run time can notice they are there.GET /resilience4catalists the policy instances; an empty list means the processor did not run.
Annotated classes
Annotate the class itself; this is the recommended style, as calls on this are protected too:
@Singleton
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@Retry(maxAttempts = 3, delay = "100ms")
@CircuitBreaker(name = "orders")
public Order find(String id) {
return orders.load(id);
}
public Order findAgain(String id) {
return find(id); // a call on this: still retried
}
}
The processor generates OrderService$Resilient extends OrderService. With -Ar4c.inject=micronaut it also
repeats the class's own annotations on it (@Singleton, qualifiers such as @Named, @Requires, @Primary,
@Order…). Micronaut's processor then writes a bean definition for the generated class, the integration leaves out
the one Micronaut compiled for OrderService itself, and Micronaut creates OrderService$Resilient wherever it
would have created OrderService:
- the bean is still an
OrderService, with its scope, qualifiers and requirements; - calls on
this(findAgaincallingfind) go through the policies, unlike Micronaut's own@Retryable; - constructor injection works as before: the generated constructors take the
ResilienceRegistry(injected by type) followed by the original parameters, with their names and annotations (@Injecton one of several constructors,@Named,@Value; see annotated classes). Field and method injection and@PostConstructare inherited.
Without the option, Micronaut would create the unprotected OrderService. The module notices it when the bean is
created and fails with R4C-MN-004. (A class whose constructor has @Inject is a
special case: the generated constructor repeats @Inject, so without the option Micronaut sees two beans, the
class and its subclass, and R4C-MN-004 reports the first one.)
Without the option, with a factory. Leave the class without a scope annotation (and without @Inject), so
Micronaut does not create it, and return the generated subclass from a @Factory method:
public class InvoiceClient { // no scope annotation: Micronaut does not create it itself
private final String baseUrl;
public InvoiceClient(String baseUrl) {
this.baseUrl = baseUrl;
}
@Retry(maxAttempts = 2)
public String fetch(String number) {
return baseUrl + "/invoices/" + number; // an HTTP call in a real application
}
}
@Factory
public class InvoiceFactory {
@Singleton
InvoiceClient invoiceClient(ResilienceRegistry registry, @Value("${invoices.url}") String url) {
return new InvoiceClient$Resilient(registry, url); // typed InvoiceClient, the generated subclass inside
}
}
Declare the method's return type as the class, not InvoiceClient$Resilient: the generated class does not exist
yet when Micronaut's processor first reads the factory. A factory-made bean gets no injection of its own and no
Micronaut AOP; with the option, the class keeps all of Micronaut's features.
Micronaut AOP on the class. The generated overrides repeat the annotations of the methods they override, so
around advice (@Transactional, @Cacheable, your own @Around bindings) still applies, and Micronaut's proxy
(an $Intercepted subclass of OrderService$Resilient) goes around the policies: the advice runs once for all
the attempts. For transactions that would mean one transaction for every attempt, and after a failed attempt
marks it rollback-only, a later successful attempt fails to commit. So a method that is both retried or hedged and
@Transactional (Micronaut's, or Jakarta's mapped onto it, with a propagation that joins or starts a transaction)
fails with R4C-MN-005 when the bean is created. Fix it by declaring the policy on an
interface the bean implements (the wrapper goes outside the transaction proxy: one transaction per attempt), or by
moving @Transactional to another bean that the retried method calls. If one shared transaction is what you want,
set resilience4cata.micronaut.allow-retry-inside-transaction=true. @CircuitBreaker, @Timeout,
@RateLimiter and @Bulkhead run the call once and are not checked.
Annotated interfaces
@Retry(maxAttempts = 3)
@Timeout("2s")
public interface StockClient {
int available(String sku);
}
Mark the implementation with @ResilientBean, naming the interface:
@Singleton
@ResilientBean(StockClient.class) // the bean is replaced by StockClient$Resilient
public class HttpStockClient implements StockClient {
@Override
public int available(String sku) {
return 42; // an HTTP call in a real application
}
}
When Micronaut creates the bean, a BeanCreatedEventListener replaces it by
Resilient.of(StockClient.class, bean, registry). Inject the interface:
@Singleton
public class StockReport {
private final StockClient stock; // the interface: the bean is StockClient$Resilient
public StockReport(StockClient stock) {
this.stock = stock;
}
public String line(String sku) {
return sku + ": " + stock.available(sku);
}
}
@ResilientBean(StockClient.class)also limits the bean's types toStockClient(it sets@Bean(typed = StockClient.class)), because the wrapper implements only the interface: injectingHttpStockClientfinds no bean. Exposing it as other types fails the startup withR4C-MN-001, and naming an interface the bean does not implement withR4C-MN-002.- The wrapper replaces the bean as Micronaut built it, AOP proxy included, so Micronaut's advice on
HttpStockClientruns inside the policies: a retried@Transactionalmethod gets a new transaction per attempt. - Calls inside
HttpStockClientonthisare not protected (the wrapper is outside the object). - Use
@Singleton,@Prototypeor@Context. Scopes that Micronaut proxies (@Refreshable,@RequestScope,@ThreadLocal) fail the startup withR4C-MN-003. - Every
@ResilientBeandefinition is checked when the context starts, so these mistakes stop the startup even for beans created later. Why a marker and no automatic wrapping: it is explicit, and needs no class lookup for every bean.
Configuration
The policies read the application's configuration under resilience4cata, with the keys of the
configuration reference in kebab-case or camelCase:
resilience4cata:
defaults:
retry:
delay: 50ms
circuit-breaker:
minimum-calls: 20
configs:
slow:
retry.delay: 500ms
instances:
orders:
circuit-breaker.failure-rate-threshold: 25
'"dev.resilience4cata.micronaut.guide.StockClient#available(java.lang.String)"':
timeout.value: 3s
- Instance ids other than letters, digits,
_and-(the derived<type>#<method>(<parameter types>)) contain dots, which Micronaut would read as nesting: write the id in double quotes inside the YAML key, as above ('"com.acme.StockClient#available(java.lang.String)"'), or give the policy aname. - Lists (
retry-on,abort-on,record-on,ignore-on) can be YAML lists or comma-separated values. - Placeholders (
${…}) are resolved. - Precedence is Micronaut's: command line over system properties over environment variables over
application-<environment>.ymloverapplication.yml. Environment variables use theR4C_orRESILIENCE4CATA_names ofresilience4cata-config, read as they are rather than through Micronaut's mapping. Aresilience4cata.propertiesfile on the class path is still read, below all of them. - Environments: Micronaut's (
application-prod.yml) are the natural way; resilience4cata's ownprofiles.<name>.*keys keep working too, separately. - Configuration is read once, at startup, like everywhere else in resilience4cata: there is no refresh, and the
beans are not
@Refreshable.
Validation at startup. The configuration and the registry are @Context beans, so an unknown key or a bad
value stops the startup with every problem at once, naming the file where it was set (Micronaut keeps no line
numbers):
Bean definition [dev.resilience4cata.config.ResilienceConfig] could not be loaded: Error instantiating bean of type [dev.resilience4cata.config.ResilienceConfig]
Message: Invalid resilience4cata configuration (2 problems):
[R4C-CFG-001] unknown key 'resilience4cata.defaults.retry.max-atempts' (application.yml): ... Did you mean 'resilience4cata.defaults.retry.max-attempts'?
[R4C-CFG-002] invalid value 'soon' for 'resilience4cata.defaults.retry.delay' (application.yml): ...
At startup, each resilience4cata.instances.<id>.* key must match a policy instance
(R4C-CFG-013). Micronaut creates singletons lazily by default, so the instances of
most beans do not exist yet then: an unmatched key is only logged as a warning, which may be a false alarm. When
the context creates its singletons eagerly (eagerInitSingletons(true)), the instances of singletons exist and an
unmatched key stops the startup, unless the application has protected beans that are not singletons
(@Prototype, @Refreshable, request scope), whose instances appear when they are created: then it stays a
warning. resilience4cata.micronaut.verify-instances (fail, warn, off) overrides all of these.
Customizing the registry
Customize the registry builder the Micronaut way, with a BeanCreatedEventListener, for example a named config
in code:
@Singleton
BeanCreatedEventListener<ResilienceRegistry.Builder> slowBackends() {
return event -> event.getBean().retry("slow", retry -> retry.maxAttempts(5).delay(Duration.ofSeconds(1)));
}
Listeners run in @Order, before the configuration, so the configuration wins: configs.slow.retry.delay above
overrides the listener's delay and keeps its maxAttempts. A defaults.<policy>.* key replaces a listener's
<policy>Defaults(...) for that policy.
Every EventListener bean is registered for the event type in its generic signature (a raw listener gets every
event):
@Singleton
EventListener<CircuitBreakerEvent.StateTransition> breakerAlerts(Alerts alerts) {
return transition -> alerts.send(transition.instance() + " is now " + transition.to());
}
Listeners run on the thread that caused the event (events). A listener bean must not depend on a protected bean: the registry needs the listener, and the protected bean needs the registry.
A Ticker bean (the testkit's ManualClock) becomes the registry's clock. To build the registry yourself,
declare a bean with @Replaces(ResilienceRegistry.class); the ResilienceConfig bean is still there to apply
the configuration with config.applyTo(builder).
Metrics, health and the endpoint
- Metrics: with
resilience4cata-micrometeron the class path and aMeterRegistrybean (frommicronaut-micrometer-core), the Micrometer meters are registered automatically. Successful calls publish no events, so they cost nothing. - Health: with
micronaut-management, theresilience4catahealth indicator reports every circuit breaker (state, failure and slow-call rates, calls in the window). It stays UP with open breakers: an open breaker is the application protecting itself from a failing dependency, not a reason to restart it or take it out of load balancing. List the breakers that should turn it DOWN (UNKNOWN while half open) inresilience4cata.micronaut.health.down-when-open(*for all). It is neither@Livenessnor@Readiness, so it counts only in/health. - Endpoint:
GET /resilience4catareturnsregistry.snapshot()as JSON, andGET /resilience4cata/{instance}the policies of one instance id. It is read-only and keeps no event history. Like Micronaut's own endpoints it is sensitive by default: configure it withendpoints.resilience4cata.enabledandendpoints.resilience4cata.sensitive.
Switches
| Property | Default | Effect |
|---|---|---|
resilience4cata.micronaut.verify-instances |
warn (fail with eager singletons) |
fail, warn or off for instance keys that match no instance at startup |
resilience4cata.micronaut.exclude |
empty | Fully qualified class names of beans to leave alone (R4C-MN-004 and R4C-MN-005 are not checked) |
resilience4cata.micronaut.allow-retry-inside-transaction |
false |
Allow @Retry/@Hedge on transactional methods of annotated classes (one transaction for every attempt) instead of failing with R4C-MN-005 |
resilience4cata.micronaut.health.enabled |
true |
The health indicator |
resilience4cata.micronaut.health.down-when-open |
empty | Breaker ids (or *) that turn the health DOWN while open |
resilience4cata.micronaut.metrics.enabled |
true |
Register the Micrometer meters |
Tests
Give the test context a ManualClock bean: retry pauses, bulkhead and rate limiter waits and timeouts follow it,
so a test never sleeps (testing guide).
@Factory
public class Clocks {
@Singleton
ManualClock manualClock() {
return new ManualClock(); // retry pauses, waits and timeouts follow it instead of the system clock
}
}
@Inject
OrderService orders;
@Inject
ManualClock clock; // the @Factory above, in src/test
@Inject
OrderRepository repository; // fails the first loads it is told to
@Test
void retriesWithoutSleeping() {
repository.failures.set(2);
assertThat(orders.find("42")).isEqualTo(new Order("42"));
assertThat(clock.elapsed()).isPositive(); // the two pauses, taken on the manual clock
}
The test class is annotated @MicronautTest (from micronaut-test-junit5).
Native images
Nothing to add for resilience4cata:
- the annotation processor writes GraalVM reachability metadata for every generated class
(
META-INF/native-image/dev.resilience4cata.generated/…), which coversResilient.offor@ResilientBeanbeans; - with
-Ar4c.inject=micronaut, Micronaut's own bean definitions create the generated subclasses, without reflection; - the endpoint reads the snapshot records through bean introspections generated when the module was compiled, not through reflection.
A GraalVM 25 native image of a small application with an @ResilientBean interface, an annotated class, YAML
configuration, the endpoint and Micrometer was built and run by hand while developing the module; the project's CI
does not build native images. Build yours with Micronaut's Gradle or Maven plugin, which also puts
application.yml in the image. Exception classes named in external configuration (retry-on: com.acme.Busy) are
loaded by name: name them in the annotations instead, or register them for reflection. The
retry-inside-transaction check (R4C-MN-005) reads class files, so a native image skips it: it runs in the JVM
build and tests.
Limits
- Micronaut has its own
@Retryable,@CircuitBreakerand@Fallbackinio.micronaut.retry.annotation: check the imports, an IDE may pick the wrong one. Do not combine them with resilience4cata's on one method. - Annotated interfaces: inject the interface; calls on
thisinside the implementation are not protected; one annotated interface per@ResilientBean. - Annotated classes need
-Ar4c.inject=micronautto be created by Micronaut, or a factory. Only the class's own bean definition is left out: subclass beans, beans of the class from@Factorymethods and a test's@MockBeanstay.@Factoryclasses cannot be annotated. - Keep policies off configuration classes (
@ConfigurationProperties,@EachProperty). Micronaut's processor writes its configuration metadata once per compilation round, and it sees the generated subclass a round later, so it warnsError occurred writing configuration metadata, which fails a-Werrorbuild. Put the configuration in its own class and the policies on a bean that receives it:@EachBean(BackendConfig.class)gives one protected bean per@EachPropertyentry. - With annotated classes, Micronaut's AOP runs outside the policies; a retried or hedged transactional method fails
(
R4C-MN-005) unless allowed. - Methods that the constructor calls on
thisrun before the policies are set up (annotated classes). - No event history in the endpoint, no write operations (
forceOpen,reset) in it, no refresh of the configuration. Micronaut 4 and Java 21 are not supported by this module.
Compared with micronaut-retry and resilience4j-micronaut. Both work through Micronaut's @Around AOP: every
advised call goes through a method interceptor chain at run time, and the configuration is looked up per method.
resilience4cata's policies are generated code that Micronaut only instantiates. No benchmark of the three inside
Micronaut has been run, so no number is given here; see performance for what has been
measured.