Skip to content

Custom Health & Metrics Interview Questions & Answers

15 questions Updated 2026-06-26 Share:

Custom Spring Boot health and metrics — implementing HealthIndicator, the Health/Status model and aggregation, health groups for Kubernetes liveness/readiness probes, Micrometer's MeterRegistry, Counters/Gauges/Timers, low-cardinality tags, and exporting to Prometheus.

Read the in-depth guideCustom Health Indicators and Micrometer Metrics in Spring Boot(opens in new tab)
15 of 15

A HealthIndicator is a bean that reports the health of one piece of your system to the /actuator/health endpoint. You implement the interface, run a quick check, and return a Health object describing the result.

@Component                              // name "queue" derived from the bean class
public class QueueHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        if (queue.isReachable()) {
            return Health.up()              // contributes UP
                .withDetail("depth", queue.size())
                .build();
        }
        return Health.down()                // contributes DOWN
            .withDetail("error", "unreachable")
            .build();
    }
}

Spring auto-discovers every HealthIndicator bean and aggregates them into the overall health response. The bean name (minus the HealthIndicator suffix) becomes the key under components.

Rule of thumb: Use a HealthIndicator to surface a dependency's liveness (a queue, an external API, a cache) — keep the check fast so it doesn't slow the endpoint.

A Status is a string-backed code attached to every Health object. Spring ships four well-known statuses, ordered by severity:

Status.UP              // working normally
Status.OUT_OF_SERVICE  // up but deliberately taken out of rotation
Status.DOWN            // a failure
Status.UNKNOWN         // status couldn't be determined (the default)

You build them through the Health factory methods — Health.up(), Health.down(), Health.outOfService(), Health.unknown() — or pass a custom one to Health.status(...). Each status maps to an HTTP code at the endpoint: UP/UNKNOWN → 200, DOWN/OUT_OF_SERVICE → 503 by default.

Rule of thumb: Reach for OUT_OF_SERVICE when an instance is healthy but should stop receiving traffic (draining), and DOWN for genuine failures.

The overall status is computed by a StatusAggregator that takes the worst status among all contributing indicators. Spring's default order, from worst to best, is DOWN, OUT_OF_SERVICE, UP, UNKNOWN, so a single DOWN component pulls the aggregate down.

# /actuator/health response (details enabled)
status: DOWN              # ← worst of the components below
components:
  db:    { status: UP }
  redis: { status: DOWN } # this one drags the whole result to DOWN
  disk:  { status: UP }

You can re-order severity via management.endpoint.health.status.order if you add custom statuses. The aggregate status is what a load balancer or Kubernetes probe actually keys off — so one failing dependency can pull a whole instance out of rotation.

Rule of thumb: Remember the aggregate is the worst component — scope indicators carefully so a non-critical dependency doesn't mark the whole app DOWN.

Boot auto-configures indicators for the infrastructure it detects on the classpath — no code required. Common ones include:

db          DataSourceHealthIndicator   – runs a validation query
diskSpace   DiskSpaceHealthIndicator    – free space vs a threshold
ping        PingHealthIndicator         – always UP (liveness sanity check)
redis       RedisHealthIndicator        – PING to Redis
mongo, cassandra, elasticsearch, rabbit, kafka, mail, ...

Each activates only when the relevant auto-configuration is present (e.g. db when a DataSource bean exists). You can disable any of them individually:

management:
  health:
    diskspace:
      enabled: false      # turn off a built-in indicator

Rule of thumb: Lean on the built-in indicators for standard infrastructure; only write a custom one for dependencies Boot doesn't already know about.

A health group bundles a subset of indicators under its own endpoint, so different consumers get a tailored view. You define groups with management.endpoint.health.group.<name>:

management:
  endpoint:
    health:
      group:
        liveness:
          include: livenessState          # is the app alive?
        readiness:
          include: readinessState, db, redis   # ready for traffic?

Each group is served at /actuator/health/<name> — here /actuator/health/liveness and /actuator/health/readiness. A group has its own aggregated status, computed only from its members, so a DOWN indicator outside the group won't affect it.

Rule of thumb: Use groups to separate "is it alive" from "is it ready for traffic" — the two questions need different indicators and different remediation.

