Lesson 19 / 19
Resilience Patterns
Circuit breaking and retry are added by an annotation; the call site never changes at all. In a manufactured failure, how many real attempts are made and at which threshold the circuit opens are measured as numbers. The limiting measurement shows that the annotation applies only to a call passing through the proxy, and a call the same class makes from within itself stays unprotected — the same boundary web-and-data-layer/06 measured, with a different outcome. This lesson closes the course and the M08 curriculum.
Published Updated
In this lesson
- Shows that circuit breaker and retry annotations are added without changing the call site
- Measures, by counting, the real number of attempts in a manufactured failure and the threshold at which the circuit opens
- Shows that a call the same class makes from within itself is not protected by the resilience annotations
Contents
The previous lesson measured whether a call reached its target. This lesson treats that failure not as a single event but as a repeating pattern: when a target fails over and over, what should the calling side do — give up immediately, retry a few times, or cut off retrying entirely past some point? The concepts of circuit breaker and retry patterns were built in the Resilience and Reliability course and are not repeated here; this lesson measures only how these patterns are added by an annotation and what they leave in the source. No real duration is written — what is measured is the attempt count and the threshold, both plain integers. This is the closing lesson of the course and of the M08 curriculum.
Resilience Added by Annotation: Attempt Count and Threshold
FragileService is a target manufactured to fail every time it is called; it carries two
annotations — @Retry(count = 3) and @CircuitBreaker(threshold = 5). The party that reads and
applies these two marks is not FragileService itself, but the ResilienceProxy wrapping it.
// Retry.java — annotation type stating how many times a target should be retried (helper source, no main)
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Retry { int count(); }
// CircuitBreaker.java — annotation type stating after how many consecutive failures the circuit should open (helper source, no main)
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface CircuitBreaker { int threshold(); }
// OutsideService.java — interface for the called target (helper source, no main)
interface OutsideService { String call(); }
// FragileService.java — target manufactured to fail every time it is called (helper source, no main)
@Retry(count = 3) @CircuitBreaker(threshold = 5)
class FragileService implements OutsideService {
int callCounter = 0;
public String call() {
callCounter++;
throw new RuntimeException("target failed");
}
String otherOperation() {
// calls directly from within itself, never seeing the proxy at all
return call();
}
}
// ResilienceProxy.java — proxy that reads the annotations and applies the retry and circuit breaker behavior (helper source, no main)
class ResilienceProxy implements OutsideService {
final OutsideService target;
final int retryCount;
final int threshold;
int consecutiveFailures = 0;
boolean circuitOpen = false;
int realCallCounter = 0;
ResilienceProxy(OutsideService target) {
this.target = target;
Class<?> type = target.getClass();
Retry r = type.getAnnotation(Retry.class);
CircuitBreaker cb = type.getAnnotation(CircuitBreaker.class);
this.retryCount = r != null ? r.count() : 1;
this.threshold = cb != null ? cb.threshold() : Integer.MAX_VALUE;
}
public String call() {
if (circuitOpen) {
throw new IllegalStateException("circuit open, target was never reached at all");
}
RuntimeException lastError = null;
for (int i = 1; i <= retryCount; i++) {
realCallCounter++;
try {
String result = target.call();
consecutiveFailures = 0;
return result;
} catch (RuntimeException e) {
lastError = e;
consecutiveFailures++;
if (consecutiveFailures >= threshold) { circuitOpen = true; break; }
}
}
throw lastError;
}
}
// Measurement.java — result-printing helper (helper source, no main)
class Measurement {
static void show(String label, Object value) {
System.out.printf("%-52s: %s%n", label, value);
}
}
The body of FragileService.call contains neither an attempt counter nor a threshold
comparison; both live on the ResilienceProxy side. The same proxy.call() line is called
three times in a row.
// AttemptsAndThreshold.java — the same call line reveals, in a manufactured continuous failure, the real attempt count and the threshold at which the circuit opens
public class AttemptsAndThreshold {
public static void main(String[] args) {
ResilienceProxy proxy = new ResilienceProxy(new FragileService());
attempt(proxy, "call 1");
attempt(proxy, "call 2");
attempt(proxy, "call 3");
Measurement.show("total number of real attempts (reaching the target)", proxy.realCallCounter);
Measurement.show("is the circuit open", proxy.circuitOpen);
}
static void attempt(ResilienceProxy proxy, String label) {
try {
proxy.call();
Measurement.show(label, "successful");
} catch (Exception e) {
Measurement.show(label, e.getMessage());
}
}
}
call 1 : target failed call 2 : target failed call 3 : circuit open, target was never reached at all total number of real attempts (reaching the target) : 5 is the circuit open : true
ST32. Circuit breaker and retry are added by an annotation; the proxy.call() call site
never changes at all across the three attempt calls, the only thing determining the behavior
is the two marks on FragileService. ST33. In a manufactured continuous failure, the real
attempt count and the threshold at which the circuit opens are both numbers: the first call
makes three attempts (count = 3), and because it has not yet reached the threshold (threshold = 5), the circuit stays closed; the second call makes two more attempts, and the moment
consecutive failures reach five, the circuit opens — the total real attempt count is exactly
5. ST34. The third call, after the circuit has opened, is rejected without ever reaching
the target; realCallCounter is still 5 after the third call, it does not increase.
The Limiting Measurement: A Call Not Passing Through the Proxy Stays Unprotected
FragileService has a second method that calls call from within itself: otherOperation.
This call never passes through ResilienceProxy at all — the object calls its own method
directly.
// UnprotectedCall.java — otherOperation() calls call() directly from within itself, the proxy is never involved, and no retry happens at all
public class UnprotectedCall {
public static void main(String[] args) {
FragileService inner = new FragileService();
try {
inner.otherOperation();
} catch (RuntimeException e) {
Measurement.show("result of the otherOperation() call", e.getMessage());
}
Measurement.show("direct call made from within otherOperation, how many attempts were made", inner.callCounter);
}
}
result of the otherOperation() call : target failed direct call made from within otherOperation, how many attempts were made: 1
inner.callCounter increases only once, not three times. ST35. The annotation applies only
to a call passing through the proxy; a call the same class makes from within itself stays
unprotected, because the party reading the @Retry and @CircuitBreaker marks is
ResilienceProxy, and otherOperation never calls it at all. This is the same boundary
measured by web-and-data-layer/06 — a component calling itself skipped the proxy there too —
appearing here with a different outcome: there, a wrapped transaction boundary never opened at
all; here, a wrapped resilience pattern never applies at all. Someone reading the source might
assume otherOperation carries the same protection, because it calls the same class’s same
call method; there is no mark in the source at all to disprove that assumption.
Summary
- Circuit breaker and retry are added by an annotation; the call site never changes at all.
- In a manufactured continuous failure, the real attempt count and the threshold at which the circuit opens are measured as numbers, neither of them a duration.
- Calls made after the circuit opens are rejected without ever reaching the target.
- The annotation applies only to a call passing through the proxy; a call the same class makes from within itself stays unprotected.
- This boundary is the same as the self-calling boundary measured by web-and-data-layer/06, with a different outcome.
Course Wrap-Up
The course opened with a single question: most of the code in a program using an application framework is never called by anyone at all, because the expression that constructs an object, the assignment that supplies a dependency, the line that calls a setup routine are, in their place, only annotations. Nineteen lessons asked this question across three topics — core concepts, the web and data layer, and security and distributed setup — and each time returned to the same triad: did a call give rise to this behavior in the source, an annotation, or neither.
| Lesson | Behavior measured | Source of the behavior | Limiting measurement |
|---|---|---|---|
| Inversion of Control | 3 of 12 behaviors are call, 6 are annotation, 3 are default | call · annotation · default | An object left outside the container receives none of the annotations |
| Dependency Injection | The same Service is built both through the container and by hand, and both work |
annotation (supply) · call (manual supply) | An unbound interface error drops not while the container is constructed, but at the first request |
| Component Scopes and Lifecycle | Singleton scope is the default, per-request is an explicit annotation | default (singleton) · annotation (per-request) | An unrecognized scope name raises no error; the component silently stops being a singleton |
| Configuration Sources | 1 of 4 settings comes from an external source, 2 from a file, 1 from an embedded default | the container’s code order (precedence chain) | A spelling difference falls through to the embedded default with no error at all |
| Automatic Configuration | The number of behaviors born without an annotation drops from 3 to 2 | default (conditional component selection) | Adding one component silently disables another |
| Aspect-Oriented Programming | An advice annotation adds a counter without touching the call site | annotation (through a proxy) | A mark placed on an interface-less class stays ineffective with no error |
| Embedded Server and Request Processing Model | 2 of 6 links are default, 2 are annotation, 2 are call | default · annotation · call | In manually called classes, the precedence in the annotation is never read at all |
| Controllers and Routing | All 4 of 4 endpoints are born from an annotation | annotation (@Endpoint) |
An unmatched path silently returns a default response; on a colliding path the winner depends on scan order |
| Validation and Error Handling | 3 of 4 fields get their rule from an annotation, 1 carries no rule at all | annotation (rule) · default (envelope) | Invalid data given to a handler outside the container is never validated at all, and returns “ok” |
| Data Access Abstraction | The same compiled caller runs with two implementations, without being recompiled | the caller refers only to the interface | ArrayStore throws at the capacity limit, InMemoryStore does not on the same call |
| Object and Table Binding | 2 of 4 fields load at load time, 2 load on first access | annotation (@Column / @LazyColumn) |
An unaccessed lazy field on an object detached from the container silently returns empty |
| Transaction Management | The boundary opens on every call coming from outside, an internal call is never counted at all | annotation (@Transactional, through a proxy) |
A component calling itself skips the proxy, and the boundary never opens at all |
| Authentication and Authorization | The authorization decision comes from an annotation (panel) or from a default (status) | annotation · default · call (manual) | The same source produces two different security postures under two different defaults |
| Token-Based Authentication | Claim count is 1/3; validation gives the same result by annotation and by call | annotation (@RequiredClaim) · call (manual) |
A revoked token stays valid until it expires, because the revocation list is never looked at |
| Layer Tests | The slice test builds 2 components, the full context test builds 4 | the number of components the container builds | Even when a component left outside the slice never has its setup hook run, the test still passes |
| Health and Metrics Endpoints | 2 endpoints come from the default, 1 from an annotation; 1 of 3 fields is given out | default (2 endpoints) · annotation (1 endpoint) | A mistakenly marked field is given out too, with no error at all |
| Distributed Configuration | A singleton component sees the old value, a per-request component sees the current value | default (scope) · call (refresh()) |
Two components at the same moment give two different, both error-free, answers to the same value |
| Service Discovery and Gateway | The target comes from a direct reference, from a registry, or from the container’s default | call · registry · default | A dead target never removed from the registry is found in a lookup; the defect drops at call time |
| Resilience Patterns | The real attempt count is 5, the circuit opens at threshold 5 | annotation (@Retry, @CircuitBreaker) |
A self-call that never passes through the proxy stays unprotected |
The table’s third column confirms a single pattern: in five of the nineteen lessons the behavior comes entirely from an annotation (advice wrapping, four endpoints, column binding, the transaction boundary, the resilience patterns), in three it comes entirely or mostly from a default, and in the rest all three appear together. The limiting measurement column carries another pattern: in nearly all nineteen lessons the defect is silent — only two raise an error at all (the unbound interface, the capacity limit), the rest either return an unexpected value or carry on as if nothing changed. Someone reading the source top to bottom cannot see most of the behavior these nineteen lessons show, and cannot see that what they cannot see is broken either.
The M08 Java curriculum’s six courses asked six separate questions, and none took the place of another. Java Fundamentals counted which step, unwritten in the source, the compiler adds to the class file. Object-Oriented Java separated which type gives a decision at run time. The Standard Library and Streams measured who gives a guarantee — the caller or the library. JVM, Concurrency and Performance tracked which thread carries out a step. Build, Testing and the Ecosystem counted which type must be present on which class path and showed the difference between that and the run. This course, The Enterprise Application Framework, asked the sixth and final question: what gives rise to a behavior in the source — a call, an annotation, or neither. All six questions look at the same source text, but each searches for a different gap; none is a generalization of another, and none claims that reading the source top to bottom is enough.
M08 closes here. The source text tells only part of what a Java program does — the step it adds, the type that gives the decision, the party that gives the guarantee, the thread that carries it out, the required class path, the source of the behavior; all six are at least partly hidden from the eyes of someone reading the source, and all six become visible only by being run and counted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.