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.