commit def73fa1c4a67fb1602bdd5238eb74f4331a0ce6 Author: Claude Date: Wed Oct 21 17:08:42 2026 +0000 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 Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4932ec5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +run/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e481966 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +# One Dockerfile for all six services: docker build --build-arg SERVICE=order-service -t shop/order-service . +# Build the jars first (mvn -DskipTests package). NOT RUN when this repository was written: the sandbox had no Docker. +FROM eclipse-temurin:21-jre +ARG SERVICE +COPY ${SERVICE}/target/${SERVICE}-1.0.0.jar /app.jar +ENTRYPOINT ["java", "-Xmx256m", "-jar", "/app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a3a2f1 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# spring-boot-microservices-project + +A small e-commerce system on **Spring Boot 4.1.1** and **Spring Cloud 2025.1.3**, written for the article +[Spring Boot Microservices Project: Gateway, Discovery, Config and 3 Services](https://ankurm.com/spring-boot-microservices-project/). + +``` +client ──> gateway :8080 ──> product-service :8081 + ├─> order-service :8082 ──> product-service (read the price) + │ └──> inventory-service (reserve stock, circuit breaker) + └─> inventory-service :8083 +config-server :8888 serves every service's configuration (and the gateway's routes) +discovery-server :8761 (Eureka) every service registers here and finds the others here +``` + +| Module | What it is | +|---|---| +| `config-server` | Spring Cloud Config Server, native profile; files in `src/main/resources/config/` | +| `discovery-server` | Eureka server | +| `gateway` | Spring Cloud Gateway Server WebMVC; routes come from the config server (`gateway.yml`) | +| `product-service` | `GET /products`, `GET /products/{id}` | +| `inventory-service` | stock levels, `POST /inventory/{id}/reserve`, a switch to simulate an outage | +| `order-service` | `POST /orders`; calls product-service and inventory-service through the load balancer and a Resilience4j circuit breaker | + +## Run it (no Docker needed) + +Needs JDK 21+, Maven 3.9+, curl and python3. + +```bash +./scripts/run-all.sh # build, start the six services, run the scenarios, stop everything +./scripts/start.sh # or: start the services and leave them running +./scripts/demo.sh # re-run the scenarios (rewrites output/) +./scripts/stop.sh +``` + +Start order matters: **config server first** (everything else reads its configuration from it), then Eureka, then the rest. + +## Docker + +`Dockerfile` and `docker-compose.yml` are provided but were **not run**: the sandbox this was written in had no Docker. +Treat them as a starting point, not a tested artifact. + +## Captured output + +Every file in `output/` is written by `scripts/demo.sh` from real requests. Nothing is typed by hand. + +| File | What it shows | +|---|---| +| `01-config-server.txt` | What the config server serves and the value the order service ended up with | +| `02-eureka-registry.txt` | The instances registered in Eureka | +| `03-gateway-requests.txt` | Requests through the gateway: products, stock, orders, and the error cases | +| `04-trace-propagation.txt` | One request, one trace id, in the logs of four services | +| `05-circuit-breaker.txt` | The circuit breaker going CLOSED, OPEN, and back to CLOSED | + +## Versions + +Spring Boot 4.1.1, Spring Framework 7, Spring Cloud 2025.1.3 (its BOM is built against Boot 4.0.8; it ran here on 4.1.1), +Tomcat 11, Java 21 (OpenJDK 21.0.12). JDK 25 was not available when this was written. diff --git a/config-server/pom.xml b/config-server/pom.xml new file mode 100644 index 0000000..5d4c296 --- /dev/null +++ b/config-server/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + config-server + + + + org.springframework.cloud + spring-cloud-config-server + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/config-server/src/main/java/com/ankurm/shop/config/ConfigServerApplication.java b/config-server/src/main/java/com/ankurm/shop/config/ConfigServerApplication.java new file mode 100644 index 0000000..535b164 --- /dev/null +++ b/config-server/src/main/java/com/ankurm/shop/config/ConfigServerApplication.java @@ -0,0 +1,14 @@ +package com.ankurm.shop.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +/** Serves the files under src/main/resources/config to every other service. Start this one first. */ +@SpringBootApplication +@EnableConfigServer +public class ConfigServerApplication { + public static void main(String[] args) { + SpringApplication.run(ConfigServerApplication.class, args); + } +} diff --git a/config-server/src/main/resources/application.yml b/config-server/src/main/resources/application.yml new file mode 100644 index 0000000..1e4ae43 --- /dev/null +++ b/config-server/src/main/resources/application.yml @@ -0,0 +1,12 @@ +spring: + application: + name: config-server + profiles: + active: native + cloud: + config: + server: + native: + search-locations: classpath:/config +server: + port: 8888 diff --git a/config-server/src/main/resources/config/application.yml b/config-server/src/main/resources/config/application.yml new file mode 100644 index 0000000..a1c6d11 --- /dev/null +++ b/config-server/src/main/resources/config/application.yml @@ -0,0 +1,9 @@ +# Served to EVERY service that asks the config server (the "application" file is the shared default). +management: + endpoints: + web: + exposure: + include: health,info,circuitbreakers + tracing: + sampling: + probability: 1.0 diff --git a/config-server/src/main/resources/config/gateway.yml b/config-server/src/main/resources/config/gateway.yml new file mode 100644 index 0000000..470bee1 --- /dev/null +++ b/config-server/src/main/resources/config/gateway.yml @@ -0,0 +1,25 @@ +# Routes live here, not in the gateway jar: change a route, restart the gateway, no rebuild. +spring: + cloud: + gateway: + server: + webmvc: + routes: + - id: products + uri: lb://product-service + predicates: + - Path=/api/products/** + filters: + - StripPrefix=1 + - id: orders + uri: lb://order-service + predicates: + - Path=/api/orders/** + filters: + - StripPrefix=1 + - id: inventory + uri: lb://inventory-service + predicates: + - Path=/api/inventory/** + filters: + - StripPrefix=1 diff --git a/config-server/src/main/resources/config/inventory-service.yml b/config-server/src/main/resources/config/inventory-service.yml new file mode 100644 index 0000000..8be89e3 --- /dev/null +++ b/config-server/src/main/resources/config/inventory-service.yml @@ -0,0 +1,3 @@ +shop: + inventory: + low-stock-threshold: 5 diff --git a/config-server/src/main/resources/config/order-service.yml b/config-server/src/main/resources/config/order-service.yml new file mode 100644 index 0000000..13700df --- /dev/null +++ b/config-server/src/main/resources/config/order-service.yml @@ -0,0 +1,4 @@ +shop: + order: + currency: EUR + max-quantity: 10 diff --git a/discovery-server/pom.xml b/discovery-server/pom.xml new file mode 100644 index 0000000..c0588cd --- /dev/null +++ b/discovery-server/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + discovery-server + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-server + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/discovery-server/src/main/java/com/ankurm/shop/discovery/DiscoveryServerApplication.java b/discovery-server/src/main/java/com/ankurm/shop/discovery/DiscoveryServerApplication.java new file mode 100644 index 0000000..3a7e0bf --- /dev/null +++ b/discovery-server/src/main/java/com/ankurm/shop/discovery/DiscoveryServerApplication.java @@ -0,0 +1,14 @@ +package com.ankurm.shop.discovery; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +/** The service registry: every other service registers here and looks the others up here. */ +@SpringBootApplication +@EnableEurekaServer +public class DiscoveryServerApplication { + public static void main(String[] args) { + SpringApplication.run(DiscoveryServerApplication.class, args); + } +} diff --git a/discovery-server/src/main/resources/application.yml b/discovery-server/src/main/resources/application.yml new file mode 100644 index 0000000..cde7424 --- /dev/null +++ b/discovery-server/src/main/resources/application.yml @@ -0,0 +1,15 @@ +spring: + application: + name: discovery-server +server: + port: 8761 +eureka: + instance: + hostname: ${EUREKA_HOSTNAME:localhost} + client: + # This server is the registry; it must not try to register with itself or fetch from itself. + register-with-eureka: false + fetch-registry: false + server: + # Dev setting: a registry with one client would otherwise enter "self-preservation" and never evict dead instances. + enable-self-preservation: false diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a084061 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# All six services on one network: mvn -DskipTests package && docker compose up --build +# NOT RUN when this repository was written (no Docker in the sandbox). The same system was run as plain jars with +# scripts/start.sh, and everything in output/ comes from that. Inside the compose network the services reach each +# other by service name, so only the three environment variables below differ from the jar run. +x-service: &service + restart: on-failure # a service that starts before the config server is up exits and is retried + environment: &env + CONFIG_URL: http://config-server:8888 + EUREKA_URL: http://discovery-server:8761/eureka + +services: + config-server: + build: { context: ., args: { SERVICE: config-server } } + ports: ["8888:8888"] + discovery-server: + build: { context: ., args: { SERVICE: discovery-server } } + ports: ["8761:8761"] + environment: { EUREKA_HOSTNAME: discovery-server } + product-service: + <<: *service + build: { context: ., args: { SERVICE: product-service } } + environment: { <<: *env, EUREKA_HOSTNAME: product-service } + depends_on: [config-server, discovery-server] + inventory-service: + <<: *service + build: { context: ., args: { SERVICE: inventory-service } } + environment: { <<: *env, EUREKA_HOSTNAME: inventory-service } + depends_on: [config-server, discovery-server] + order-service: + <<: *service + build: { context: ., args: { SERVICE: order-service } } + environment: { <<: *env, EUREKA_HOSTNAME: order-service } + depends_on: [config-server, discovery-server] + gateway: + <<: *service + build: { context: ., args: { SERVICE: gateway } } + environment: { <<: *env, EUREKA_HOSTNAME: gateway } + ports: ["8080:8080"] + depends_on: [config-server, discovery-server] diff --git a/gateway/pom.xml b/gateway/pom.xml new file mode 100644 index 0000000..95d825d --- /dev/null +++ b/gateway/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + gateway + + + + org.springframework.cloud + spring-cloud-starter-gateway-server-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-starter-config + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.boot + spring-boot-micrometer-tracing-brave + + + io.micrometer + micrometer-tracing-bridge-brave + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/gateway/src/main/java/com/ankurm/shop/gateway/GatewayApplication.java b/gateway/src/main/java/com/ankurm/shop/gateway/GatewayApplication.java new file mode 100644 index 0000000..a20792b --- /dev/null +++ b/gateway/src/main/java/com/ankurm/shop/gateway/GatewayApplication.java @@ -0,0 +1,12 @@ +package com.ankurm.shop.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** The single public entry point. The routes are in config-server/src/main/resources/config/gateway.yml. */ +@SpringBootApplication +public class GatewayApplication { + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/gateway/src/main/java/com/ankurm/shop/gateway/RequestLogFilter.java b/gateway/src/main/java/com/ankurm/shop/gateway/RequestLogFilter.java new file mode 100644 index 0000000..12258ed --- /dev/null +++ b/gateway/src/main/java/com/ankurm/shop/gateway/RequestLogFilter.java @@ -0,0 +1,32 @@ +package com.ankurm.shop.gateway; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Logs one line per request. It runs inside the tracing filter, so the line carries the trace id the gateway + * created (or accepted from the caller) and passes on to every service behind it. + */ +@Component +class RequestLogFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(RequestLogFilter.class); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + log.info("gateway received {} {}", request.getMethod(), request.getRequestURI()); + chain.doFilter(request, response); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml new file mode 100644 index 0000000..677de42 --- /dev/null +++ b/gateway/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + application: + name: gateway + 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: 8080 +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} diff --git a/inventory-service/pom.xml b/inventory-service/pom.xml new file mode 100644 index 0000000..21da3f1 --- /dev/null +++ b/inventory-service/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + inventory-service + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-starter-config + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.boot + spring-boot-micrometer-tracing-brave + + + io.micrometer + micrometer-tracing-bridge-brave + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryController.java b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryController.java new file mode 100644 index 0000000..301d9fc --- /dev/null +++ b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryController.java @@ -0,0 +1,86 @@ +package com.ankurm.shop.inventory; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +class InventoryController { + private static final Logger log = LoggerFactory.getLogger(InventoryController.class); + + record ReserveRequest(int quantity) { + } + + private final InventoryProperties properties; + private final Map stock = new ConcurrentHashMap<>(Map.of( + 1L, new AtomicInteger(20), 2L, new AtomicInteger(8), 3L, new AtomicInteger(3))); + private final AtomicBoolean down = new AtomicBoolean(false); + private final AtomicInteger reserveCalls = new AtomicInteger(); + + InventoryController(InventoryProperties properties) { + this.properties = properties; + } + + @GetMapping("/inventory/{productId}") + Map level(@PathVariable long productId) { + AtomicInteger s = stock.get(productId); + if (s == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No stock record for product " + productId); + } + return view(productId, s.get()); + } + + @PostMapping("/inventory/{productId}/reserve") + Map reserve(@PathVariable long productId, @RequestBody ReserveRequest request) { + reserveCalls.incrementAndGet(); + if (down.get()) { + log.warn("simulating an outage: refusing to reserve product {}", productId); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "inventory is down"); + } + AtomicInteger s = stock.get(productId); + if (s == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No stock record for product " + productId); + } + int remaining = s.addAndGet(-request.quantity()); + if (remaining < 0) { + s.addAndGet(request.quantity()); + throw new ResponseStatusException(HttpStatus.CONFLICT, "Only " + s.get() + " of product " + productId + " in stock"); + } + log.info("reserved {} of product {}, {} left", request.quantity(), productId, remaining); + return view(productId, remaining); + } + + /** Test switch: POST /inventory/chaos?down=true makes every reserve call fail with 503. */ + @PostMapping("/inventory/chaos") + Map chaos(@RequestParam boolean down) { + this.down.set(down); + log.info("outage simulation is now {}", down ? "ON" : "OFF"); + return Map.of("down", down); + } + + /** How many reserve calls actually reached this service (a circuit-breaker test needs this). */ + @GetMapping("/inventory/stats") + Map stats() { + return Map.of("reserveCalls", reserveCalls.get()); + } + + private Map view(long productId, int quantity) { + Map m = new LinkedHashMap<>(); + m.put("productId", productId); + m.put("quantity", quantity); + m.put("lowStock", quantity <= properties.lowStockThreshold()); + return m; + } +} diff --git a/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryProperties.java b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryProperties.java new file mode 100644 index 0000000..e9cc189 --- /dev/null +++ b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryProperties.java @@ -0,0 +1,8 @@ +package com.ankurm.shop.inventory; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Bound from shop.inventory.* , which comes from the config server (inventory-service.yml). */ +@ConfigurationProperties("shop.inventory") +record InventoryProperties(int lowStockThreshold) { +} diff --git a/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryServiceApplication.java b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryServiceApplication.java new file mode 100644 index 0000000..f03de31 --- /dev/null +++ b/inventory-service/src/main/java/com/ankurm/shop/inventory/InventoryServiceApplication.java @@ -0,0 +1,13 @@ +package com.ankurm.shop.inventory; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class InventoryServiceApplication { + public static void main(String[] args) { + SpringApplication.run(InventoryServiceApplication.class, args); + } +} diff --git a/inventory-service/src/main/resources/application.yml b/inventory-service/src/main/resources/application.yml new file mode 100644 index 0000000..e9fa978 --- /dev/null +++ b/inventory-service/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + application: + name: inventory-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: 8083 +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} diff --git a/order-service/pom.xml b/order-service/pom.xml new file mode 100644 index 0000000..54e3db8 --- /dev/null +++ b/order-service/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + order-service + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-restclient + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-starter-config + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.boot + spring-boot-micrometer-tracing-brave + + + io.micrometer + micrometer-tracing-bridge-brave + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/order-service/src/main/java/com/ankurm/shop/order/ClientConfig.java b/order-service/src/main/java/com/ankurm/shop/order/ClientConfig.java new file mode 100644 index 0000000..bb4bf47 --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/ClientConfig.java @@ -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 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"); + } +} diff --git a/order-service/src/main/java/com/ankurm/shop/order/InventoryClient.java b/order-service/src/main/java/com/ankurm/shop/order/InventoryClient.java new file mode 100644 index 0000000..024dc7d --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/InventoryClient.java @@ -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 level(long productId) { + return client.get().uri("/inventory/{id}", productId).retrieve() + .body(new org.springframework.core.ParameterizedTypeReference>() { }); + } + + 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; + }); + } +} diff --git a/order-service/src/main/java/com/ankurm/shop/order/Order.java b/order-service/src/main/java/com/ankurm/shop/order/Order.java new file mode 100644 index 0000000..195372c --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/Order.java @@ -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) { +} diff --git a/order-service/src/main/java/com/ankurm/shop/order/OrderController.java b/order-service/src/main/java/com/ankurm/shop/order/OrderController.java new file mode 100644 index 0000000..27ae7c5 --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/OrderController.java @@ -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 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 all() { + return List.copyOf(orders); + } + + /** Where the values from the config server ended up. */ + @GetMapping("/orders/config") + Map config() { + Map 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 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 circuit() { + var cb = breakers.circuitBreaker("inventory"); + Map 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"); + } + } +} diff --git a/order-service/src/main/java/com/ankurm/shop/order/OrderProperties.java b/order-service/src/main/java/com/ankurm/shop/order/OrderProperties.java new file mode 100644 index 0000000..773028b --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/OrderProperties.java @@ -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) { +} diff --git a/order-service/src/main/java/com/ankurm/shop/order/OrderServiceApplication.java b/order-service/src/main/java/com/ankurm/shop/order/OrderServiceApplication.java new file mode 100644 index 0000000..565b3b7 --- /dev/null +++ b/order-service/src/main/java/com/ankurm/shop/order/OrderServiceApplication.java @@ -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); + } +} diff --git a/order-service/src/main/resources/application.yml b/order-service/src/main/resources/application.yml new file mode 100644 index 0000000..edacea5 --- /dev/null +++ b/order-service/src/main/resources/application.yml @@ -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} diff --git a/output/00-startup-race.txt b/output/00-startup-race.txt new file mode 100644 index 0000000..09545eb --- /dev/null +++ b/output/00-startup-race.txt @@ -0,0 +1,21 @@ +# GET /api/orders/ready through the gateway, every 2 seconds, starting the moment the last service reported healthy. +# 200 means the gateway can route to the order service AND the order service can reach product and inventory. + +/api/products/1 t+ 1s HTTP 200 {"id":1,"name":"Mechanical keyboard","price":89.90} +/api/inventory/1 t+ 0s HTTP 200 {"productId":1,"quantity":20,"lowStock":false} +/api/orders/ready t+ 0s HTTP 500 {"timestamp":"2026-10-21T17:07:18.322Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+ 2s HTTP 500 {"timestamp":"2026-10-21T17:07:20.364Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+ 4s HTTP 500 {"timestamp":"2026-10-21T17:07:22.396Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+ 6s HTTP 500 {"timestamp":"2026-10-21T17:07:24.421Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+ 8s HTTP 500 {"timestamp":"2026-10-21T17:07:26.444Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+10s HTTP 500 {"timestamp":"2026-10-21T17:07:28.466Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+12s HTTP 500 {"timestamp":"2026-10-21T17:07:30.490Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+14s HTTP 500 {"timestamp":"2026-10-21T17:07:32.513Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+16s HTTP 500 {"timestamp":"2026-10-21T17:07:34.542Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+18s HTTP 500 {"timestamp":"2026-10-21T17:07:36.570Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+20s HTTP 500 {"timestamp":"2026-10-21T17:07:38.595Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+22s HTTP 500 {"timestamp":"2026-10-21T17:07:40.634Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+24s HTTP 500 {"timestamp":"2026-10-21T17:07:42.658Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+26s HTTP 500 {"timestamp":"2026-10-21T17:07:44.686Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+28s HTTP 500 {"timestamp":"2026-10-21T17:07:46.709Z","status":500,"error":"Internal Server Error","path +/api/orders/ready t+30s HTTP 200 {"ready":true} diff --git a/output/01-config-server.txt b/output/01-config-server.txt new file mode 100644 index 0000000..8f5e326 --- /dev/null +++ b/output/01-config-server.txt @@ -0,0 +1,16 @@ +# What the config server hands out (port 8888). The services fetch this at startup. + +--- order-service: which files were used and the values --- +name: order-service profiles: ['default'] +source: order-service.yml + shop.order.currency = EUR + shop.order.max-quantity = 10 +source: application.yml + management.endpoints.web.exposure.include = health,info,circuitbreakers + management.tracing.sampling.probability = 1.0 + +--- the same value as the order service itself reports it (GET /api/orders/config through the gateway) --- +$ curl -X GET localhost:8080/api/orders/config +HTTP 200 +{"currency":"EUR","maxQuantity":10} + diff --git a/output/02-eureka-registry.txt b/output/02-eureka-registry.txt new file mode 100644 index 0000000..1ce159b --- /dev/null +++ b/output/02-eureka-registry.txt @@ -0,0 +1,6 @@ +# What is registered in Eureka (port 8761) + +GATEWAY instance=gateway:8080 status=UP +INVENTORY-SERVICE instance=inventory-service:8083 status=UP +ORDER-SERVICE instance=order-service:8082 status=UP +PRODUCT-SERVICE instance=product-service:8081 status=UP diff --git a/output/03-gateway-requests.txt b/output/03-gateway-requests.txt new file mode 100644 index 0000000..ecad17a --- /dev/null +++ b/output/03-gateway-requests.txt @@ -0,0 +1,46 @@ +# Requests through the gateway (port 8080). The client only ever talks to 8080. + +--- products --- +$ curl -X GET localhost:8080/api/products +HTTP 200 +[{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C dock","price":129.00},{"id":3,"name":"27-inch monitor","price":249.50}] + +$ curl -X GET localhost:8080/api/products/2 +HTTP 200 +{"id":2,"name":"USB-C dock","price":129.00} + +$ curl -X GET localhost:8080/api/products/99 +HTTP 404 +{"timestamp":"2026-10-21T17:07:49.313Z","status":404,"error":"Not Found","path":"/products/99"} + +--- stock --- +$ curl -X GET localhost:8080/api/inventory/3 +HTTP 200 +{"productId":3,"quantity":3,"lowStock":true} + +--- orders --- +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":2}' +HTTP 201 +{"id":1,"productId":1,"productName":"Mechanical keyboard","quantity":2,"total":179.80,"currency":"EUR","status":"CONFIRMED"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":3,"quantity":4}' +HTTP 409 +{"timestamp":"2026-10-21T17:07:49.685Z","status":409,"error":"Conflict","path":"/orders"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":2,"quantity":11}' +HTTP 400 +{"timestamp":"2026-10-21T17:07:49.721Z","status":400,"error":"Bad Request","path":"/orders"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":99,"quantity":1}' +HTTP 404 +{"timestamp":"2026-10-21T17:07:49.763Z","status":404,"error":"Not Found","path":"/orders"} + +$ curl -X GET localhost:8080/api/orders +HTTP 200 +[{"id":1,"productId":1,"productName":"Mechanical keyboard","quantity":2,"total":179.80,"currency":"EUR","status":"CONFIRMED"}] + +--- stock after the first order --- +$ curl -X GET localhost:8080/api/inventory/1 +HTTP 200 +{"productId":1,"quantity":18,"lowStock":false} + diff --git a/output/04-trace-propagation.txt b/output/04-trace-propagation.txt new file mode 100644 index 0000000..5c8e79a --- /dev/null +++ b/output/04-trace-propagation.txt @@ -0,0 +1,12 @@ +# One request, one trace id, four services. Lines are 'service | traceId-spanId | message' from each service's log. + +--- POST /api/orders sent to the gateway; trace id 6ad8f16556352b627323a47cb0510c20 --- +gateway | 6ad8f16556352b627323a47cb0510c20-7323a47cb0510c20 | gateway received POST /api/orders +order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | placing order for product 2 x1 +product-service | 6ad8f16556352b627323a47cb0510c20-a01e14c8e2434acc | looking up product 2 +inventory-service | 6ad8f16556352b627323a47cb0510c20-419570d38f840942 | reserved 1 of product 2, 7 left +order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | order 2 is CONFIRMED + +--- a caller that already has a trace id (W3C traceparent header): the same id comes out the other end --- +gateway | 4bf92f3577b34da6a3ce929d0e0e4736-91a3561198164d6c | gateway received GET /api/products/1 +product-service | 4bf92f3577b34da6a3ce929d0e0e4736-bce2498c85b72fc7 | looking up product 1 diff --git a/output/05-circuit-breaker.txt b/output/05-circuit-breaker.txt new file mode 100644 index 0000000..cd965cb --- /dev/null +++ b/output/05-circuit-breaker.txt @@ -0,0 +1,70 @@ +# order-service -> inventory-service behind a Resilience4j circuit breaker (4-call window, 50% threshold, 5 s open) + +--- healthy: circuit state, then one order --- +$ curl -X GET localhost:8080/api/orders/circuit +HTTP 200 +{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":3,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"} + +--- switch inventory-service to failing (POST /inventory/chaos?down=true), then place 4 orders --- +$ curl -X POST localhost:8080/api/inventory/chaos?down=true +HTTP 200 +{"down":true} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":4,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":5,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":6,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":7,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X GET localhost:8080/api/orders/circuit +HTTP 200 +{"state":"OPEN","failedCalls":2,"notPermittedCalls":2} + +--- circuit is OPEN: 2 more orders. inventory reserve calls before and after prove the calls were not even attempted --- +$ curl -X GET localhost:8080/api/inventory/stats +HTTP 200 +{"reserveCalls":8} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":8,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":9,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"} + +$ curl -X GET localhost:8080/api/inventory/stats +HTTP 200 +{"reserveCalls":8} + +$ curl -X GET localhost:8080/api/orders/circuit +HTTP 200 +{"state":"OPEN","failedCalls":2,"notPermittedCalls":4} + +--- inventory-service recovers; wait 6 seconds (> the 5 s open window), then order again --- +$ curl -X POST localhost:8080/api/inventory/chaos?down=false +HTTP 200 +{"down":false} + +$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}' +HTTP 201 +{"id":10,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"} + +$ curl -X GET localhost:8080/api/orders/circuit +HTTP 200 +{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0} + diff --git a/output/06-eureka-lb-trap.txt b/output/06-eureka-lb-trap.txt new file mode 100644 index 0000000..79a0d38 --- /dev/null +++ b/output/06-eureka-lb-trap.txt @@ -0,0 +1,9 @@ +# order-service started with --shop.demo.primary-builder=false (the only RestClient.Builder is the @LoadBalanced one) + +--- the log lines that give it away --- +org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'scopedTarget.eurekaClient': Requested bean is currently in creation: Is there an unresolvable circular reference or an as +c.n.d.s.t.d.RedirectingEurekaHttpClient : Request execution error. endpoint=DefaultEndpoint{ serviceUrl='http://localhost:8761/eureka/}, exception=No instances available for localhost +com.netflix.discovery.DiscoveryClient : DiscoveryClient_ORDER-SERVICE/order-service:8092 - registration failed Cannot execute request on any known server + +--- is instance order-service:8092 in Eureka? --- +order-service:8092 registered: False diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f28fef1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm.shop + shop + 1.0.0 + pom + spring-boot-microservices-project + + + 21 + 2025.1.3 + + + + config-server + discovery-server + gateway + product-service + inventory-service + order-service + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + diff --git a/product-service/pom.xml b/product-service/pom.xml new file mode 100644 index 0000000..2fedc5f --- /dev/null +++ b/product-service/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.ankurm.shop + shop + 1.0.0 + + product-service + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-starter-config + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + org.springframework.boot + spring-boot-micrometer-tracing-brave + + + io.micrometer + micrometer-tracing-bridge-brave + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/product-service/src/main/java/com/ankurm/shop/product/Product.java b/product-service/src/main/java/com/ankurm/shop/product/Product.java new file mode 100644 index 0000000..316d430 --- /dev/null +++ b/product-service/src/main/java/com/ankurm/shop/product/Product.java @@ -0,0 +1,6 @@ +package com.ankurm.shop.product; + +import java.math.BigDecimal; + +public record Product(long id, String name, BigDecimal price) { +} diff --git a/product-service/src/main/java/com/ankurm/shop/product/ProductController.java b/product-service/src/main/java/com/ankurm/shop/product/ProductController.java new file mode 100644 index 0000000..636881a --- /dev/null +++ b/product-service/src/main/java/com/ankurm/shop/product/ProductController.java @@ -0,0 +1,38 @@ +package com.ankurm.shop.product; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +class ProductController { + private static final Logger log = LoggerFactory.getLogger(ProductController.class); + + private final Map products = Map.of( + 1L, new Product(1, "Mechanical keyboard", new BigDecimal("89.90")), + 2L, new Product(2, "USB-C dock", new BigDecimal("129.00")), + 3L, new Product(3, "27-inch monitor", new BigDecimal("249.50"))); + + @GetMapping("/products") + List all() { + log.info("listing {} products", products.size()); + return products.values().stream().sorted((a, b) -> Long.compare(a.id(), b.id())).toList(); + } + + @GetMapping("/products/{id}") + Product one(@PathVariable long id) { + log.info("looking up product {}", id); + Product p = products.get(id); + if (p == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product " + id + " not found"); + } + return p; + } +} diff --git a/product-service/src/main/java/com/ankurm/shop/product/ProductServiceApplication.java b/product-service/src/main/java/com/ankurm/shop/product/ProductServiceApplication.java new file mode 100644 index 0000000..773c1b3 --- /dev/null +++ b/product-service/src/main/java/com/ankurm/shop/product/ProductServiceApplication.java @@ -0,0 +1,11 @@ +package com.ankurm.shop.product; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ProductServiceApplication { + public static void main(String[] args) { + SpringApplication.run(ProductServiceApplication.class, args); + } +} diff --git a/product-service/src/main/resources/application.yml b/product-service/src/main/resources/application.yml new file mode 100644 index 0000000..f5d4dda --- /dev/null +++ b/product-service/src/main/resources/application.yml @@ -0,0 +1,16 @@ +spring: + application: + name: product-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: 8081 +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} diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..b218fbd --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Runs the scenarios against a running system (scripts/start.sh) and writes output/NN-*.txt. +# Every file under output/ is the real response of a real request; nothing is typed by hand. +set -uo pipefail +cd "$(dirname "$0")/.." +GW=http://localhost:8080 +OUT=output +mkdir -p $OUT run/tmp + +# curl wrapper: prints the command, then "HTTP " and the body. +call() { # file method path [json-body] + local file=$1 method=$2 path=$3 body=${4:-} args=() + [ -n "$body" ] && args=(-H 'Content-Type: application/json' -d "$body") + local code; code=$(curl -s -o run/tmp/body -w '%{http_code}' -X "$method" "${args[@]}" "$GW$path") + { printf '$ curl -X %s %s%s\n' "$method" "localhost:8080$path" "${body:+ -d '$body'}" + printf 'HTTP %s\n' "$code"; [ -s run/tmp/body ] && { cat run/tmp/body; echo; }; echo; } >> "$file" +} +section() { printf -- '--- %s ---\n' "$2" >> "$1"; } +trace_lines() { # trace id -> "service | traceId-spanId | message" for every service, in time order + for s in gateway order-service product-service inventory-service; do + grep -h "$1" run/logs/$s.log + done | sort | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[([^]]*)\] \[[^]]*\] \[([^]]*)\] [^ ]+ *: (.*)$/\1 | \2 | \3/' +} + +# The gateway and its load balancer refresh their copy of the registry every 30 seconds, +# so the first requests after startup fail (the gateway answers 500: "Unable to find instance"). Wait until the gateway routes work and the order service can reach both of its dependencies. +echo "waiting for the gateway to see all services..." +R=$OUT/00-startup-race.txt; : > $R +echo "# GET /api/orders/ready through the gateway, every 2 seconds, starting the moment the last service reported healthy." >> $R +echo "# 200 means the gateway can route to the order service AND the order service can reach product and inventory." >> $R +echo >> $R +for path in /api/products/1 /api/inventory/1 /api/orders/ready; do + start=$(date +%s) + for _ in $(seq 1 60); do + code=$(curl -s -o run/tmp/body -w '%{http_code}' $GW$path) + printf '%-20s t+%2ss HTTP %s %s\n' "$path" "$(( $(date +%s) - start ))" "$code" "$(head -c 90 run/tmp/body | tr -d '\n')" >> $R + [ "$code" = 200 ] && break + sleep 2 + done +done + +# ---- 01 config server +F=$OUT/01-config-server.txt; : > $F +echo "# What the config server hands out (port 8888). The services fetch this at startup." >> $F; echo >> $F +section $F "order-service: which files were used and the values" +curl -s localhost:8888/order-service/default | python3 -c ' +import sys, json +d = json.load(sys.stdin) +print("name:", d["name"], " profiles:", d["profiles"]) +for ps in d["propertySources"]: + print("source:", ps["name"].split("/")[-1]) + for k, v in ps["source"].items(): + print(" ", k, "=", v)' >> $F +echo >> $F +section $F "the same value as the order service itself reports it (GET /api/orders/config through the gateway)" +call $F GET /api/orders/config + +# ---- 02 registry (a service shows up in Eureka after its first registration, which can lag its startup by a few seconds) +for _ in $(seq 1 60); do + n=$(curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c 'import sys,json; print(sum(len(a["instance"]) for a in json.load(sys.stdin)["applications"]["application"]))' 2>/dev/null || echo 0) + [ "$n" -ge 4 ] && break + sleep 2 +done +F=$OUT/02-eureka-registry.txt; : > $F +echo "# What is registered in Eureka (port 8761)" >> $F; echo >> $F +curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c ' +import sys, json +for a in sorted(json.load(sys.stdin)["applications"]["application"], key=lambda a: a["name"]): + for i in a["instance"]: + print("%-18s instance=%-22s status=%s" % (a["name"], i["instanceId"], i["status"]))' >> $F + +# ---- 03 requests through the gateway +F=$OUT/03-gateway-requests.txt; : > $F +echo "# Requests through the gateway (port 8080). The client only ever talks to 8080." >> $F; echo >> $F +section $F "products" +call $F GET /api/products +call $F GET /api/products/2 +call $F GET /api/products/99 +section $F "stock" +call $F GET /api/inventory/3 +section $F "orders" +call $F POST /api/orders '{"productId":1,"quantity":2}' +call $F POST /api/orders '{"productId":3,"quantity":4}' +call $F POST /api/orders '{"productId":2,"quantity":11}' +call $F POST /api/orders '{"productId":99,"quantity":1}' +call $F GET /api/orders +section $F "stock after the first order" +call $F GET /api/inventory/1 + +# ---- 04 tracing +F=$OUT/04-trace-propagation.txt; : > $F +echo "# One request, one trace id, four services. Lines are 'service | traceId-spanId | message' from each service's log." >> $F; echo >> $F +curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -d '{"productId":2,"quantity":1}' $GW/api/orders +sleep 1 +TID=$(grep -h "placing order for product 2" run/logs/order-service.log | tail -1 | sed -E 's/.*\[([0-9a-f]{32})-[0-9a-f]{16}\].*/\1/') +section $F "POST /api/orders sent to the gateway; trace id $TID" +trace_lines "$TID" >> $F +echo >> $F +section $F "a caller that already has a trace id (W3C traceparent header): the same id comes out the other end" +CALLER=4bf92f3577b34da6a3ce929d0e0e4736 +curl -s -o /dev/null -H "traceparent: 00-$CALLER-00f067aa0ba902b7-01" $GW/api/products/1 +sleep 1 +trace_lines "$CALLER" >> $F + +# ---- 05 circuit breaker +F=$OUT/05-circuit-breaker.txt; : > $F +echo "# order-service -> inventory-service behind a Resilience4j circuit breaker (4-call window, 50% threshold, 5 s open)" >> $F; echo >> $F +section $F "healthy: circuit state, then one order" +call $F GET /api/orders/circuit +call $F POST /api/orders '{"productId":1,"quantity":1}' +section $F "switch inventory-service to failing (POST /inventory/chaos?down=true), then place 4 orders" +call $F POST '/api/inventory/chaos?down=true' +for _ in 1 2 3 4; do call $F POST /api/orders '{"productId":1,"quantity":1}'; done +call $F GET /api/orders/circuit +section $F "circuit is OPEN: 2 more orders. inventory reserve calls before and after prove the calls were not even attempted" +call $F GET /api/inventory/stats +call $F POST /api/orders '{"productId":1,"quantity":1}' +call $F POST /api/orders '{"productId":1,"quantity":1}' +call $F GET /api/inventory/stats +call $F GET /api/orders/circuit +section $F "inventory-service recovers; wait 6 seconds (> the 5 s open window), then order again" +call $F POST '/api/inventory/chaos?down=false' +sleep 6 +call $F POST /api/orders '{"productId":1,"quantity":1}' +call $F GET /api/orders/circuit +echo "demo finished; files:"; ls -1 $OUT diff --git a/scripts/gotchas.sh b/scripts/gotchas.sh new file mode 100755 index 0000000..75605c8 --- /dev/null +++ b/scripts/gotchas.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Reproduces the one trap that costs the most time: a load-balanced RestClient.Builder that Eureka's own client picks up. +# Needs the system running (scripts/start.sh). Starts a second order-service on :8092 WITHOUT the @Primary plain builder, +# captures what it logs and whether it registered, then stops it. Writes output/06-eureka-lb-trap.txt. +set -uo pipefail +cd "$(dirname "$0")/.." +unset JAVA_TOOL_OPTIONS +F=output/06-eureka-lb-trap.txt +LOG=run/logs/order-service-broken.log +nohup java -Xmx200m -XX:+UseSerialGC -jar order-service/target/order-service-1.0.0.jar \ + --server.port=8092 --shop.demo.primary-builder=false > $LOG 2>&1 & +echo $! > run/order-service-broken.pid +sleep 30 +{ + echo "# order-service started with --shop.demo.primary-builder=false (the only RestClient.Builder is the @LoadBalanced one)" + echo + echo "--- the log lines that give it away ---" + grep -m1 "BeanCurrentlyInCreationException" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220 + grep -m1 "No instances available for localhost" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220 + grep -m1 "registration failed" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220 + echo + echo "--- is instance order-service:8092 in Eureka? ---" + curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c ' +import sys, json +ids = [i["instanceId"] for a in json.load(sys.stdin)["applications"]["application"] for i in a["instance"]] +print("order-service:8092 registered:", "order-service:8092" in ids)' +} > $F +kill "$(cat run/order-service-broken.pid)" 2>/dev/null; rm -f run/order-service-broken.pid +cat $F diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..be5c981 --- /dev/null +++ b/scripts/run-all.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Build, start all six services, run the scenarios, stop everything. Regenerates output/. +# Needs JDK 21+, Maven 3.9+, curl and python3. No Docker needed. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -B -q -DskipTests package +trap 'scripts/stop.sh' EXIT +scripts/start.sh +scripts/demo.sh +scripts/gotchas.sh diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..263987b --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Starts the six services as plain jars, in dependency order, and waits until each one answers. +# Logs: run/logs/.log PIDs: run/.pid (stop with scripts/stop.sh) +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p run/logs +unset JAVA_TOOL_OPTIONS + +start() { # service port + local name=$1 port=$2 + nohup java -Xmx200m -XX:+UseSerialGC -jar "$name/target/$name-1.0.0.jar" > "run/logs/$name.log" 2>&1 & + echo $! > "run/$name.pid" + for _ in $(seq 1 90); do + if curl -fs "http://localhost:$port/actuator/health" >/dev/null 2>&1; then echo "$name up on :$port"; return; fi + sleep 1 + done + echo "$name did not come up; see run/logs/$name.log" >&2; exit 1 +} + +# the config server must be first: the others read their config from it at startup +start config-server 8888 +start discovery-server 8761 +start product-service 8081 +start inventory-service 8083 +start order-service 8082 +start gateway 8080 diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..ccf968d --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Stops whatever scripts/start.sh started, using the PID files (never pkill -f). +cd "$(dirname "$0")/.." +for f in run/*.pid; do + [ -e "$f" ] || continue + kill "$(cat "$f")" 2>/dev/null || true + rm -f "$f" +done +echo stopped