When Boot detects Kubernetes (or you set management.endpoint.health.probes.enabled=true), it exposes livenessState and readinessState indicators and the liveness/readiness groups at /actuator/health/liveness and /actuator/health/readiness. Point each probe at the matching group:

# Kubernetes Deployment
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }

The semantics differ sharply: a failed liveness probe makes Kubernetes restart the pod (the app is broken beyond recovery), while a failed readiness probe just stops routing traffic to it (temporarily busy/starting). Mixing them up — e.g. putting a flaky external dependency in liveness — causes needless restart loops.

Rule of thumb: Put only internal, fatal state in liveness; put dependencies and warm-up in readiness so a transient outage drains traffic instead of restarting the pod.

The mapping from a Status to an HTTP code is handled by a HttpCodeStatusMapper. The default maps DOWN and OUT_OF_SERVICE to 503 and everything else to 200. You can override it via configuration or a custom bean:

management:
  endpoint:
    health:
      status:
        http-mapping:
          down: 503              # default
          out-of-service: 503
          fatal: 500             # a custom status you defined
// Or programmatically for full control:
@Bean
HttpCodeStatusMapper customMapper() {
    return status -> status.equals(Status.DOWN.getCode()) ? 503 : 200;
}

This matters because probes and load balancers usually react to the HTTP code, not the JSON body — a misconfigured mapping (e.g. returning 200 for DOWN) means a sick instance keeps getting traffic.

Rule of thumb: Make sure failing statuses map to a non-200 code so load balancers and probes actually route around the unhealthy instance.

In a WebFlux app you implement ReactiveHealthIndicator, returning a Mono<Health> so the check runs non-blocking on the event loop instead of tying up a thread:

@Component
public class ApiHealthIndicator implements ReactiveHealthIndicator {
    private final WebClient client;

    @Override
    public Mono<Health> health() {
        return client.get().uri("/ping").retrieve().toBodilessEntity()
            .map(r -> Health.up().build())          // reachable
            .onErrorResume(ex -> Mono.just(           // failure → DOWN, never throw
                Health.down(ex).build()));
    }
}

Spring also adapts a plain blocking HealthIndicator by running it on a bounded elastic scheduler, but a native ReactiveHealthIndicator avoids that hop. The key is to handle errors with onErrorResume so a failed call becomes a DOWN rather than propagating an exception.

Rule of thumb: In WebFlux, prefer ReactiveHealthIndicator + a non-blocking client, and always convert errors into a DOWN health rather than letting the Mono fail.

Micrometer is a vendor-neutral metrics facade — think "SLF4J for metrics". Your code records measurements against Micrometer's API, and a registry implementation forwards them to whatever monitoring backend you choose (Prometheus, Datadog, CloudWatch, New Relic) without changing application code.

// Your code talks to Micrometer's API, not to Prometheus directly:
registry.counter("orders.placed").increment();
// Swapping monitoring systems = swapping the registry dependency, not the code.

Spring Boot Actuator auto-configures a MeterRegistry and instruments large parts of the framework for you — HTTP requests, the JVM, data sources, caches — so a lot of useful metrics appear with zero code. Adding a backend is just adding its micrometer-registry-* starter.

Rule of thumb: Treat Micrometer as a portability layer — instrument against its API once and stay free to change monitoring vendors later.

Micrometer offers a handful of meter types, each for a different shape of measurement:

Counter            counter   = registry.counter("orders.placed");        // monotonic count, only goes up
registry.gauge("queue.size", queue, Queue::size);                        // instantaneous value, up or down
Timer              timer     = registry.timer("order.process.time");      // count + total time + max
DistributionSummary summary  = registry.summary("order.amount");          // distribution of a value (not time)
  • Counter — a value that only increments (requests served, errors).
  • Gauge — a current value that can rise and fall (queue depth, active connections); it samples a live object rather than being set.
  • Timer — measures duration and frequency of short events (latency).
  • DistributionSummary — like a Timer but for non-time distributions (payload sizes, amounts).

Rule of thumb: Pick by question: counts → Counter, "how many right now" → Gauge, latency → Timer, value distribution → DistributionSummary.

Inject the auto-configured MeterRegistry and register a meter — usually once in the constructor so you increment a cached reference rather than looking it up on every call:

@Service
public class OrderService {
    private final Counter placed;
    private final Timer processTimer;

