Skip to content
resilience4cata
ENES

Design decisions

Status: 0.10.0 is released on Maven Central. The six MVP policies work end to end, sync and async, on interfaces and annotated classes, plus external configuration, classifiers, the programmatic API, events, snapshots and the testkit; see implementation status. New here? Start with the quickstart. This page also summarises the design decisions taken before the first code (phase 1).

Versión en español: es/design.md

resilience4cata is a resilience library for plain Java 21+: circuit breaker, retry, timeout, bulkhead, rate limiter and fallback, declared with annotations that work without Spring, CDI or AspectJ, and built to have lower overhead than Resilience4j.

Why another resilience library

  1. Annotations without a framework. Resilience4j's annotations only do something through Spring AOP, AspectJ or Micronaut. SmallRye Fault Tolerance's standalone mode is programmatic only. No mainstream library offers declarative resilience in plain Java (01 §1.3, §4.2).
  2. Measurable performance. Resilience4j allocates on every call (272 B/op for a bare circuit breaker in its own JMH suite), takes a lock in its default sliding window, and composes policies through shared lambdas that the JIT cannot inline (01 §1.7, 03 §0). Polly v8 (.NET) shows that near-zero allocation is reachable.
  3. Multiple configurations as a first-class feature: named configs with inheritance, profiles, user-defined composed annotations such as @ExternalCall, and external overrides without recompiling (04 §6).

Decisions taken in phase 1

Topic Decision Details
Java baseline Java 21+, tested on 21 and 25 architecture
Build Gradle with a minimal Groovy DSL; convention plugins written in Java; version catalog. No Kotlin in the project architecture
Annotation mechanism Compile-time annotation processor (APT) that generates plain Java source. No runtime proxies, bytecode generation or agents in core architecture
Frameworks Plain Java only for now —
Modules Single-word names: annotations, core, processor, config, testkit, micrometer, bom, plus internal benchmark, stress, examples architecture
core dependencies None besides JSpecify (see below) architecture
MVP policies @CircuitBreaker, @Retry, @Timeout, @Bulkhead, @RateLimiter, @Fallback features
Performance contract 0 B/op on success paths, no locks on hot paths, enforced by JMH in CI features
Docs Plain Markdown in docs/en and docs/es, served by Docsify. English is the source of truth architecture
annotations module Separate from core architecture
Durations Strings ("250ms", "2s", "PT2S"), validated by the processor features
Gradle conventions build Included build named conventions (keeps the single-word rule) architecture
Processor Mandatory. No runtime proxy fallback. If the generated class is missing, Resilient.of fails at startup with a message saying how to add the processor. Code that cannot be annotated uses the programmatic API in core architecture
Annotation retention CLASS: annotations are not loaded at runtime, so annotations can be a compileOnly dependency. Moving to RUNTIME later is a compatible change if a proxy fallback is ever added architecture
Interface vs implementation annotations With Resilient.of(interface, impl, registry) only the interface's annotations count. In the subclass style (Resilient.create(Impl.class, registry)), which a class enters by declaring at least one annotation itself, the class's annotations win, attribute by attribute: interface < interface method < class < class method (a method overriding a superclass method wins over it). Methods overriding Object's only take annotations written on them architecture
Self-invocation Intercepted in the subclass style: this.other() goes through the generated override, so its policies apply. Not possible in the interface-wrapper style, where calls inside the user's implementation never reach the wrapper; this is documented, and users who need it annotate a non-final class instead architecture
Nullness annotations JSpecify as a real (api) dependency of core: the only exception to "zero dependencies". It is an annotations-only jar of a few KB, and it gives IDEs, Kotlin and NullAway nullness information architecture
Hot reload None. Configuration is immutable once the registry is built; changing it means restarting the application. This keeps every config value in final fields on the hot path features
Coordinates Domain resilience4cata.dev: Maven groupId dev.resilience4cata, root package dev.resilience4cata architecture

Implementation status

