Spring Cloud (Microservices)

Spring Cloud (Microservices)

Service Discovery (Eureka/Consul)

In a microservices architecture, service instances are ephemeral — scaled up/down, rescheduled, IPs change. Service discovery solves “how does Service A find a live, healthy instance of Service B” without hard-coded hostnames/ports.

Pattern: each service instance registers itself with a discovery server on startup and sends periodic heartbeats; consumers query the registry (directly or via a client-side cache) to resolve a logical service name into one of its current healthy instances.

// On the service being discovered
@SpringBootApplication
@EnableDiscoveryClient // works with both Eureka and Consul depending on classpath
public class OrderServiceApplication { }
# Eureka client config
eureka:
  client:
    service-url:
      defaultZone: http://eureka-server:8761/eureka/
  instance:
    lease-renewal-interval-in-seconds: 10
Eureka Consul
Origin Netflix OSS HashiCorp
Consistency model AP (favors availability — serves slightly stale registry data during partition rather than failing) CP-leaning (Raft consensus), with tunable consistency
Additional capabilities Pure service registry Service registry + distributed KV config store + health checking + native multi-datacenter support
Spring Boot status Maintenance mode — feature-complete but minimal active development Actively developed, broader non-Spring ecosystem adoption
Typical fit Existing Netflix-OSS-stack shops, simpler topology New builds, especially where KV config and multi-DC/multi-region discovery matter

Architect-level point: Eureka’s CAP choice (AP) is deliberate — Netflix’s design philosophy was that serving a stale-but-available registry during a network partition is preferable to refusing to serve discovery data at all, since a slightly outdated instance list is recoverable (a failed call retries against another instance) while a discovery outage cascades. This is the right framing if asked “why would discovery tolerate stale data” — it’s not a flaw, it’s an explicit trade-off favoring resilience over strict consistency, consistent with the broader “fail gracefully, degrade rather than halt” philosophy of resilient distributed systems.

In modern cloud-native deployments (Kubernetes), many teams skip Eureka/Consul entirely and rely on Kubernetes’ own built-in service discovery (DNS-based Service objects) — worth flagging as the pragmatic default when the deployment target is already K8s, reserving Eureka/Consul for non-K8s or hybrid environments.


API Gateway (Spring Cloud Gateway)

