Skip to content

02 · Microservices Architecture Patterns

A monolith becomes hard to scale and deploy independently once a team and codebase grow large enough. Microservices split an application into small, independently deployable services — at the cost of new problems: how do clients find services, how do failures get contained, and how do transactions work across service boundaries? This module covers the patterns that answer those questions.

API Gateway

Instead of clients calling every microservice directly, they call a single API Gateway that routes requests to the right backend service, and centralizes cross-cutting concerns: authentication, rate limiting, request logging, and response aggregation.

                     ┌───────────────┐
   mobile app  ───▶  │  API Gateway   │
   web app     ───▶  │ (routing, auth)│
                     └───────┬───────┘
                 ┌───────────┼───────────┐
                 ▼           ▼           ▼
           Order Service  User Service  Inventory Service

A gateway route (conceptually, using Spring Cloud Gateway style config):

# gateway routes
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/orders/**
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/users/**

Clients only need to know one hostname; the gateway hides how many services exist behind it and can change routing without clients noticing.

Service discovery

In a monolith, calling another module is a Java method call. In microservices, calling another service means finding where an instance of it currently runs — and instances come and go as they scale, restart, or get redeployed. A service registry (Eureka, Consul, or Kubernetes' built-in DNS-based discovery) keeps a live directory of healthy instances, so a service asks "who is ORDER-SERVICE right now?" instead of hardcoding an IP.

// Using a discovery-aware RestTemplate/WebClient -- "order-service"
// resolves to a live instance via the registry, not a hardcoded host:port
@Service
public class InventoryClient {
    private final WebClient webClient;

    public InventoryClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://order-service").build();
    }

    public Mono<Order> fetchOrder(Long id) {
        return webClient.get().uri("/orders/{id}", id).retrieve().bodyToMono(Order.class);
    }
}

Circuit Breaker (Resilience4j)

When a downstream service is slow or down, calling it repeatedly wastes threads and cascades the failure upstream. A circuit breaker tracks failure rates and "opens" — short-circuiting calls immediately with a fallback — once failures cross a threshold, giving the downstream service time to recover.

<!-- pom.xml -->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
// InventoryService.java
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;

@Service
public class InventoryService {

    private final InventoryClient inventoryClient;

    public InventoryService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }

    @CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackStock")
    public int checkStock(String sku) {
        return inventoryClient.getStockLevel(sku);   // may throw if the service is down
    }

    // Fallback signature must match + accept the Throwable
    private int fallbackStock(String sku, Throwable t) {
        return -1;   // sentinel meaning "unknown, service is degraded"
    }
}
# application.yml
resilience4j:
  circuitbreaker:
    instances:
      inventoryService:
        sliding-window-size: 20
        failure-rate-threshold: 50   # percent
        wait-duration-in-open-state: 10s

Once 50% of the last 20 calls fail, the circuit opens: further calls skip straight to fallbackStock without hitting the network, until the wait duration passes and it tries a few "trial" requests again.

Saga pattern for distributed transactions

A single database transaction can't span multiple services, each with their own database. The Saga pattern replaces one big transaction with a sequence of local transactions, each publishing an event that triggers the next step — and if a step fails, compensating actions undo the previous steps.

1. Order Service:      create order (PENDING)        --publishes--> OrderCreated
2. Payment Service:    charge card                    --publishes--> PaymentCompleted
3. Inventory Service:  reserve stock                   --publishes--> StockReserved
4. Order Service:      mark order CONFIRMED

If step 2 fails (card declined):
2'. Order Service:     compensate -- mark order CANCELLED
// Simplified saga step -- reacts to an event, does local work, emits the next event
@Service
public class PaymentSagaHandler {

    @EventListener
    public void on(OrderCreatedEvent event) {
        try {
            paymentService.charge(event.orderId(), event.amount());
            eventPublisher.publishEvent(new PaymentCompletedEvent(event.orderId()));
        } catch (PaymentDeclinedException e) {
            // compensating action: tell the order service to roll back
            eventPublisher.publishEvent(new PaymentFailedEvent(event.orderId()));
        }
    }
}

Sagas trade strict ACID consistency for eventual consistency — every step must be idempotent (safe to retry) since messages can be redelivered.

Database-per-service

Each microservice owns its own database schema — no other service reads or writes it directly. If the Inventory service needs order data, it asks the Order service's API (or subscribes to its events); it never queries the orders table directly. This keeps services independently deployable: an Order schema migration can't accidentally break Inventory.

Pattern Solves Trade-off
API Gateway Single entry point, cross-cutting concerns Gateway becomes a critical path — must scale it too
Service discovery Finding live instances dynamically Extra moving part (the registry) to keep healthy
Circuit breaker Stopping cascading failures Fallbacks must degrade gracefully, not just error
Saga Transactions across services Eventual consistency, requires idempotent, compensable steps
Database-per-service Independent deployability No cross-service joins; more data duplication/events

See Level 3, Module 4 for the REST API fundamentals these services are built on, and Module 6 for the event backbone that saga choreography and service-to-service events typically run on.

How It Actually Works

Each service instance is a separate JVM process with its own heap, GC, and class loader hierarchy — there is no shared object graph or shared memory the way there is between classes in one monolith, so "communication" is necessarily serialization over a network boundary (JSON over HTTP, protobuf over gRPC). This is the mechanical reason a network call between two "internal" services can fail in ways a same-JVM method call structurally cannot: partial failure, timeout, and serialization mismatches replace a call stack that used to just throw.

Service discovery mechanically works by services registering their network address (host:port) with a registry (Eureka, Consul, or Kubernetes' own DNS-based service abstraction) on startup and periodically renewing a lease/heartbeat; clients resolve a logical service name to a live address at call time rather than hardcoding one, which is what actually allows instances to scale up/down or move without client reconfiguration.

Circuit breakers (Resilience4j) are a state machine, not a metaphor: CLOSED (calls pass through, failures counted in a sliding window) → OPEN (calls fail fast without attempting the network call at all, once the failure-rate threshold trips) → HALF_OPEN (a limited number of trial calls test recovery) → back to CLOSED or OPEN — genuinely avoiding wasted timeouts against a downstream service that's already down, not just a config knob.

Exercise

Sketch (in code comments or a short text diagram) a 3-step saga for placing a food delivery order: Order Service creates the order, Restaurant Service accepts or rejects it, Delivery Service assigns a courier. Write the compensating action for the case where the restaurant rejects the order after a courier has already been tentatively assigned. Then annotate a Java method assignCourier(Long orderId) with @CircuitBreaker and write a fallback method that returns null when the delivery service is unreachable.