View source on GitHub

Module 47 - Spring Cloud

Overview

Spring Cloud is an umbrella of libraries that solve the common cross-cutting concerns that arise when running Java services in a distributed system: where to find other services (discovery), where to get configuration (Config Server), how to route traffic (Gateway), how to tolerate partial failures (Circuit Breaker), and how to trace a request as it crosses service boundaries (distributed tracing).


1. Spring Cloud BOM

Spring Cloud releases are independent of Spring Boot and are managed through their own BOM:

<properties>
    <spring-cloud.version>2023.0.3</spring-cloud.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Spring Cloud 2023.0.x is compatible with Spring Boot 3.3.x. Individual starters are then added without an explicit version - the BOM manages them.


2. Config Server

Externalises configuration into a central server. All services fetch their properties at startup (and optionally at runtime via /actuator/refresh).

Server setup

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication { ... }
# application.properties for the Config Server itself
server.port=8888
spring.cloud.config.server.git.uri=https://github.com/example/config-repo
spring.cloud.config.server.git.default-label=main

Alternatively, use a local filesystem back-end for development:

spring.cloud.config.server.native.search-locations=classpath:/config
spring.profiles.active=native

Client setup

Any service that wants centralised config adds:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
</dependency>
# bootstrap.properties (loaded before application.properties)
spring.application.name=order-service
spring.config.import=optional:configserver:http://localhost:8888

The Config Server serves {application-name}/{profile} - so order-service/production maps to order-service-production.properties in the git repo.


3. Eureka - Service Discovery

Eureka lets services find each other by name instead of hard-coded URLs.

Server

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication { ... }
server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

Client

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
spring.application.name=order-service
eureka.client.service-url.defaultZone=http://localhost:8761/eureka

@LoadBalanced RestClient resolves service names via Eureka:

@Bean
@LoadBalanced
RestClient.Builder loadBalancedBuilder() {
    return RestClient.builder();
}

// Later: restClient.get().uri("http://inventory-service/inventory/{id}", id)
// "inventory-service" is resolved to an actual host:port by the load balancer

4. API Gateway

Spring Cloud Gateway is a reactive edge service that routes requests to downstream services, applies filters (auth, rate limiting, header rewriting), and integrates with Eureka for load balancing.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