A single entry point that fronts all downstream microservices — handling routing, cross-cutting concerns (auth, rate limiting, request/response transformation) at the edge so individual services don’t each reimplement them.

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://ORDER-SERVICE  # lb:// = resolve via load balancer/discovery, not a fixed host
          predicates:
            - Path=/api/v1/orders/**
          filters:
            - StripPrefix=1
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
        - id: customer-service
          uri: lb://CUSTOMER-SERVICE
          predicates:
            - Path=/api/v1/customers/**
// Programmatic route definition — preferred for complex conditional routing
@Bean
public RouteLocator customRoutes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("order-service", r -> r.path("/api/v1/orders/**")
            .filters(f -> f.stripPrefix(1)
                            .circuitBreaker(c -> c.setName("orderCB").setFallbackUri("forward:/fallback/orders")))
            .uri("lb://ORDER-SERVICE"))
        .build();
}

Built on Spring WebFlux (Netty, reactive, non-blocking) — not Spring MVC — which matters architecturally: the gateway sits on the hot path of every request to every downstream service, so its own throughput ceiling directly caps the whole system’s throughput. A blocking gateway implementation would defeat the purpose; this is a deliberate, non-optional reactive design choice, not an arbitrary one.

Common gateway-level concerns to centralize here rather than per-service:

  • JWT validation (reject unauthenticated requests before they reach any backend service)
  • Rate limiting / throttling
  • Request/response logging and correlation ID injection
  • Response aggregation for simple BFF (Backend-for-Frontend) scenarios
  • TLS termination

Architect-level caution: avoid letting the gateway accumulate business logic — it should remain a routing/cross-cutting-concerns layer. A gateway that starts making business decisions becomes a coupling bottleneck and a single point of both failure and deployment contention across teams — a smell worth flagging in architecture reviews of fast-growing platforms.


Config Server

Externalizes configuration for all services into a single Git-backed (or Vault/JDBC-backed) repository, served over HTTP — services fetch their config at startup (and optionally refresh it live) rather than baking config into the deployed artifact.

# Config Server itself
spring:
  cloud:
    config:
      server:
        git:
          uri: https://github.com/acme/config-repo
          default-label: main
          search-paths: '{application}'
# Consuming service — bootstrap config (Spring Boot 2.4+: bootstrap.yml or spring.config.import)
spring:
  config:
    import: "configserver:http://config-server:8888"
  application:
    name: order-service  # determines which config file is fetched: order-service.yml
  profiles:
    active: prod          # fetches order-service-prod.yml as an override layer

Live refresh without redeployment:

@RefreshScope // bean is recreated on next access after a /actuator/refresh call
@Component
public class FeatureFlags {
    @Value("${app.feature.new-checkout-enabled}")
    private boolean newCheckoutEnabled;
}

Triggered via POST /actuator/refresh on the instance, or fleet-wide via Spring Cloud Bus (publishes a refresh event over RabbitMQ/Kafka to all instances simultaneously) rather than hitting each instance individually.

Architect-level point: Config Server centralizes config but introduces a startup-time dependency — if it’s down, dependent services can fail to start (mitigated via local config fallback/retry settings). In Kubernetes-native shops, this role increasingly overlaps with K8s ConfigMap/Secret plus a GitOps reconciliation loop (ArgoCD/Flux) — worth being able to articulate that Config Server solves the same core problem as K8s-native config management, and the choice between them is largely about whether the platform is already K8s-centric versus needing to support mixed VM/container/legacy deployment targets.


Feign Client

A declarative HTTP client — define an interface annotated with the target service’s contract, Spring generates the implementation (HTTP call construction, serialization, load-balanced target resolution) at runtime. Eliminates manual RestTemplate/WebClient boilerplate for service-to-service calls.

@FeignClient(name = "inventory-service", path = "/api/v1/inventory")
public interface InventoryClient {

    @GetMapping("/{sku}")
    InventoryResponse getStock(@PathVariable String sku);

    @PostMapping("/reserve")
    ReservationResponse reserve(@RequestBody ReservationRequest request);
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final InventoryClient inventoryClient; // just inject and call — looks like a local method call

    public void placeOrder(Order order) {
        InventoryResponse stock = inventoryClient.getStock(order.getSku());
        // ...
    }
}

name resolves via the discovery client (Eureka/Consul) to an actual instance, load-balanced automatically — Feign integrates Spring Cloud LoadBalancer transparently underneath.

Resilience integration (the combination that comes up most in architect interviews):

feign:
  circuitbreaker:
    enabled: true   # wires Feign calls through Resilience4j automatically
@FeignClient(name = "inventory-service", fallback = InventoryClientFallback.class)
public interface InventoryClient { ... }

@Component
public class InventoryClientFallback implements InventoryClient {
    public InventoryResponse getStock(String sku) {
        return InventoryResponse.unavailable(sku); // degrade gracefully instead of propagating the failure
    }
}

Architect-level point: Feign’s declarative simplicity is exactly what makes it dangerous if used carelessly — because the call looks like a local method invocation, it’s easy for a team to forget it’s a network call with all the failure modes that implies (timeout, partial failure, retries, idempotency concerns). Always pair Feign clients with explicit timeout configuration (feign.client.config.*.connectTimeout) and a circuit breaker — relying on defaults (or no timeout at all) is a common production incident root cause: a hanging downstream call with no timeout will eventually exhaust the caller’s own thread pool.


Circuit Breaker (Resilience4j)

Prevents a failing/slow downstream dependency from cascading into a full system outage — once failures cross a threshold, the breaker “opens” and short-circuits further calls (fast-failing or routing to a fallback) instead of letting threads pile up waiting on a dependency that’s already unhealthy.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final InventoryClient inventoryClient;

    @CircuitBreaker(name = "inventoryService", fallbackMethod = "reserveFallback")
    @Retry(name = "inventoryService")
    @TimeLimiter(name = "inventoryService")
    public CompletableFuture<ReservationResponse> reserveStock(ReservationRequest request) {
        return CompletableFuture.supplyAsync(() -> inventoryClient.reserve(request));
    }

    public CompletableFuture<ReservationResponse> reserveFallback(ReservationRequest request, Throwable t) {
        return CompletableFuture.completedFuture(ReservationResponse.queued(request));
    }
}
resilience4j:
  circuitbreaker:
    instances:
      inventoryService:
        failure-rate-threshold: 50          # % of calls failing before opening
        slow-call-rate-threshold: 50
        slow-call-duration-threshold: 2s
        wait-duration-in-open-state: 10s    # how long to stay OPEN before testing recovery
        sliding-window-size: 20
        permitted-number-of-calls-in-half-open-state: 5
  retry:
    instances:
      inventoryService:
        max-attempts: 3
        wait-duration: 500ms
  timelimiter:
    instances:
      inventoryService:
        timeout-duration: 3s

State machine — a frequent whiteboard-diagram interview question:

State Behavior
CLOSED Normal operation, calls pass through; failures counted in a sliding window
OPEN Failure threshold exceeded — calls fail immediately (fallback invoked), no actual network call made, for wait-duration-in-open-state
HALF_OPEN After the wait duration, a limited number of trial calls are permitted through; success rate determines whether it returns to CLOSED or back to OPEN

Architect-level point — combining Retry + Circuit Breaker + TimeLimiter correctly (a real-world misconfiguration source): order matters. Retrying inside an already-open circuit breaker defeats the breaker’s purpose (each retry attempt still counts as a fast-failed call, but you’re now multiplying load on a recovering dependency during HALF_OPEN testing). The conventional layering is CircuitBreaker as the outermost decorator, with Retry operating only on individual call attempts within the CLOSED/HALF_OPEN window — Resilience4j’s annotation ordering and the underlying decorator chain need to reflect this, and getting it backwards is a documented gotcha worth explicitly checking in code review (Retry wrapping CircuitBreaker can cause exactly the retry-storm-against-a-failing-dependency scenario the breaker exists to prevent).

Why this superseded Netflix Hystrix: Hystrix entered maintenance mode/EOL; Resilience4j is the standard modern choice — lightweight, functional-style, modular (pick only the modules you need: circuit breaker, retry, rate limiter, bulkhead, time limiter independently), and natively integrates with Micrometer for metrics export.


Distributed Tracing (Sleuth/Zipkin)

Important currency note for architect-level accuracy: Spring Cloud Sleuth has been discontinued as of Spring Boot 3 — its tracing instrumentation was absorbed into Micrometer Tracing (the same project that powers Micrometer Metrics), with Zipkin (and OpenTelemetry/OTLP) as supported exporters underneath. If asked about Sleuth in an interview, the strongest answer demonstrates awareness of this migration rather than describing Sleuth as current — it signals up-to-date practical knowledge over textbook recall.

# Modern stack (Spring Boot 3+)
management:
  tracing:
    sampling:
      probability: 1.0   # 1.0 = trace every request; tune down in high-throughput prod (e.g., 0.1)
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>

Core concepts unchanged across the Sleuth → Micrometer Tracing transition:

Concept Meaning
Trace ID Unique ID for an entire end-to-end request journey across all services it touches
Span ID Unique ID for one unit of work within that trace (e.g., one service’s handling of the request, or one DB call)
Parent Span ID Links a span to its caller, building the tree/waterfall structure visible in the Zipkin UI
Baggage Key-value context propagated across service boundaries alongside the trace (e.g., tenant ID, user ID) for correlation beyond just timing

Trace context propagates automatically across Feign calls, RestTemplate/WebClient, messaging (Kafka/RabbitMQ headers) — instrumentation is largely auto-configured once the dependencies are present; manual span creation is only needed for custom non-HTTP boundaries:

@Autowired
private Tracer tracer;

public void processInternal() {
    Span span = tracer.nextSpan().name("custom-processing-step").start();
    try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
        // work
    } finally {
        span.end();
    }
}

Architect-level point: distributed tracing’s actual value in a microservices estate is diagnosing where latency or failure occurs across a call chain spanning many services — without it, a slow end-to-end request gives no visibility into which of N downstream hops was the bottleneck. Sampling probability is the key production tuning lever: full sampling (1.0) is fine in dev/low-traffic, but at scale it generates significant trace-storage volume and overhead — most production systems sample a fraction (often combined with “always sample on error” rules) rather than tracing every request.


Load Balancing (Ribbon/Spring Cloud LoadBalancer)

Currency note, same pattern as Sleuth: Ribbon is deprecated and in maintenance mode — Spring Cloud LoadBalancer is its replacement and has been the default since Spring Cloud Greenwich/Hoxton-era releases. Both still come up in interviews (often phrased exactly as “Ribbon/Spring Cloud LoadBalancer,” as in this list), so it’s worth being able to speak to why the migration happened, not just that it did.

spring:
  cloud:
    loadbalancer:
      configurations: health-check
@Bean
public ReactorLoadBalancerExceptionEvaluator customEvaluator() { ... } // pluggable strategy points

@Configuration
public class LoadBalancerConfig {
    @Bean
    public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier(
            ConfigurableApplicationContext context) {
        return ServiceInstanceListSupplier.builder()
            .withDiscoveryClient()
            .withHealthChecks()       // filters out unhealthy instances before they're ever selected
            .withCaching()
            .build(context);
    }
}

Client-side load balancing: the calling service itself holds (a cached view of) the list of available instances from the discovery client and picks one per call — as opposed to server-side load balancing (a dedicated LB/proxy like an ALB/NGINX sitting between caller and callees). This is the architectural distinction worth being precise about:

Client-side (Ribbon / Spring Cloud LoadBalancer) Server-side (ALB, NGINX, K8s Service)
Where the decision is made In the calling service’s process At a dedicated network hop
Extra network hop No — caller connects directly to the chosen instance Yes — traffic flows through the LB
Coupling Caller needs discovery-client awareness Caller is LB-agnostic, just hits a stable endpoint
Failure mode LB logic failure is per-caller LB is a (typically highly-available) shared dependency

Why Ribbon was deprecated: Netflix stopped actively maintaining it, and architecturally it predates reactive programming — Spring Cloud LoadBalancer was built with first-class support for both blocking (RestTemplate) and reactive (WebClient) clients, and is more naturally extensible (pluggable ServiceInstanceListSupplier strategies for health-check filtering, zone-awareness, caching) without Ribbon’s older, more rigid configuration model.

Architect-level point, tying back to Kubernetes deployments: in a K8s-native deployment, server-side load balancing via Service/Ingress is often already provided by the platform — running client-side load balancing on top of K8s Services is frequently redundant unless there’s a specific reason (e.g., needing zone-aware or weighted client-side routing logic K8s Services don’t natively express). Recognizing when client-side load balancing is solving a problem the platform already solves is a good signal of platform-aware architectural judgment, distinct from defaulting to the full Spring Cloud stack out of habit on every project regardless of deployment target.