    public OrderService(MeterRegistry registry) {
        this.placed = registry.counter("orders.placed", "channel", "web");
        this.processTimer = registry.timer("orders.process.time");
    }

    public void place(Order o) {
        processTimer.record(() -> repository.save(o));   // times the lambda
        placed.increment();                              // bumps the counter
    }
}

The metric name uses a dotted convention (orders.placed); Micrometer translates it to each backend's idiom (Prometheus turns dots into underscores). The trailing pairs are tags.

Rule of thumb: Register meters once (constructor or @PostConstruct) and reuse the reference; re-creating a meter on every call is wasteful.

@Timed and @Counted let you add metrics declaratively via AOP instead of hand-writing registry.timer(...). They require the TimedAspect / CountedAspect beans (and spring-aop):

@Bean TimedAspect timedAspect(MeterRegistry r) { return new TimedAspect(r); }

@Service
public class ReportService {
    @Timed(value = "report.generate", percentiles = {0.95, 0.99})  // records a Timer
    @Counted("report.requests")                                    // records a Counter
    public Report generate(String id) { /* ... */ }
}

The aspect wraps the method, timing it and counting invocations (including failures, tagged with the exception). It's the convenient option for coarse, method-level instrumentation; for fine-grained or conditional metrics, the explicit MeterRegistry API gives you more control.

Rule of thumb: Use @Timed/@Counted for quick method-level metrics; drop to the MeterRegistry API when you need custom tags or to measure something other than a whole method.

Tags (also called dimensions/labels) attach key-value attributes to a meter so you can slice it — e.g. counting requests by uri and status. Crucially, every unique combination of tag values creates a separate time series in the backend:

// GOOD: low cardinality – a bounded set of values
registry.counter("http.requests", "status", "200", "method", "GET").increment();

// BAD: unbounded cardinality – userId/orderId explode the series count
registry.counter("http.requests", "userId", userId).increment();   // ✗ millions of series

Putting a high-cardinality value (user IDs, order IDs, raw URLs with path params, timestamps) into a tag causes a cardinality explosion: memory blows up in your app and the monitoring backend, queries slow to a crawl, and costs spike. Spring's http.server.requests deliberately tags the templated path (/orders/{id}) rather than the actual URL for exactly this reason.

Rule of thumb: Keep tag values from a small, bounded set — never put unbounded identifiers (user/order/request IDs) into a tag.

Add the Prometheus registry starter and expose the endpoint; Boot wires up a PrometheusMeterRegistry and serves metrics in Prometheus' text format at /actuator/prometheus:

# build.gradle: implementation 'io.micrometer:micrometer-registry-prometheus'
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus      # expose the scrape endpoint
  metrics:
    tags:
      application: order-service          # common tag on every metric

Prometheus is pull-based: it scrapes /actuator/prometheus on an interval rather than your app pushing data out. The endpoint renders every registered meter as # HELP/# TYPE annotated samples — including the metrics Boot auto-provides (http.server.requests latency/error rates, jvm.memory.used, jvm.gc.pause, system.cpu.usage, HikariCP pool stats) plus your custom ones. Common tags (like application) help you distinguish instances in Grafana. For traces alongside these metrics, Micrometer's Observation API instruments an operation once and emits both a Timer and a tracing span — add micrometer-tracing to ship spans to Zipkin/Tempo.

Rule of thumb: Expose /actuator/prometheus and let Prometheus scrape it; add a common application/instance tag so multi-service dashboards can tell sources apart.

A MeterBinder is a reusable component that registers a set of related meters against a registry. Boot's built-in metrics (JvmMemoryMetrics, ProcessorMetrics, …) are all MeterBinders, and you can write your own to package up a cohesive group of gauges/counters:

@Component
public class CacheMetrics implements MeterBinder {
    private final MyCache cache;

    @Override
    public void bindTo(MeterRegistry registry) {
        Gauge.builder("mycache.size", cache, MyCache::size).register(registry);
        Gauge.builder("mycache.hit.ratio", cache, MyCache::hitRatio).register(registry);
    }
}

Any MeterBinder bean is auto-applied to every registry, which keeps instrumentation decoupled from business logic and ensures the same meters land in every backend you configure. It's the idiomatic way to expose a library's or subsystem's metrics.

Rule of thumb: Package a related group of metrics as a MeterBinder so they register consistently across all registries and stay out of your business code.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel