HealthCheck, HealthCheckRegistry, and aggregation logic for combining multiple dependency checks into one overall status, mapped directly onto Spring Boot Actuator's own HealthIndicator design.
Published September 23, 2026
interface HealthCheck { HealthStatus check(); String name(); }
enum HealthStatus { UP, DOWN, DEGRADED }
class HealthCheckRegistry {
List<HealthCheck> checks = new CopyOnWriteArrayList<>();
void register(HealthCheck check) { checks.add(check); }
}
HealthCheck as an interface (not a fixed enum of known checks) is what makes the registry OPEN for new dependency checks without ever modifying the registry itself — adding a check for a new dependency (a message broker connection, an external API) is purely additive, the same Open/Closed pattern seen throughout this Machine Coding chapter.
class HealthAggregator {
HealthStatus aggregate(List<HealthCheck> checks) {
List<HealthStatus> results = checks.stream().map(HealthCheck::check).toList();
if (results.stream().anyMatch(s -> s == HealthStatus.DOWN)) return HealthStatus.DOWN;
if (results.stream().anyMatch(s -> s == HealthStatus.DEGRADED)) return HealthStatus.DEGRADED;
return HealthStatus.UP;
}
}
The aggregation LOGIC itself is a real design decision, not a trivial detail: should ANY single DOWN dependency make the WHOLE service report DOWN, or only checks marked as "essential"? This directly mirrors Health Checks' essential-vs-non-essential distinction — a more sophisticated version would tag each HealthCheck with a criticality level, and only ESSENTIAL checks failing should drag the overall status to DOWN, while non-essential checks failing might only produce DEGRADED (still serving traffic, with reduced functionality).
// Actuator's actual interface — structurally identical to the HealthCheck above
interface HealthIndicator { Health health(); }
This exercise's HealthCheck/HealthCheckRegistry/aggregation design ISN'T a hypothetical toy — it's essentially a from-scratch reimplementation of what Spring Boot Actuator already provides via HealthIndicator (covered concretely in Health Checks). Building it yourself here is what makes Actuator's own design legible — recognizing the SAME Open/Closed extension point, the SAME aggregation-across-multiple-checks pattern, in a framework you already use daily.
The simple aggregator above runs checks one after another and trusts each one to return promptly. A real aggregator has to survive exactly the situation it exists to report on: a dependency that hangs. Here's a fuller version that runs checks in parallel, gives each a timeout, and weighs results by criticality:
enum Criticality { ESSENTIAL, NON_ESSENTIAL }
record CheckResult(String name, HealthStatus status, Duration took, String detail) {}
interface HealthCheck {
String name();
Criticality criticality();
HealthStatus check() throws Exception;
}
class HealthAggregator {
private final List<HealthCheck> checks;
private final ExecutorService executor;
private final Duration perCheckTimeout;
HealthAggregator(List<HealthCheck> checks, ExecutorService executor, Duration perCheckTimeout) {
this.checks = checks; this.executor = executor; this.perCheckTimeout = perCheckTimeout;
}
HealthReport run() {
Map<HealthCheck, Future<HealthStatus>> futures = new LinkedHashMap<>();
for (HealthCheck c : checks) futures.put(c, executor.submit(c::check)); // all start at once
List<CheckResult> results = new ArrayList<>();
for (var e : futures.entrySet()) {
HealthCheck c = e.getKey();
long start = System.nanoTime();
HealthStatus status; String detail = "";
try {
status = e.getValue().get(perCheckTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
e.getValue().cancel(true);
status = HealthStatus.DOWN; detail = "timed out";
} catch (ExecutionException ex) {
status = HealthStatus.DOWN; detail = ex.getCause().getClass().getSimpleName();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
status = HealthStatus.DOWN; detail = "interrupted";
}
results.add(new CheckResult(c.name(), status, Duration.ofNanos(System.nanoTime() - start), detail));
}
return new HealthReport(overall(results), results);
}
private HealthStatus overall(List<CheckResult> results) {
boolean essentialDown = false, anyProblem = false;
for (int i = 0; i < results.size(); i++) {
HealthStatus s = results.get(i).status();
if (s != HealthStatus.UP) anyProblem = true;
if (s == HealthStatus.DOWN && checks.get(i).criticality() == Criticality.ESSENTIAL) essentialDown = true;
}
return essentialDown ? HealthStatus.DOWN : anyProblem ? HealthStatus.DEGRADED : HealthStatus.UP;
}
}
record HealthReport(HealthStatus overall, List<CheckResult> checks) {}
What this adds, and why:
DOWN: timed out, instead of the health endpoint itself hanging. Since the waits are sequential, the worst case is still bounded by the number of checks × the timeout. A stricter version computes one overall deadline and gives each get the remaining time.Load balancers and orchestrators may poll every few seconds from many places. Wrap each check in a small cache so it runs at most once per interval:
class CachedHealthCheck implements HealthCheck {
private final HealthCheck delegate; private final Duration ttl; private final Clock clock;
private volatile HealthStatus last = HealthStatus.UP; private volatile Instant expires = Instant.MIN;
CachedHealthCheck(HealthCheck delegate, Duration ttl, Clock clock) { this.delegate = delegate; this.ttl = ttl; this.clock = clock; }
public String name() { return delegate.name(); }
public Criticality criticality() { return delegate.criticality(); }
public synchronized HealthStatus check() throws Exception {
if (clock.instant().isBefore(expires)) return last;
last = delegate.check();
expires = clock.instant().plus(ttl);
return last;
}
}
This is the Decorator pattern: caching is added around any check without changing it. The same approach can add logging or metrics (each check's latency is itself a useful signal).
Q: How would you add per-check timeout handling, so one slow HealthCheck doesn't block the whole aggregation? A: Wrap each individual check's execution with a timeout (Timeout Strategy) — running checks in parallel (each with its own bounded timeout) rather than sequentially, treating a check that times out as DOWN (or DEGRADED) rather than letting it stall the entire aggregation indefinitely.
Q: Should the aggregator cache results rather than re-running every check on every request? A: Often yes, for exactly the reason discussed in Health Checks' follow-up questions — running an expensive check (a real database query) on every single health-check poll adds avoidable load; caching results for a short interval and only re-running periodically is a common, practical optimization.
Q: How does 'criticality level' per check actually get decided? A: This is a genuine per-dependency design decision, not something derivable automatically — it requires explicitly reasoning about each dependency's role (per Health Checks' essential-vs-non-essential framing), and different services legitimately reach different answers for what looks like a superficially similar dependency.
Q: Does this design need to distinguish liveness-check aggregation from readiness-check aggregation? A: Yes — mirroring Health Checks' liveness/readiness split, a real implementation would maintain TWO separate check sets/aggregations (a minimal liveness set with no external dependencies, a broader readiness set including real dependency checks), not one single aggregated status serving both purposes.
Q: Why run health checks on a separate executor instead of the request thread pool? A: If a dependency hangs, health-check threads block. Using the same pool as real requests would let a slow dependency starve user traffic through the health endpoint. A small, dedicated pool isolates it (a bulkhead).
Q: Should the health endpoint return HTTP 200 for DEGRADED? A: Usually yes for readiness. The service can still serve its core traffic, so the load balancer should keep routing to it, while the response body and metrics report the degradation. Return 503 only for DOWN, so the instance is taken out of rotation.
Q: How do you stop a flapping dependency from flipping the overall status every few seconds? A: Add hysteresis: require several consecutive failures before reporting DOWN, and several successes before returning to UP. That can be another decorator around the check, keeping the aggregator itself simple.