Hire Me
← All Writing Spring Boot

Custom Health Indicators with Spring Boot Actuator

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 HealthIndicator contract

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.

A minimal example

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"
      }
    }
  }
}

Adding timeout protection

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.

Grouping with CompositeHealthContributor

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.

Health groups for liveness vs readiness

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:

This 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.

Exposing the endpoint securely

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.

The full picture

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.