Microservices Architecture & Design

Project: Designing a Microservices System

18 min Lesson 10 of 12

Project: Designing a Microservices System

Every principle covered in this tutorial — bounded contexts, database-per-service, synchronous and asynchronous communication, saga coordination, cross-cutting concerns — must eventually be reconciled in a single coherent design. This lesson walks through the complete decomposition of a realistic e-commerce domain into well-bounded services, explains every decision at the level a working developer needs, and shows how the resulting services connect as runnable Spring Boot 3 components.

The Domain: an Online Marketplace

The business operates a marketplace where sellers list products, customers place orders, a payment processor charges cards, and a fulfilment team ships packages. Additional capabilities include search, notifications, and an admin reporting portal. Before writing a single class you need to map the domain using Event Storming — list every domain event in chronological order, then colour-code them by business capability to reveal natural service boundaries.

Key domain events identified:

  • ProductListedByseller, ProductUpdated, ProductDeactivated
  • CartItemAdded, CheckoutInitiated
  • OrderPlaced, OrderConfirmed, OrderCancelled
  • PaymentAuthorised, PaymentFailed, RefundIssued
  • ShipmentCreated, PackageDispatched, DeliveryConfirmed
  • CustomerRegistered, EmailVerified, PasswordChanged

Grouping events by the team that owns them — not by technical similarity — yields the following bounded contexts:

Decomposition into Services

Six services emerge from the event map. Each owns its own database, exposes a REST API for synchronous reads, and publishes/consumes events over Kafka for state-changing operations.

  • Catalog Service — CRUD for products; Elasticsearch index for search. Publishes ProductUpdated.
  • Order Service — shopping cart and order lifecycle. Publishes OrderPlaced, OrderCancelled; listens to PaymentAuthorised, ShipmentCreated.
  • Payment Service — payment gateway integration. Publishes PaymentAuthorised, PaymentFailed.
  • Inventory Service — stock reservation. Listens to OrderPlaced; publishes StockReserved, StockInsufficient.
  • Fulfilment Service — shipping labels and carrier integration. Listens to PaymentAuthorised; publishes PackageDispatched.
  • Identity Service — user accounts, JWT issuance, password management. All other services validate tokens from here.
Size heuristic: a service is well-sized when a single developer can hold its entire domain model in their head, a team can deploy it independently, and changing it requires no coordination with other teams. If any of those three fail, the boundary is wrong.

Order Service — Skeleton

The Order Service demonstrates the canonical Spring Boot 3 structure. It exposes a REST API for the checkout flow and publishes domain events to Kafka for downstream services.