Routes are declared in application.yml:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service # lb:// means resolve via Eureka
          predicates:
            - Path=/orders/**
          filters:
            - StripPrefix=0
            - AddRequestHeader=X-Gateway-Source, api-gateway

        - id: inventory-service
          uri: lb://inventory-service
          predicates:
            - Path=/inventory/**
          filters:
            - CircuitBreaker=name=inventory,fallbackUri=/fallback/inventory

Key concepts:

  • Predicate - matches an incoming request (path, method, header, query param)
  • Filter - mutates the request or response (add header, rate limit, circuit break)
  • lb:// prefix - routes through the load balancer (requires Eureka or similar)
  • StripPrefix=N - removes N path segments before forwarding

Gateway runs on Spring WebFlux and requires a separate application (cannot share a JVM with a Spring MVC service).


5. Circuit Breaker - Resilience4j

A circuit breaker wraps a call to a remote service and tracks failures. When the failure rate exceeds a threshold the circuit opens: subsequent calls return a fallback immediately without reaching the downstream service, letting it recover. After a wait period one probe call is allowed (HALF_OPEN); success closes the circuit, failure keeps it open.

     Calls
       │
  ┌────▼──────────────────────────────┐
  │  CLOSED                           │  failure rate < threshold
  │  All calls pass through           │
  └────────────────┬──────────────────┘
                   │ failure rate ≥ threshold
                   ▼
  ┌────────────────────────────────────┐
  │  OPEN                              │  all calls → fallback immediately
  │  No calls reach downstream         │
  └────────────────┬───────────────────┘
                   │ after wait-duration-in-open-state
                   ▼
  ┌────────────────────────────────────┐
  │  HALF_OPEN                         │  one probe call allowed
  │  permitted-number-of-calls         │  success → CLOSED
  └────────────────────────────────────┘  failure → OPEN

Annotation-based (Resilience4j)

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
@Service
public class InventoryService {

    @CircuitBreaker(name = "inventory", fallbackMethod = "availabilityFallback")
    public boolean checkAvailability(Long productId, int quantity) {
        return inventoryClient.checkAvailability(productId, quantity);
    }

    boolean availabilityFallback(Long productId, int quantity, Exception e) {
        log.warn("CB fallback: {}", e.getMessage());
        return false;   // degrade gracefully
    }
}

Configuration:

resilience4j.circuitbreaker.instances.inventory.sliding-window-size=10
resilience4j.circuitbreaker.instances.inventory.minimum-number-of-calls=5
resilience4j.circuitbreaker.instances.inventory.failure-rate-threshold=50
resilience4j.circuitbreaker.instances.inventory.wait-duration-in-open-state=30s
resilience4j.circuitbreaker.instances.inventory.permitted-number-of-calls-in-half-open-state=3

Spring Cloud Circuit Breaker abstraction

For provider-agnostic code, use CircuitBreakerFactory:

@Autowired CircuitBreakerFactory circuitBreakerFactory;

public boolean checkAvailability(Long productId, int quantity) {
    return circuitBreakerFactory
            .create("inventory")
            .run(
                () -> inventoryClient.checkAvailability(productId, quantity),
                throwable -> false  // fallback
            );
}

CircuitBreakerFactory is auto-configured by spring-cloud-starter-circuitbreaker-resilience4j. Swapping to a different implementation (e.g. Sentinel) only requires a dependency change.


6. Retry

Retry is complementary to Circuit Breaker: it handles transient failures by re-attempting the call before giving up, while the Circuit Breaker handles sustained failures by stopping calls entirely.

@Retry(name = "inventory", fallbackMethod = "reserveFallback")
public boolean reserve(Long productId, int quantity) {
    return inventoryClient.reserve(productId, quantity);
}
resilience4j.retry.instances.inventory.max-attempts=3
resilience4j.retry.instances.inventory.wait-duration=500ms
resilience4j.retry.instances.inventory.enable-exponential-backoff=true
resilience4j.retry.instances.inventory.exponential-backoff-multiplier=2

With exponential backoff and max-attempts=3, waits are: 500ms, 1000ms - then fallback.

Stacking Retry inside Circuit Breaker:

@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
@Retry(name = "inventory")
public boolean reserve(Long productId, int quantity) { ... }

The Retry aspect is innermost: it retries the HTTP call. If all retries fail, the exception propagates to the Circuit Breaker which counts the entire retry batch as one failure. This is the recommended production configuration.


7. Distributed Tracing

Distributed tracing follows a request across service boundaries by attaching a traceId (constant for the whole request) and spanId (unique per service hop) to every log line and HTTP header.

Micrometer Tracing + Brave

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management.tracing.sampling.probability=1.0     # 100% in dev; 0.1 (10%) in prod
management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans

Spring Boot auto-configures Brave and injects traceId/spanId into MDC - every log.info(...) call automatically includes them.

@Observed - custom spans

@Service
@Observed(name = "inventory.service")
public class InventoryService { ... }

Or on individual methods:

@Observed(name = "inventory.reserve",
          contextualName = "reserve-inventory",
          lowCardinalityKeyValues = {"service", "inventory"})
public boolean reserve(Long productId, int quantity) { ... }

Requires spring-boot-starter-aop and spring-boot-starter-actuator on the classpath. Spring Boot auto-configures the ObservedAspect that intercepts @Observed and creates spans.


8. Testing Circuit Breaker and Retry

Resilience4j state is held in CircuitBreakerRegistry and RetryRegistry beans. Tests use tight thresholds (src/test/resources/application.properties) and reset the circuit breaker state between test methods.

@SpringBootTest
class CircuitBreakerTest {

    @Autowired InventoryService inventoryService;
    @Autowired CircuitBreakerRegistry circuitBreakerRegistry;
    @MockBean InventoryClient inventoryClient;

    @BeforeEach
    void reset() {
        circuitBreakerRegistry.circuitBreaker("inventory").reset();
    }

    @Test
    void circuit_opens_after_failure_threshold() {
        when(inventoryClient.checkAvailability(anyLong(), anyInt()))
                .thenThrow(new RuntimeException("unavailable"));

        inventoryService.checkAvailability(1L, 1);
        inventoryService.checkAvailability(1L, 1);

        assertThat(circuitBreakerRegistry.circuitBreaker("inventory").getState())
                .isEqualTo(CircuitBreaker.State.OPEN);
    }

    @Test
    void open_circuit_returns_fallback_without_reaching_client() {
        when(inventoryClient.checkAvailability(anyLong(), anyInt()))
                .thenThrow(new RuntimeException("unavailable"));

        inventoryService.checkAvailability(1L, 1);
        inventoryService.checkAvailability(1L, 1); // opens

        boolean result = inventoryService.checkAvailability(1L, 1); // short-circuited

        assertThat(result).isFalse();
        verify(inventoryClient, times(2)).checkAvailability(anyLong(), anyInt());
    }

    @Test
    void circuit_closes_after_successful_probe_in_half_open() throws InterruptedException {
        when(inventoryClient.checkAvailability(anyLong(), anyInt()))
                .thenThrow(new RuntimeException())
                .thenThrow(new RuntimeException())
                .thenReturn(true);

        inventoryService.checkAvailability(1L, 1);
        inventoryService.checkAvailability(1L, 1);

        Thread.sleep(150); // > wait-duration-in-open-state=100ms

        inventoryService.checkAvailability(1L, 1); // probe → HALF_OPEN → CLOSED

        assertThat(circuitBreakerRegistry.circuitBreaker("inventory").getState())
                .isEqualTo(CircuitBreaker.State.CLOSED);
    }
}

Test configuration (src/test/resources/application.properties):

resilience4j.circuitbreaker.instances.inventory.sliding-window-size=2
resilience4j.circuitbreaker.instances.inventory.minimum-number-of-calls=2
resilience4j.circuitbreaker.instances.inventory.failure-rate-threshold=100
resilience4j.circuitbreaker.instances.inventory.wait-duration-in-open-state=100ms
resilience4j.circuitbreaker.instances.inventory.permitted-number-of-calls-in-half-open-state=1

resilience4j.retry.instances.inventory.max-attempts=3
resilience4j.retry.instances.inventory.wait-duration=10ms
resilience4j.retry.instances.inventory.enable-exponential-backoff=false

Key takeaways

  • Spring Cloud BOM 2023.0.x manages all Spring Cloud artifact versions alongside Spring Boot 3.3.x - import it in <dependencyManagement> and add starters without versions
  • Config Server externalises configuration into a git repo (or local filesystem); clients fetch properties at startup via spring.config.import=configserver:
  • Eureka enables service discovery: services register by name and clients resolve names to host:port at call time; @LoadBalanced RestClient handles the resolution
  • Spring Cloud Gateway is a reactive edge service (requires WebFlux); routes are declared as predicate + filter chains, with lb:// URIs for Eureka-based load balancing
  • Circuit Breaker (@CircuitBreaker) stops cascading failures by short-circuiting calls to unhealthy services and returning a fallback; the three states are CLOSED → OPEN → HALF_OPEN → CLOSED
  • Retry (@Retry) handles transient failures; stacked inside a Circuit Breaker, each retry batch counts as a single failure observation for the CB
  • Test with tight thresholds in src/test/resources/application.properties and reset CircuitBreakerRegistry in @BeforeEach to keep tests independent