How to write custom HealthIndicator and CompositeHealthContributor implementations in Spring Boot Actuator — with real examples for external service checks and IAM-aware AWS dependencies.
Spring Boot Actuator ships a /actuator/health endpoint out of the box, and it works well for database connections, Kafka brokers, and Redis. But in a production system you almost always have dependencies that Actuator knows nothing about: a third-party REST API you call, an exchange connection that has its own notion of “ready”, or a custom warm-up state that determines whether your service should receive traffic.
Custom health indicators let you plug those checks directly into the same endpoint that your load balancer, Kubernetes liveness probe, and on-call dashboard already query. Everything in one place, with a consistent response model.
The interface is a single method:
@FunctionalInterface
public interface HealthIndicator {
Health health();
}
Health carries a Status (UP, DOWN, OUT_OF_SERVICE, UNKNOWN) and an arbitrary details map for diagnostic context. Spring picks up any bean implementing HealthIndicator and includes it in the aggregated response automatically.
Suppose your service calls the Betfair Exchange API and you want the health endpoint to reflect whether that connection is live:
@Component
public class BetfairConnectionHealthIndicator implements HealthIndicator {
private final BetfairSessionManager sessionManager;
public BetfairConnectionHealthIndicator(BetfairSessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public Health health() {
if (!sessionManager.hasValidSession()) {
return Health.down()
.withDetail("reason", "No active Betfair session token")
.withDetail("lastAttempt", sessionManager.getLastAttemptTime())
.build();
}
return Health.up()
.withDetail("sessionAge", sessionManager.getSessionAgeSeconds() + "s")
.withDetail("endpoint", sessionManager.getEndpoint())
.build();
}
}
Spring registers this under the key betfairConnection. A GET /actuator/health now includes:
{
"components": {
"betfairConnection": {
"status": "UP",
"details": {
"sessionAge": "142s",
"endpoint": "https://api.betfair.com/exchange/betting"
}
}
}
}
Health checks run synchronously during the HTTP request. A slow external call will stall the entire health response. Wrap anything with network I/O in a bounded call:
@Override
public Health health() {
try {
boolean reachable = CompletableFuture
.supplyAsync(this::pingExternalService)
.get(2, TimeUnit.SECONDS);
return reachable
? Health.up().build()
: Health.down().withDetail("reason", "ping returned false").build();
} catch (TimeoutException e) {
return Health.unknown()
.withDetail("reason", "health check timed out after 2s")
.build();
} catch (Exception e) {
return Health.down()
.withException(e)
.build();
}
}
Use UNKNOWN rather than DOWN for timeouts — a timeout means you don’t know the state, which is different from knowing the service is unhealthy.
When you have several related checks — say, three downstream services that all need to be UP before your service is considered ready — group them under a single named composite:
@Component("tradingDependencies")
public class TradingDependenciesHealthContributor implements CompositeHealthContributor {
private final Map<String, HealthContributor> contributors;
public TradingDependenciesHealthContributor(
BetfairConnectionHealthIndicator betfair,
MarketDataFeedHealthIndicator feed,
OrderServiceHealthIndicator orders) {
this.contributors = Map.of(
"betfair", betfair,
"marketFeed", feed,
"orderService", orders
);
}
@Override
public HealthContributor getContributor(String name) {
return contributors.get(name);
}
@Override
public Iterator<NamedContributor<HealthContributor>> iterator() {
return contributors.entrySet().stream()
.map(e -> NamedContributor.of(e.getKey(), e.getValue()))
.iterator();
}
}
The response nests under tradingDependencies:
{
"components": {
"tradingDependencies": {
"status": "DOWN",
"components": {
"betfair": { "status": "UP" },
"marketFeed": { "status": "DOWN", "details": { "reason": "no heartbeat in 30s" } },
"orderService": { "status": "UP" }
}
}
}
}
The composite rolls up to DOWN because one child is DOWN. This is the default SimpleStatusAggregator behaviour — override it if you need different roll-up logic.
Kubernetes distinguishes liveness (should I restart this pod?) from readiness (should I send traffic here?). Spring Boot models this natively via health groups in application.yml:
management:
endpoint:
health:
group:
liveness:
include: livenessState
readiness:
include: readinessState, db, kafka, betfairConnection
health:
show-details: always
Kubernetes then probes two endpoints:
/actuator/health/liveness — fails only if the JVM is in an unrecoverable state/actuator/health/readiness — fails if any dependency needed to serve traffic is unavailableThis prevents Kubernetes from killing pods that are merely waiting for a dependency to recover, while still routing traffic away from them until they are ready.
By default, only health and info are exposed over HTTP. Exposing all endpoints is fine internally but should be restricted in production:
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
show-details: when-authorized
server:
port: 8081 # management on a separate port, not exposed via ALB
A dedicated management port lets you restrict the ALB listener to port 8080 and only allow internal traffic on 8081 — health checks reach the pod directly, users never can.
Custom health indicators are a small addition with a large operational payoff. Instead of debugging why traffic is being dropped after a deployment, your readiness probe tells you immediately which dependency is unhealthy and why. Instead of checking three separate dashboards, your on-call engineer opens one URL and sees the entire dependency tree.
The investment is a single class per external dependency. The return is faster incident resolution every time something goes wrong.
If you’re building production Spring Boot services on AWS, get in touch — I can help with the full observability stack from health checks through to Grafana dashboards.