// OrderService.java — application service (not a Spring @Service for clarity) package com.marketplace.order.application; import com.marketplace.order.domain.*; import com.marketplace.order.events.OrderEventPublisher; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final OrderEventPublisher eventPublisher; @Transactional public Order placeOrder(PlaceOrderCommand cmd) { Order order = Order.create(cmd.customerId(), cmd.items()); orderRepository.save(order); // publish AFTER the DB commit to avoid phantom events eventPublisher.publish(new OrderPlacedEvent(order.getId(), order.getItems())); return order; } @Transactional public void confirmPayment(OrderId orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); order.markPaymentConfirmed(); orderRepository.save(order); } }
Publish events after the commit. If you publish to Kafka inside the transaction and the commit later fails, downstream services have already acted on a ghost event. Use a transactional outbox (or Spring's @TransactionalEventListener(phase = AFTER_COMMIT)) to guarantee exactly-once publication relative to your own DB write.

Kafka Event Contracts

Event schemas are your public API. Use Avro with a Schema Registry so incompatible changes are caught at build time, not at runtime on production. A minimal Avro schema for OrderPlaced:

{ "namespace": "com.marketplace.events", "type": "record", "name": "OrderPlaced", "fields": [ { "name": "orderId", "type": "string" }, { "name": "customerId", "type": "string" }, { "name": "occurredAt", "type": "long", "logicalType": "timestamp-millis" }, { "name": "items", "type": { "type": "array", "items": { "type": "record", "name": "OrderItem", "fields": [ { "name": "productId", "type": "string" }, { "name": "quantity", "type": "int" }, { "name": "unitPrice", "type": "string" } ] } }} ] }

The Inventory Service listens on the same topic. Its consumer is idempotent — if the same OrderPlaced message is delivered twice (Kafka guarantees at-least-once), the second attempt sees the reservation already exists and does nothing:

// InventoryEventConsumer.java @Component @RequiredArgsConstructor public class InventoryEventConsumer { private final StockReservationRepository reservationRepo; private final StockReservationService reservationService; @KafkaListener(topics = "order.placed", groupId = "inventory-service") @Transactional public void onOrderPlaced(OrderPlaced event) { if (reservationRepo.existsByOrderId(event.getOrderId())) { return; // idempotency guard } reservationService.reserve(event.getOrderId(), event.getItems()); } }

The Checkout Saga

Placing an order is a multi-service saga: Order → Inventory → Payment → Fulfilment. Use a choreography-based saga for this flow — no central orchestrator, each service reacts to events and publishes compensating events on failure:

  1. Order Service publishes OrderPlaced.
  2. Inventory Service reserves stock → publishes StockReserved or StockInsufficient.
  3. Payment Service charges card → publishes PaymentAuthorised or PaymentFailed.
  4. On any failure, compensating events roll back prior steps (StockReleased, OrderCancelled).
  5. On full success, Fulfilment Service creates a shipment.

Cross-Service Security: JWT Propagation

The Identity Service issues a signed JWT on login. Every other service validates the token locally with the Identity Service's public key — no round-trip on each request. Spring Security 6 makes this one property:

# application.yml — Catalog Service (and every other resource server) spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://identity-service/auth/.well-known/jwks.json
// SecurityConfig.java — same for all resource servers @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain api(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) // stateless API .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } }
Service-to-service calls must also carry a token. When Order Service calls Inventory Service's REST endpoint directly (e.g. for a synchronous stock check), it must attach a service account JWT, not the end-user token. Use a ClientCredentials grant (machine-to-machine) so Inventory Service can audit which caller made the request.

Spring Cloud Gateway — The Single Entry Point

Expose exactly one public hostname. Spring Cloud Gateway routes requests to the correct downstream service, enforces authentication at the edge, and strips internal headers that clients should never see:

# gateway application.yml spring: cloud: gateway: routes: - id: catalog uri: lb://catalog-service # resolved by Eureka predicates: - Path=/api/v1/products/** filters: - RemoveRequestHeader=X-Internal-User-Id - AddRequestHeader=X-Gateway-Version, 1 - id: order uri: lb://order-service predicates: - Path=/api/v1/orders/** filters: - TokenRelay= # forward JWT to downstream

Observability Wiring

In a distributed system a single user request fans out across multiple services. Without correlated tracing you cannot reconstruct what happened. Add Micrometer Tracing with Zipkin to every service — Spring Boot 3 autoconfigures this from two dependencies:

<!-- pom.xml — add to every service --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-zipkin</artifactId> </dependency>

Every log line automatically includes a traceId and spanId. Aggregate logs in a central store (ELK, Loki) and filter by traceId to follow a checkout request across Order, Inventory, Payment, and Fulfilment services in one query.

Design Decisions Recap

  • Six services aligned to business capabilities, not technical layers.
  • Database per service: PostgreSQL for Order/Payment/Fulfilment, MongoDB for Catalog, Redis for Inventory (fast reservation checks), MySQL for Identity.
  • Async by default: state-changing operations go through Kafka; synchronous REST is reserved for queries that need an immediate response.
  • Choreography saga for the checkout flow; orchestration saga would be preferable if the number of steps exceeds six or compensation logic becomes complex.
  • JWT at the edge and between services — no session state anywhere.
  • One gateway — clients never know service topology; internal URLs are opaque.

With this architecture in place, any single service can be redeployed, scaled horizontally, or replaced without touching the others — which is the defining promise of the microservices style.