Building Microservices with Spring Boot

Project: A Two-Service System

18 min Lesson 10 of 12

Project: A Two-Service System

Throughout this tutorial you have built each piece — REST APIs, DTOs, WebClient, OpenFeign, resilience patterns, per-service databases, distributed tracing, and Docker containers — in isolation. This capstone lesson pulls everything together into a working two-service system you can run locally, observe end-to-end, and reason about as a distributed system rather than a monolith.

The Use Case: Order Service + Inventory Service

We model a small e-commerce back-end. Two autonomous services collaborate to place an order:

  • Inventory Service (port 8081) — owns the products table. Exposes a REST API to query stock levels and decrement reserved quantity.
  • Order Service (port 8080) — owns the orders table. Receives a place-order request, calls Inventory to reserve stock, and persists the order only if the reservation succeeds.

This is the classic orchestration pattern: Order Service is the orchestrator; Inventory is a participant. The boundary is intentional — neither service touches the other's database.

Why two separate databases? Shared databases are the most common way microservices silently couple over time. Each service owns its schema, can evolve it independently, and can be deployed, scaled, or replaced without affecting the other. The cost is that you cannot use a single ACID transaction across both — you must design for eventual consistency or compensating actions instead.

Project Layout

Create two independent Spring Boot projects side by side. Each is its own Maven/Gradle module with its own application.yml, its own JPA entity set, and its own Docker image.

order-system/ ├── inventory-service/ # Spring Boot 3, port 8081 │ ├── src/main/java/com/example/inventory/ │ │ ├── InventoryServiceApplication.java │ │ ├── product/ │ │ │ ├── Product.java (JPA entity) │ │ │ ├── ProductRepository.java │ │ │ ├── InventoryController.java │ │ │ └── ReserveRequest.java (DTO) │ └── src/main/resources/application.yml └── order-service/ # Spring Boot 3, port 8080 ├── src/main/java/com/example/order/ │ ├── OrderServiceApplication.java │ ├── order/ │ │ ├── Order.java (JPA entity) │ │ ├── OrderRepository.java │ │ ├── OrderController.java │ │ ├── OrderService.java │ │ └── PlaceOrderRequest.java (DTO) │ └── client/ │ └── InventoryClient.java (OpenFeign or WebClient) └── src/main/resources/application.yml

Inventory Service — Core Code

The entity and reservation endpoint are straightforward. The key design choice is that reserve is an idempotent, side-effecting POST: callers send how many units they want; the service atomically checks and decrements with a @Transactional pessimistic lock.

// Product.java @Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int stockQuantity; // getters, setters } // ReserveRequest.java — DTO (record) public record ReserveRequest(Long productId, int quantity) {} // InventoryController.java @RestController @RequestMapping("/inventory") public class InventoryController { private final ProductRepository repo; public InventoryController(ProductRepository repo) { this.repo = repo; } @GetMapping("/{id}/stock") public ResponseEntity<Integer> getStock(@PathVariable Long id) { return repo.findById(id) .map(p -> ResponseEntity.ok(p.getStockQuantity())) .orElse(ResponseEntity.notFound().build()); } @PostMapping("/reserve") @Transactional public ResponseEntity<String> reserve(@RequestBody ReserveRequest req) { Product p = repo.findByIdWithLock(req.productId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); if (p.getStockQuantity() < req.quantity()) { return ResponseEntity.status(HttpStatus.CONFLICT).body("Insufficient stock"); } p.setStockQuantity(p.getStockQuantity() - req.quantity()); repo.save(p); return ResponseEntity.ok("Reserved"); } }
// ProductRepository.java — pessimistic lock to prevent overselling public interface ProductRepository extends JpaRepository<Product, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithLock(@Param("id") Long id); }
Pessimistic vs optimistic locking for inventory: Inventory is a classic contention hotspot — many concurrent orders can target the same product. Pessimistic locking (a SELECT ... FOR UPDATE at the DB level) is simpler to reason about here. For lower-contention data, optimistic locking with @Version gives better throughput.

Order Service — Feign Client and Orchestration

Order Service declares the Inventory call as a Feign interface. This keeps HTTP concerns out of the business logic, and Spring Cloud OpenFeign handles retries, timeouts, and error decoding in one place.