Piece Status
@Retry Done end to end: annotation, processor validation and generation, RetryPolicy runtime, registry with defaults and named configs, composed annotations, type-level and method-level merging
@CircuitBreaker Done end to end: count- and time-based windows, lock-free state machine, half-open probes with maxWaitInHalfOpen, slow calls, recordOn/ignoreOn, shared instances by name, nested inside @Retry, and forceOpen/disable/reset through registry.circuitBreaker(id)
@Timeout Done end to end (async: see Async methods). Synchronous methods: the call stays on the caller's thread and one shared watchdog thread interrupts it when it runs late (resolution: timeout / 16, 1–50 ms, never early); the interrupt is always cleared afterwards. Applied per attempt, inside the circuit breaker
@Bulkhead Done end to end: semaphore-style limit with maxConcurrent; with maxWait = "0" a lock-free counter whose rejections only read, otherwise a non-fair Semaphore that parks virtual threads without pinning; shared instances by name; innermost layer, so a slot is held only while the method runs and the wait for one counts toward @Timeout. Async methods with maxWait > 0 wait without blocking: they are queued FIFO, handed a released slot directly (the call starts on a new virtual thread), and rejected after maxWait; sync and async callers share the slots, with approximate fairness between them
@RateLimiter Done end to end: permits per period with GCRA (generic cell rate algorithm) on one long: a burst of up to permits after a quiet period, then one permit every period / permits; no background thread, and a rejection only reads. With maxWait, a call reserves the next permit and sleeps until it is due; an interrupted wait gives the permit back. Shared instances by name, inspection through registry.rateLimiter(id). Layer inside the circuit breaker, so calls rejected by the breaker use no permit
@Fallback Done end to end (async: see Async methods), as the outermost layer, so it also sees rejections (CallNotPermittedException, BulkheadFullException, RateLimitExceededException, CallTimeoutException). Two forms, both checked at compile time: a typed handler class (FallbackHandler<T>, T assignable to the return type, public no-argument constructor, one instance per method) that receives a FallbackContext with the failure, the number of attempts and the arguments; or sibling methods with the original parameters plus an optional exception parameter, the most specific exception type winning. With the interface wrapper, sibling methods must be default, abstract or static interface methods, because the generated class cannot call private ones. applyOn/skipOn filter the failures (skipOn wins). With @Retry, the fallback runs where the retry loop gives up, so it knows the attempts without allocating anything on the success path. A checked exception thrown by a handler is rethrown if the method declares it, otherwise wrapped in UndeclaredThrowableException. Generic methods are supported: sibling methods declare the same type parameters (by position, same bounds) and are called with explicit type arguments; a handler class works only when the result type does not depend on the method's type parameters
@Hedge Done (async only) in 0.4.0: the same call is started again after delay without a result, or at once after a failure in hedgeOn; the first success wins and the other attempts are cancelled through every inner policy (a breaker records nothing, a bulkhead frees the slot). One int of progress per call, changed by compare-and-set, decides the outcome exactly once; delays on the lock-free async timer (or exact on a ManualClock); a hedge budget (on by default) caps the extra load. Exclusive with @Retry on one method; synchronous methods are a compile error for now (features)
@AdaptiveLimit Done in 0.6.0, sync and async: a bulkhead whose limit moves by itself, between minLimit and maxLimit, from the latency of the calls (Netflix concurrency-limits: GRADIENT2, the default, or AIMD), recomputed once per window by the thread that completes a call after the window ends; drops (dropOn: timeouts, full bulkheads by default) back AIMD off; fail-fast only, no Vegas yet (page)
Async methods Done for every policy: a method that returns CompletionStage<T> or CompletableFuture<T> gets the async pipeline, decided from the return type (no extra annotation). An exception thrown before the delegate returns a stage, or a null stage, counts as a failed call. Retries are scheduled, never slept: the first attempt runs on the caller's thread, later ones on new virtual threads. The timeout fails the future with CallTimeoutException and cancels the late stage (Future.cancel); it runs on a lock-free timer (striped inboxes, one lane per duration, cancel by a state CAS) that never fires early and fires at most about two ticks late (tick = shortest async timeout / 16, 1–50 ms), and a stage that is already complete skips the timer. The bulkhead holds its slot until the stage completes. The rate limiter's maxWait reserves the permit and starts the call when it is due, without blocking. Fallbacks may return T or a stage of T. One daemon timer thread for the library; everything it fires runs on a virtual thread. Wildcard stages (CompletionStage<? extends X>, CompletableFuture<?>) are supported: the policies, fallbacks and classifiers work with the upper bound X, and fallbacks may also return a CompletionStage<? extends X>. Limits: no ? super X or custom stage types; cancelling the returned future does not cancel the delegate
classifier on @Retry / @CircuitBreaker Done, sync and async: one Classifier<T> decides for results and exceptions (SUCCESS, FAILURE, IGNORE), checked at compile time, one instance per method. It replaces retryOn/recordOn (combining them is a compile error); abortOn/ignoreOn still win. A FAILURE result is retried and, when attempts run out, returned as is (no @Fallback), or thrown as a ResultRejectedException that goes to @Fallback with onExhaustedResult = THROW. A classifier that throws (the retry's or the breaker's) ends the call at once, with a GaveUp event (CLASSIFIER_FAILED). Methods without a classifier generate the same code as before (features)
Programmatic API (Pipeline) Done: an immutable pipeline built from a ResilienceRegistry for code that cannot be annotated; same policies, order and semantics as generated code, sync and async, with fallback and classifiers, run(...) for calls with no result, asynchronous fallbacks that return a stage, and onExhaustedResult; allocation-free on the sync success path (features)
Events and snapshots Done: typed sealed events (breaker transitions and rejections, retries, timeouts, bulkhead and rate limiter rejections, fallbacks), registered on the registry builder, no cost when nobody listens; registry.snapshot() and snapshot() on the handles return read-only records (features)
Test support (testkit) Done: ManualClock drives retries, breakers, rate limiters, sync and async timeouts, sync and async bulkhead waits and async delays through Builder.ticker(...); seeded jitter with Builder.random(...) (features)
Annotated classes (subclass style) Done end to end, sync and async: a non-final class that declares a resilience annotation gets a generated <Class>$Resilient extends <Class>, created with Resilient.create(Type.class, registry, constructorArgs...) or new Type$Resilient(registry, ...). Only methods with policies are overridden, each ending with super.method(...), so self-invocation (this.other()) is intercepted. Annotations of implemented interfaces and superclasses are merged, attribute by attribute: interface < interface method < class < class method. Final, sealed, abstract, inner and private classes, records, enums, and final methods annotated at method level are compile errors. A class that only implements an annotated interface is not processed: it gets the interface's policies through Resilient.of (features)
External configuration (config module) Done (MVP): properties files, classpath resources, environment variables (R4C_…) and system properties (r4c.…); defaults, named configs, extends, profiles, per-instance overrides (instances.<id>.*, which beat annotation values); a named config defined both in code and externally takes the external values attribute by attribute; strict validation that reports every problem at once with its source and a "did you mean" (features), and verifyInstances(registry) for override ids that match no instance.; locked = true on an annotation keeps its values from per-instance overrides (locking). Not yet: YAML
Gradle incremental-processing registration Done: the processor is registered as isolating (META-INF/gradle/incremental.annotation.processors); each generated class has the wrapped interface as its only originating element, so editing an unrelated file recompiles only that file, and editing a bundle, handler or superinterface reprocesses only the interfaces that use it
JMH benchmarks Retry (one and four services), circuit breaker (count and time windows), timeout, bulkhead and rate limiter (1 and 4 threads), async methods, classifiers and the programmatic Pipeline, vs Resilience4j; full base-vs-new runs in parallel on AWS with infra/bench.sh (benchmarks)
jcstress (stress module) Done: 21 jcstress tests of the lock-free policies (bulkhead counter, rate limiter burst and give-back, circuit breaker transitions and quiet state, timeout/watchdog stamp protocol, breaker transition events published exactly once), mutation-checked (17 of 18 mutants caught; a release-only CAS in the timeout's end-of-call is guarded by a test but not reproduced on this Mac). Run with ./gradlew :stress:jcstress -Pjcstress="-m quick"; ./gradlew build only compiles them
Error codes Done: every processor diagnostic (R4C-APT/ATR/CMP/ASY/CLS/FB/SUB), runtime wrapping and pipeline error (R4C-RT), registry and policy-configuration error (R4C-REG: invalid values, unknown or duplicate named configs, conflicting shared instances, unknown instance ids, with "Did you mean …?") and config problem (R4C-CFG) has a stable code and a section in the error reference, with a wrong example and the fix; the build compiles the processor examples
Publishing Maven Central setup ready (r4c.publish, bom, centralBundle); 0.1.0 to 0.10.0 released (release.yml) — see Releasing
User guide Done: getting started, every snippet compiled and run by examples/.../GuideTest.java; root README.md for GitHub
CI (GitHub Actions) Done, on GitHub: build on JDK 21 and 25 on every push and PR (./gradlew build -PtestJdk=N), alloc-gate on every PR (./gradlew :benchmark:allocationCheck: 40 benchmarks against their B/op budgets in benchmark/allocation-budgets.properties), stress (jcstress quick) after every push to main and nightly, sonar (SonarCloud with coverage), actionlint on the workflow files; release (manual, on a v<version> tag) runs the whole build first; Dependabot for the version catalog and the actions, which are pinned to commit SHAs

Open questions

None open. New questions are added here.

Research documents

# Document Topic
01 Landscape Resilience4j in depth, competitors, opportunities
02 Annotation mechanisms APT vs proxies vs ByteBuddy vs agents, with our own JMH run
03 Performance internals Algorithms and lock-free design per policy, benchmark methodology
04 API and configuration Feature tiers, annotations, configuration model
05 Project architecture Modules, Gradle, JPMS, tooling, docs, versioning

About the evidence. Each research document tags its claims: verified in source code or documentation, measured, or inferred. Document 04 was written from prior knowledge, without checking sources online. Its references, especially SmallRye annotation names and MicroProfile execution order, must be checked before they are relied on. Performance claims about resilience4cata are valid only when measured; the measurements are in benchmarks.