Architecture
Synthesis of 02 Annotation mechanisms, 03 Performance internals and 05 Project architecture. It describes the design as implemented in 0.6.0; the research documents record how it was chosen.
Overview
graph LR
user["User code<br/>@Retry @CircuitBreaker …"] -->|javac| processor
processor -->|generates| gen["PaymentClient$Resilient<br/>(plain Java source)"]
gen -->|permit protocol| core
config -->|registry builder| core
user -->|"Resilient.of(...)"| core
- The user annotates interfaces or non-final classes.
- At compile time,
processorvalidates the annotations (fallback signatures, durations, conflicting bundles) and generates plain Java source: a delegating wrapper for interfaces, a subclass for non-final classes. - At startup, the user builds a
ResilienceRegistry(programmatically and/or from external config) and gets the instance withResilient.of(PaymentClient.class, impl, registry)for an interface, orResilient.create(InventoryService.class, registry, constructorArgs...)(or the generated constructor,new InventoryService$Resilient(registry, ...)) for a class. - The processor is mandatory. If the generated class is missing (the processor was not configured),
Resilient.offails at startup with a message that shows theannotationProcessorline to add. There is no reflection or proxy fallback. - Configuration is resolved once, when the instance is created, into immutable policy objects held in
finalfields of the generated class. Calls never look anything up.
Annotation mechanism
Decision: compile-time annotation processing (APT) that generates source (02 §5).
Dispatch cost measured in our own JMH run (JDK 25, Apple M1, trivial guard; a direct call costs 1.2 ns) (02 §3):
| Mechanism | 1 service type | 4 service types | Allocation | First instance |
|---|---|---|---|---|
| APT, policy protocol inlined | 2.4–2.6 ns | 6.9–7.3 ns | 0 B | 0.37 ms |
APT through a shared execute(Supplier) |
2.5 ns | 14.2 ns | 0 B | — |
| Runtime ByteBuddy | 2.0 ns | ~6.0 ns | 0 B | ~200 ms |
Proxy + Method.invoke |
16 ns | 37 ns | 16–64 B | 2.4 ms |
Why APT:
- Per-call cost similar to hand-written code, no reflection, monomorphic call sites.
- Instant startup, works in GraalVM native-image without configuration, readable stack traces.
- Errors such as a wrong fallback signature or an invalid duration become compile errors.
- No user ceremony beyond adding an
annotationProcessordependency. No JVM flags, no agent, no special compiler.
Rejected or postponed:
- Runtime ByteBuddy: about 200 ms for the first instance, a 9 MB dependency, and extra native-image setup.
- AspectJ and Java agents: JVM flags, the JEP 451 dynamic-attach warning, and the weaver lags behind each JDK.
- Class-File API: final only since JDK 24, not usable with a Java 21 baseline.
Proxyfallback: rejected for now. The processor is mandatory and annotations useCLASSretention. If adynamicmodule is ever added, moving toRUNTIMEretention is a compatible change.
Shape of the generated code
The JMH run shows that the shape of the generated code matters as much as the mechanism. Calling the policies through a shared lambda doubles the cost once several services exist. So core exposes two APIs:
- A functional API for humans (
Pipeline, builders) for code that cannot be annotated. - A low-level, allocation-free permit protocol for generated code, in the package
dev.resilience4cata.generated(exported, but only for generated code):acquire()/onSuccess(token, started)/onFailure(token, started, error)for the breaker,start()/onFailure(attempts, error, start)/pause(delay)for the retry,enter()/succeeded(scope)/failed(scope, error)for the timeout, and so on.
Each policy is one layer: the public method holds the outermost one, and each inner layer is a private method with the same signature, ending with the call to the implementation. This is what the processor generates for @Retry + @CircuitBreaker + @Timeout on String track(String) (from the getting-started guide; the fully qualified names are shortened here):
@Override
public String track(String p0) throws IOException {
final RetryPolicy r4c$retry = this.retry0;
final long r4c$start = r4c$retry.start();
int r4c$attempts = 0;
while (true) {
try {
return this.r4c$circuitBreaker0(p0);
} catch (Throwable r4c$e) {
final long r4c$delay = r4c$retry.onFailure(++r4c$attempts, r4c$e, r4c$start);
if (r4c$delay < 0 || !r4c$retry.pause(r4c$delay)) {
throw r4c$e;
}
}
}
}
private String r4c$circuitBreaker0(String p0) throws IOException {
final CircuitBreakerPolicy r4c$cb = this.circuitBreaker0;
final long r4c$token = r4c$cb.acquire();
if (r4c$token < 0) {
throw r4c$cb.rejection(); // preallocated, stackless
}
final long r4c$started = r4c$cb.startTimer(); // reads the clock only if slowCallDuration is set
try {
final String r4c$result = this.r4c$timeout0(p0);
r4c$cb.onSuccess(r4c$token, r4c$started);
return r4c$result;
} catch (Throwable r4c$e) {
r4c$cb.onFailure(r4c$token, r4c$started, r4c$e);
throw r4c$e;
}
}
private String r4c$timeout0(String p0) throws IOException {
final TimeoutPolicy r4c$timeout = this.timeout0;
final TimeoutPolicy.Scope r4c$scope = r4c$timeout.enter();
final String r4c$result;
try {
r4c$result = this.delegate.track(p0); // monomorphic call
} catch (Throwable r4c$e) {
r4c$timeout.failed(r4c$scope, r4c$e);
throw r4c$e;
}
r4c$timeout.succeeded(r4c$scope);
return r4c$result;
}
Asynchronous methods (returning CompletionStage or CompletableFuture) are the exception: they are generated as nested policy.executeAsync(() -> next) calls, because futures already allocate and go through megamorphic callbacks, so one lambda per layer costs little there.
A processor/core version mismatch is detected when the instance is created: if the generated class lacks the constructor this core expects, Resilient.of / Resilient.create fail with R4C-RT-004. Use the BOM to keep both on the same version.
Interface vs implementation annotations
| Style | Annotations that count | Precedence, lowest first |
|---|---|---|
Wrapper: Resilient.of(PaymentClient.class, impl, registry) |
Interface only. The implementation is not known at compile time; annotating it makes the class use the subclass style instead | declaring interface < wrapped interface < interface method |
Subclass: Resilient.create(HttpPaymentClient.class, registry) |
Interface and implementation | interface < interface method < class < class method |
In the subclass style the implementation wins, attribute by attribute: a class method that sets only maxAttempts keeps the delay declared on the interface.
Self-invocation
Decision: calls from one method to another inside the same object are intercepted whenever the mechanism allows it.
| Style | this.other() inside the user's code |
Why |
|---|---|---|
Subclass (annotated non-final class, Resilient.create(InventoryService.class, registry)) |
Intercepted | The generated subclass overrides other(), so the virtual call goes through it |
Wrapper (annotated interface, Resilient.of(PaymentClient.class, impl, registry)) |
Not intercepted | The user's implementation calls itself directly; the wrapper never sees it |
The difference is documented in the getting-started guide. Users who need self-invocation in the wrapper style annotate the implementation class instead. A method that calls itself recursively goes through its policies on every call, which is also documented.
Policy internals
As implemented, starting from the proposals in 03; the lock-free parts are checked with jcstress and every hot-path change with JMH (benchmarks).
| Policy | Design |
|---|---|
| Circuit breaker | The state is an immutable phase object (closed, open, half-open, forced open, disabled) swapped by CAS through a VarHandle, so objects are allocated only on transitions; permits carry the phase's generation and stale outcomes are dropped; count window as a slot array updated with getAndSet plus counters, time window as one-second buckets with LongAdder counters; while closed with only fast successes, a single quiet field is all a call reads |
| Rate limiter | GCRA on one padded long (the theoretical arrival time) with a plain CAS; the reject path only reads; a caller that loses the CAS parks for 1 ns before retrying; no background thread |
| Bulkhead | With maxWait = 0, a CAS loop on a padded counter (acquire to take a slot, release to give it back); a non-fair Semaphore when maxWait > 0; async waiters queue without blocking and are handed released slots |
| Adaptive limit | The bulkhead's fail-fast counter against a limit read with opaque access; each completed call adds its latency to its thread's stripe of cumulative counters (lossy plain writes, 128-byte stripes); the thread that completes a call after the window's deadline takes a one-CAS window lock and runs Gradient2 or AIMD (Netflix concurrency-limits) on the window's average latency, most calls in flight and drops; no background thread |
| Retry | Attempt state in locals; nothing allocated on the success path; full, equal or no jitter |
| Timeout | The call stays on the caller's thread; one shared watchdog thread checks per-thread reusable scopes against a coarse clock and interrupts late calls, with a stamped CAS protocol so an interrupt never hits the next call; async timeouts use a separate lock-free timer (striped inboxes) that never fires early |
| Cross-cutting | Typed event records, allocated only when someone listens; snapshots on demand (pull); padding of write-hot fields; preallocated stackless rejections; no clock read unless the feature needs it |
Not available with a Java 21 baseline: StructuredTaskScope (still a preview API in JDK 25). synchronized pinning was only fixed in JDK 24 (JEP 491), so synchronized is banned on hot paths.
Modules
Single-word directories. Published under the groupId dev.resilience4cata (the project owns resilience4cata.dev); artifactIds carry a prefix: resilience4cata-core, and so on. Root Java package and JPMS module prefix: dev.resilience4cata (05 §1).
| Module | Published | Responsibility | Depends on |
|---|---|---|---|
annotations |
yes | Annotation types, plus @Experimental / @Internal |
— |
core |
yes | Every policy, registry, config model, Pipeline, the generated-code protocol, events and snapshots |
— (JDK only, plus JSpecify) |
processor |
yes | Annotation processor: validation and code generation | annotations |
config |
yes | Loads properties files, classpath resources, environment variables and system properties into the registry builder | core |
micrometer |
yes | Micrometer metrics: counters from events, gauges from breaker, bulkhead, rate limiter and adaptive limit handles (guide) | core, micrometer-core |
opentelemetry |
yes | OpenTelemetry metrics, the same as the Micrometer module's (guide) | core, opentelemetry-api |
testkit |
yes | Test helpers for users: ManualClock (clock, sleeps and timers), seeded jitter, elapseWaitInOpen |
core |
junit |
yes | JUnit Jupiter extension: a fresh ManualClock and registry per test (testing guide) |
testkit, junit-jupiter-api |
bom |
yes | Version alignment (resilience4cata-bom) |
— |
benchmark |
no | JMH against Resilience4j | core, processor, Resilience4j |
stress |
no | jcstress for lock-free structures | core |
examples |
no | Runnable samples, also end-to-end tests of the generated code | annotations, core, config, testkit, junit, micrometer, opentelemetry, processor |
docs |
no | Documentation. Not a Gradle project | — |
graph TD
processor -->|compile| annotations
config --> core
micrometer --> core
testkit --> core
stress --> core
benchmark --> core
benchmark --> processor
examples --> annotations
examples --> core
examples --> config
examples --> testkit
examples -.annotationProcessor.-> processor
Rules, enforced by the module descriptors (module-info.java) and the Gradle dependencies (no ArchUnit yet):
-
Interfaces that annotations refer to (
FallbackHandler<T>,Classifier<T>) live incore, not inannotations, soannotationscan staycompileOnly. The annotation attributes are typedClass<?>, and the processor checks at compile time that the class implements the right interface. -
coreandannotationshave no dependencies, except JSpecify (api, annotations only) incore. -
processoris never a runtime dependency; it only appears onannotationProcessor. -
Generated code uses only
core's exported API. -
Integrations depend on
core, never on each other. -
The split is by dependency boundary, never by pattern. Resilience4j's one-artifact-per-pattern split is a common complaint.
-
There is no module named
test: it would clash with Gradle'stesttask, so the module istestkit.
What a user declares (details, Maven and JPMS in getting started):
dependencies {
implementation platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
annotationProcessor platform('dev.resilience4cata:resilience4cata-bom:0.10.0') // the processor path does not inherit it
implementation 'dev.resilience4cata:resilience4cata-core'
compileOnly 'dev.resilience4cata:resilience4cata-annotations' // CLASS retention: not needed at runtime
annotationProcessor 'dev.resilience4cata:resilience4cata-processor'
}
Build and tooling
Based on 05 §2–§4, as set up now:
- No Kotlin anywhere. Gradle 9 (wrapper 9.6) with the Groovy DSL, kept minimal and declarative (
settings.gradle, one shortbuild.gradleper module). All build logic lives in convention plugins written in Java, in theconventionsincluded build. Versions are ingradle/libs.versions.toml. - Java toolchain 21 with
--release 21,-Xlint:all -Werror; CI matrix on JDK 21 and 25 (GitHub Actions, not yet run on GitHub). Configuration cache and build cache on. module-info.javain every published module; internal packages are not exported.- Testing: JUnit, AssertJ, jcstress and JMH (with an allocation gate). Processor tests compile sources in memory with a small
Compilationhelper on plainjavax.tools, not the compile-testing library. ArchUnit and jqwik (in maintenance mode) are ideas, not used. - Static analysis:
javac -Xlint:all -Werrorand JSpecify nullness annotations. Error Prone + NullAway, Spotless and JaCoCo are ideas, not set up. - Publishing: to the Maven Central Portal (OSSRH closed in June 2025) with Gradle's own
maven-publishandsigningplugins and no third-party plugin. Ther4c.publishconvention plugin writes the POMs and signs;r4c.central, at the root, builds a checked bundle zip and uploads it through the Publisher API as a deployment that is released by hand in the portal. Thedev.resilience4catanamespace is verified on the Central Portal through a DNS TXT record onresilience4cata.dev. Steps: Releasing. - Versioning: SemVer,
0.xwhile incubating.apiCompatibility(japicmp, incheckand CI) compares every published module with the last release: a warning before1.0, a build failure from1.0unless the major version changes (Releasing).
Documentation
docs/is plain Markdown, not a Gradle module, served by Docsify (docs/index.html).- Same file tree in
docs/enanddocs/es. English is the source of truth; every change todocs/enmust updatedocs/es. - Markdown stays tool-neutral (CommonMark/GFM plus Mermaid), so moving to another site generator later needs no rewrite.