// InventoryClient.java @FeignClient(name = "inventory-service", url = "${inventory.service.url}") public interface InventoryClient { @PostMapping("/inventory/reserve") ResponseEntity<String> reserve(@RequestBody ReserveRequest req); }
// OrderService.java — orchestrates the use case @Service public class OrderService { private final OrderRepository orderRepo; private final InventoryClient inventoryClient; public OrderService(OrderRepository orderRepo, InventoryClient inventoryClient) { this.orderRepo = orderRepo; this.inventoryClient = inventoryClient; } @Transactional public Order placeOrder(PlaceOrderRequest req) { // 1. Reserve stock — call Inventory Service ResponseEntity<String> reservationResponse = inventoryClient.reserve(new ReserveRequest(req.productId(), req.quantity())); if (!reservationResponse.getStatusCode().is2xxSuccessful()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Inventory reservation failed: " + reservationResponse.getBody()); } // 2. Persist the order only after a successful reservation Order order = new Order(); order.setProductId(req.productId()); order.setQuantity(req.quantity()); order.setStatus("CONFIRMED"); order.setCreatedAt(Instant.now()); return orderRepo.save(order); } }
The dual-write problem. There is a window between a successful reservation and the orderRepo.save() call where a crash leaves stock decremented but no order record created. A full solution requires either the Saga pattern (a compensating transaction that un-reserves the stock on failure) or an outbox pattern (write both the order and a pending event in one local transaction, then publish asynchronously). For this project, document the limitation and add a compensating endpoint to Inventory — it is more honest than pretending the problem does not exist.

Propagating the Correlation ID

With two services, a single user request spawns two log streams. A correlation ID ties them together. Order Service generates one if absent, stores it in MDC, and forwards it as an HTTP header to Inventory.

// CorrelationFilter.java (Order Service — also add to Inventory Service) @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class CorrelationFilter implements Filter { private static final String HEADER = "X-Correlation-Id"; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; String correlationId = httpReq.getHeader(HEADER); if (correlationId == null || correlationId.isBlank()) { correlationId = UUID.randomUUID().toString(); } MDC.put("correlationId", correlationId); HttpServletResponse httpRes = (HttpServletResponse) res; httpRes.setHeader(HEADER, correlationId); try { chain.doFilter(req, res); } finally { MDC.clear(); } } }

Add a Feign request interceptor in Order Service to forward the header on every outgoing call:

@Bean public RequestInterceptor correlationInterceptor() { return template -> { String id = MDC.get("correlationId"); if (id != null) { template.header("X-Correlation-Id", id); } }; }

Running Both Services with Docker Compose

Each service has a Dockerfile (multi-stage, JDK 21 slim). The docker-compose.yml at the root brings everything up with one command:

# docker-compose.yml version: "3.9" services: inventory-db: image: postgres:16-alpine environment: POSTGRES_DB: inventory POSTGRES_USER: inv_user POSTGRES_PASSWORD: inv_pass ports: ["5433:5432"] order-db: image: postgres:16-alpine environment: POSTGRES_DB: orders POSTGRES_USER: ord_user POSTGRES_PASSWORD: ord_pass ports: ["5434:5432"] inventory-service: build: ./inventory-service ports: ["8081:8081"] environment: SPRING_DATASOURCE_URL: jdbc:postgresql://inventory-db:5432/inventory SPRING_DATASOURCE_USERNAME: inv_user SPRING_DATASOURCE_PASSWORD: inv_pass depends_on: [inventory-db] order-service: build: ./order-service ports: ["8080:8080"] environment: SPRING_DATASOURCE_URL: jdbc:postgresql://order-db:5432/orders SPRING_DATASOURCE_USERNAME: ord_user SPRING_DATASOURCE_PASSWORD: ord_pass INVENTORY_SERVICE_URL: http://inventory-service:8081 depends_on: [order-db, inventory-service]

End-to-End Verification

After docker compose up --build, verify the happy path and the failure path:

# Place an order (product 1, 2 units) curl -s -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":2}' | jq . # Try to over-order (should return 409 Conflict) curl -s -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":9999}' | jq . # Check the correlation ID flows across services curl -v -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":1}' 2>&1 | grep X-Correlation

Search both service log streams for the same correlation ID value to confirm end-to-end tracing is working.

What to Extend Next

This two-service system is a foundation, not a destination. Natural next steps include: adding a Saga orchestrator or choreography with events to handle the dual-write problem; introducing a Spring Cloud Gateway as a single entry point; adding Micrometer + Prometheus + Grafana for metrics; and wiring in a Config Server so neither service hard-codes its database URL or peer address.

Summary

You built a two-service system where Order Service orchestrates Inventory Service over HTTP, each service owns its own database, a correlation ID flows across the network boundary, and both services run together in Docker Compose. More importantly, you experienced the trade-offs first-hand: the dual-write window, the need for compensating logic, and the operational discipline required when a single business transaction spans two autonomous processes. Those trade-offs are what microservices architecture is really about.