Add spring-boot-microservices-project: config server, Eureka, gateway and three services on Boot 4.1.1 + Spring Cloud 2025.1.3

Product, order and inventory services behind Spring Cloud Gateway (WebMVC), configuration from a Config Server,
discovery with Eureka, a Resilience4j circuit breaker on order -> inventory and Brave tracing. Run as six plain jars;
output/ holds real transcripts (config, registry, requests, trace propagation, circuit breaker, startup race and the
load-balanced RestClient.Builder trap). Dockerfile and docker-compose.yml are included but were not run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5
This commit is contained in:
Claude
2026-10-21 17:08:42 +00:00
commit def73fa1c4
49 changed files with 1419 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.ankurm.shop.order;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
import io.micrometer.observation.ObservationRegistry;
import java.time.Duration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.client.circuitbreaker.Customizer;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestClient;
@Configuration
class ClientConfig {
/**
* "http://product-service/..." is not a DNS name: @LoadBalanced makes the client ask Eureka for a real
* host and port. The ObservationRegistry is what makes the client add the trace headers to the request.
*/
@Bean
@LoadBalanced
RestClient.Builder loadBalancedRestClientBuilder(ObservationRegistry observationRegistry) {
return RestClient.builder().observationRegistry(observationRegistry);
}
/**
* The Eureka client also asks for a RestClient.Builder. If the only one is the load-balanced one, Eureka's own
* calls to the registry go through the load balancer, which asks Eureka for "localhost": a circular reference
* and "No instances available for localhost". A plain @Primary builder keeps Eureka off the balanced one.
*/
@Bean
@Primary
@ConditionalOnProperty(name = "shop.demo.primary-builder", havingValue = "true", matchIfMissing = true) // false only in scripts/gotchas.sh
RestClient.Builder plainRestClientBuilder(ObservationRegistry observationRegistry) {
return RestClient.builder().observationRegistry(observationRegistry);
}
/** One circuit breaker named "inventory": open after 2 failures out of 4 calls, probe again after 5 seconds. */
@Bean
Customizer<Resilience4JCircuitBreakerFactory> inventoryBreaker() {
return factory -> factory.configure(builder -> builder
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.slidingWindowSize(4)
.minimumNumberOfCalls(4)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(5))
.permittedNumberOfCallsInHalfOpenState(1)
.build())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(3)).build()),
"inventory");
}
}
@@ -0,0 +1,57 @@
package com.ankurm.shop.order;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
/** Talks to inventory-service through the "inventory" circuit breaker. */
@Component
class InventoryClient {
private static final Logger log = LoggerFactory.getLogger(InventoryClient.class);
private final RestClient client;
private final CircuitBreakerFactory<?, ?> breakers;
InventoryClient(@LoadBalanced RestClient.Builder builder, CircuitBreakerFactory<?, ?> breakers) {
this.client = builder.baseUrl("http://inventory-service").build();
this.breakers = breakers;
}
/** A plain read used only by /orders/ready to warm up and check the route to inventory-service. */
Map<String, Object> level(long productId) {
return client.get().uri("/inventory/{id}", productId).retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() { });
}
enum Reservation { RESERVED, OUT_OF_STOCK, UNAVAILABLE }
/**
* RESERVED and OUT_OF_STOCK are answers from a healthy inventory service. UNAVAILABLE means the call failed or the
* circuit is open. The 409 is turned into OUT_OF_STOCK inside the protected call, so it never counts as a failure
* and never reaches the fallback: Spring Cloud's fallback receives every exception, including ones you would
* configure Resilience4j to ignore.
*/
Reservation reserve(long productId, int quantity) {
return breakers.create("inventory").run(
() -> {
try {
client.post().uri("/inventory/{id}/reserve", productId)
.body(Map.of("quantity", quantity))
.retrieve()
.toBodilessEntity();
return Reservation.RESERVED;
} catch (HttpClientErrorException.Conflict e) {
return Reservation.OUT_OF_STOCK;
}
},
failure -> {
log.warn("inventory call did not complete ({}); falling back", failure.getClass().getSimpleName());
return Reservation.UNAVAILABLE;
});
}
}
@@ -0,0 +1,6 @@
package com.ankurm.shop.order;
import java.math.BigDecimal;
record Order(long id, long productId, String productName, int quantity, BigDecimal total, String currency, String status) {
}
@@ -0,0 +1,116 @@
package com.ankurm.shop.order;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
@RestController
class OrderController {
private static final Logger log = LoggerFactory.getLogger(OrderController.class);
record NewOrder(@Min(1) long productId, @Min(1) int quantity) {
}
record ProductView(long id, String name, BigDecimal price) {
}
private final RestClient products;
private final InventoryClient inventory;
private final OrderProperties properties;
private final CircuitBreakerRegistry breakers;
private final List<Order> orders = new CopyOnWriteArrayList<>();
private final AtomicLong ids = new AtomicLong();
OrderController(@LoadBalanced RestClient.Builder builder, InventoryClient inventory, OrderProperties properties, CircuitBreakerRegistry breakers) {
this.products = builder.baseUrl("http://product-service").build();
this.inventory = inventory;
this.properties = properties;
this.breakers = breakers;
}
@PostMapping("/orders")
@ResponseStatus(HttpStatus.CREATED)
Order place(@Valid @RequestBody NewOrder request) {
if (request.quantity() > properties.maxQuantity()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At most " + properties.maxQuantity() + " units per order");
}
log.info("placing order for product {} x{}", request.productId(), request.quantity());
ProductView product = findProduct(request.productId());
String status = switch (inventory.reserve(request.productId(), request.quantity())) {
case RESERVED -> "CONFIRMED";
case UNAVAILABLE -> "PENDING_INVENTORY";
case OUT_OF_STOCK -> throw new ResponseStatusException(HttpStatus.CONFLICT,
"Not enough stock for product " + request.productId());
};
Order order = new Order(ids.incrementAndGet(), product.id(), product.name(), request.quantity(),
product.price().multiply(BigDecimal.valueOf(request.quantity())), properties.currency(), status);
orders.add(order);
log.info("order {} is {}", order.id(), order.status());
return order;
}
@GetMapping("/orders")
List<Order> all() {
return List.copyOf(orders);
}
/** Where the values from the config server ended up. */
@GetMapping("/orders/config")
Map<String, Object> config() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("currency", properties.currency());
m.put("maxQuantity", properties.maxQuantity());
return m;
}
/**
* 200 only when this service can really reach product-service and inventory-service through the load balancer.
* The registry copy is refreshed every 30 seconds, so right after startup this answers 503 for a while.
*/
@GetMapping("/orders/ready")
Map<String, Object> ready() {
try {
findProduct(1);
inventory.level(1);
return Map.of("ready", true);
} catch (RuntimeException e) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "dependencies not visible yet: " + e.getMessage());
}
}
@GetMapping("/orders/circuit")
Map<String, Object> circuit() {
var cb = breakers.circuitBreaker("inventory");
Map<String, Object> m = new LinkedHashMap<>();
m.put("state", cb.getState().name());
m.put("failedCalls", cb.getMetrics().getNumberOfFailedCalls());
m.put("notPermittedCalls", cb.getMetrics().getNumberOfNotPermittedCalls());
return m;
}
private ProductView findProduct(long id) {
try {
return products.get().uri("/products/{id}", id).retrieve().body(ProductView.class);
} catch (HttpClientErrorException.NotFound e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product " + id + " not found");
}
}
}
@@ -0,0 +1,8 @@
package com.ankurm.shop.order;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Bound from shop.order.* , which comes from the config server (order-service.yml). */
@ConfigurationProperties("shop.order")
record OrderProperties(String currency, int maxQuantity) {
}
@@ -0,0 +1,13 @@
package com.ankurm.shop.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@@ -0,0 +1,16 @@
spring:
application:
name: order-service
config:
# Fail fast if the config server is not up: a service with the wrong config is worse than no service.
import: "configserver:${CONFIG_URL:http://localhost:8888}"
server:
port: 8082
eureka:
client:
service-url:
defaultZone: ${EUREKA_URL:http://localhost:8761/eureka}
instance:
hostname: ${EUREKA_HOSTNAME:localhost}
# Register the service name + port as the instance id so two copies of one service do not collide.
instance-id: ${spring.application.name}:${server.port}