Every microservices book lists thirty patterns. In practice you use the same eight. This article is about those eight — the ones that show up on every serious project, why they exist, and how to implement each one cleanly in Java. Skip the ones below and you’ll build a distributed monolith. Use them all blindly and you’ll build a gold-plated distributed monolith. The trick is knowing which problem each one solves, so you apply it only when you have that problem.

The map

  ┌──────────────────────────────────────────────────────────────────┐
  │                        CLIENT INTEGRATION                         │
  │   1. API Gateway        2. Backend for Frontend (BFF)             │
  ├──────────────────────────────────────────────────────────────────┤
  │                         COMMUNICATION                             │
  │   3. Saga              4. Event-Driven / Choreography             │
  ├──────────────────────────────────────────────────────────────────┤
  │                          RESILIENCE                               │
  │   5. Circuit Breaker    6. Bulkhead                               │
  ├──────────────────────────────────────────────────────────────────┤
  │                         DATA INTEGRITY                            │
  │   7. Transactional Outbox    8. CQRS                              │
  └──────────────────────────────────────────────────────────────────┘

That’s the whole taxonomy worth memorizing. Each pattern addresses a specific pain point you hit as the system grows.

1. API Gateway — one front door

Problem: Clients shouldn’t know about ten services. Auth, rate limiting, TLS, and CORS shouldn’t be solved ten times.

Pattern: Put a gateway in front. Clients hit one endpoint. The gateway handles cross-cutting concerns and routes to services.

  [Mobile]    [Web]    [Partner API]
       \        |         /
        \       |        /
         ┌──────▼───────┐
         │   Gateway    │   auth, rate limit, routing,
         └──────┬───────┘   TLS termination, logging

   ┌────────────┼────────────┐
   │            │            │
[Orders]   [Catalog]    [Payments]

Spring Cloud Gateway, minimal config:

@Bean
RouteLocator routes(RouteLocatorBuilder b) {
    return b.routes()
        .route("orders", r -> r.path("/api/orders/**")
            .filters(f -> f
                .stripPrefix(1)
                .requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter()))
                .circuitBreaker(c -> c.setName("orders").setFallbackUri("forward:/fallback")))
            .uri("lb://orders-service"))
        .build();
}

When to add one: as soon as you have more than two services or more than one client type. Earlier than people think.

When not to: if you have one service, the gateway just adds a network hop.

2. Backend for Frontend (BFF) — a gateway per client type

Problem: A mobile app needs lightweight responses with image thumbnails. A web app needs detailed data with pre-computed aggregates. A gateway serving both becomes a Frankenstein of ?include= parameters.

Pattern: One BFF per client type. Each BFF aggregates from core services and shapes responses for its specific client.

  [Mobile app] ──▶ [Mobile BFF] ─┐
                                 ├──▶ [Core services]
  [Web app]    ──▶ [Web BFF]    ─┘

A BFF typically does parallel fan-out and response shaping:

@GetMapping("/home-feed")
public Mono<HomeFeedResponse> homeFeed(@RequestParam String userId) {
    return Mono.zip(
        userClient.profile(userId),
        ordersClient.recent(userId, 5),
        recommendationsClient.topPicks(userId, 10)
    ).map(t -> new HomeFeedResponse(
        t.getT1().name(),
        t.getT2().stream().map(OrderSummary::from).toList(),
        t.getT3().stream().map(Product::toThumbnailView).toList()
    ));
}

When to add one: when your single gateway starts carrying client-specific logic that has nothing to do with routing.

3. Saga — multi-service business transactions

Problem: Placing an order touches inventory, payments, and shipping. You can’t wrap that in @Transactional — they’re in different services with different databases.

Pattern: Model the workflow as a sequence of local transactions, each with a compensating action that undoes it if a later step fails.

  reserve-inventory ──▶ charge-card ──▶ schedule-shipping
        │                    │                │
        ▼                    ▼                ▼
   on fail: (nothing)   on fail:         on fail:
                        release-inv      release-inv,
                                         refund-card

Two flavors:

  • Orchestrated saga — a coordinator service drives the flow. Easy to see the state machine.
  • Choreographed saga — services react to each other’s events. Looser coupling, harder to trace.

Orchestrated example with a simple state machine:

@Service
public class PlaceOrderSaga {
    public SagaResult run(PlaceOrderCommand cmd) {
        String reservationId = null;
        String paymentId = null;
        try {
            reservationId = inventoryClient.reserve(cmd.items());
            paymentId = paymentClient.charge(cmd.amount(), cmd.customerId());
            shippingClient.schedule(cmd.orderId(), cmd.address());
            return SagaResult.ok();
        } catch (PaymentFailedException e) {
            inventoryClient.release(reservationId);
            return SagaResult.failed("payment_failed");
        } catch (ShippingFailedException e) {
            paymentClient.refund(paymentId);
            inventoryClient.release(reservationId);
            return SagaResult.failed("shipping_failed");
        }
    }
}

For anything non-trivial use a proper state machine library (Spring Statemachine, Temporal). Hand-rolled sagas get tangled fast.

When: whenever a business operation spans more than one service and must be atomic from the user’s perspective.

4. Event-Driven / Choreography

Problem: Services tightly coupled via synchronous calls become fragile. A dependency being slow takes down everyone upstream.

Pattern: Services publish events about facts (“OrderPlaced”, “PaymentSettled”). Other services subscribe to events they care about. No one calls anyone directly for state changes.

  Orders ──publishes──▶ OrderPlaced ──▶ Payments   (charges)
                                    ──▶ Email      (sends confirmation)
                                    ──▶ Analytics  (records)
                                    ──▶ Inventory  (reserves)

Adding a new consumer later is zero-risk — Orders doesn’t know it exists.

@Component
public class EmailListener {
    @KafkaListener(topics = "order.placed", groupId = "email")
    public void onOrderPlaced(OrderPlaced event) {
        emailService.sendConfirmation(event.customerId(), event.orderId());
    }
}

Event design rules that keep this maintainable:

  • Events describe facts, not commands. OrderPlaced, not SendEmail.
  • Events are immutable. Never mutate after publish. Version via new event types (OrderPlacedV2).
  • Schemas belong in a registry. Avro or Protobuf in Confluent/Apicurio Schema Registry. Enforce compatibility.
  • Include enough context for consumers to not call back. A consumer forced to ask the producer for details every time is a synchronous call in disguise.

When: for anything that isn’t a request/response query. State changes almost always want to be events.

5. Circuit Breaker

Problem: A downstream service is slow or down. Every request you send it hangs, then fails. Your threads pile up on it. Your service goes down too.

Pattern: Wrap the call. After N failures, open the circuit: fail fast for the next T seconds without even trying. Periodically test with a single request; if it succeeds, close the circuit again.

  CLOSED   ──(failures ≥ threshold)──▶   OPEN

                                          │ (wait N seconds)

  CLOSED   ◀──(test call succeeds)──   HALF-OPEN

                                          │ (test call fails)

                                         OPEN

Resilience4j annotation:

@CircuitBreaker(name = "payments", fallbackMethod = "chargeFallback")
@Retry(name = "payments")
@TimeLimiter(name = "payments")
public CompletableFuture<PaymentResult> charge(UUID orderId, BigDecimal amount) {
    return CompletableFuture.supplyAsync(() -> paymentsClient.charge(orderId, amount));
}

public CompletableFuture<PaymentResult> chargeFallback(UUID orderId, BigDecimal amount, Throwable t) {
    return CompletableFuture.completedFuture(
        PaymentResult.deferred("payments_unavailable", orderId));
}

Configured in application.yml:

resilience4j:
  circuitbreaker:
    instances:
      payments:
        failure-rate-threshold: 50
        sliding-window-size: 20
        minimum-number-of-calls: 10
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3

Fallbacks should be intentional. A fallback that returns fake data silently is worse than an error. Prefer: queue the operation, return a cached value, or return a specific error code the client can handle.

When: around every out-of-process synchronous call. No exceptions.

6. Bulkhead — isolate resources by dependency

Problem: Your service calls five downstreams. One becomes slow. It holds every thread in your pool. Requests that don’t touch the slow downstream queue too.

Pattern: Separate resource pools per dependency. A slow downstream can only exhaust its own pool.

  ┌─────────────────────────────────────────────┐
  │                  Service                     │
  │                                             │
  │  [pool A: 50]   [pool B: 50]   [pool C: 50] │
  │       │              │              │       │
  │       ▼              ▼              ▼       │
  └──[ Payments ]───[ Catalog ]───[ Shipping ]──┘

  If Catalog is slow, only pool B saturates.
  Payments and Shipping calls still flow.

Resilience4j semaphore bulkhead:

resilience4j:
  bulkhead:
    instances:
      payments:
        max-concurrent-calls: 30
        max-wait-duration: 100ms
      catalog:
        max-concurrent-calls: 100
        max-wait-duration: 50ms
@Bulkhead(name = "payments")
public PaymentResult charge(...) { ... }

For DB calls, give slow-query endpoints a separate HikariCP pool or a dedicated read replica. Don’t let report queries starve transactional endpoints.

When: when you have multiple downstreams with different SLAs or failure profiles.

7. Transactional Outbox

Problem: You need to save a row and publish an event. If you do save() then kafka.send(), a crash in between leaves the DB changed but no event. If you send() first, you publish events that never get persisted.

Pattern: Write the event to an outbox table in the same DB transaction as the business row. A separate publisher reads the outbox and sends to Kafka, then marks the row published.

  ┌───────────────────────────┐
  │     DB transaction        │
  │   ┌──────────────────┐    │
  │   │  orders INSERT   │    │
  │   └──────────────────┘    │
  │   ┌──────────────────┐    │    (same commit)
  │   │  outbox INSERT   │    │
  │   └──────────────────┘    │
  └───────────────────────────┘

             │ poller / CDC

        ┌─────────┐
        │  Kafka  │
        └─────────┘

Business code — atomic write:

@Transactional
public Order placeOrder(CreateOrderRequest req) {
    Order order = orderRepo.save(Order.from(req));

    OutboxMessage msg = new OutboxMessage(
        UUID.randomUUID(),
        "order.placed",
        order.getId().toString(),
        json.writeValueAsString(OrderPlacedEvent.from(order))
    );
    outboxRepo.save(msg);

    return order;
}

Publisher — runs on a schedule, polls unpublished rows:

@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
    List<OutboxMessage> batch = outboxRepo.findUnpublished(PageRequest.of(0, 100));
    for (OutboxMessage msg : batch) {
        kafka.send(msg.getTopic(), msg.getAggregateId(), msg.getPayload()).get();
        msg.setPublishedAt(Instant.now());
    }
}

The alternative: Change Data Capture. Tools like Debezium tail the DB’s write-ahead log and stream changes to Kafka. Zero polling, exactly-once at the DB level. Heavier to operate but the gold standard for high-throughput systems.

When: any time you write to the DB and publish an event. No exceptions.

8. CQRS — separate read and write models

Problem: Your write model is normalized for correctness. Your read queries need denormalized, joined, aggregated data. Serving both from the same tables produces either slow reads or complicated writes.

Pattern: Split the models. Writes go to one database shape. Reads come from a different shape, kept in sync by events.

   ┌──────────────┐                         ┌──────────────┐
   │ Write Model  │   events via Kafka      │  Read Model  │
   │ (normalized, │ ──────────────────────▶ │ (denormalized│
   │  Postgres)   │                         │  projections)│
   └──────────────┘                         └──────────────┘
        ▲                                          │
        │ commands                                 │ queries
        │                                          ▼
     [Client writes]                          [Client reads]

Write side stays simple:

@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = Order.from(cmd);
    orderRepo.save(order);
    outbox.publish("order.placed", OrderPlaced.from(order));
}

Read side subscribes and projects into whatever shape the UI needs — could be Elasticsearch for search, Postgres with denormalized joins for lists, Redis for a counter:

@KafkaListener(topics = "order.placed", groupId = "order-projection")
public void onOrderPlaced(OrderPlaced event) {
    OrderListItem view = new OrderListItem(
        event.orderId(),
        event.customerName(),
        event.itemSummary(),
        event.totalAmount(),
        event.placedAt()
    );
    orderListRepo.save(view);

    redis.opsForValue().increment("orders:count:" + event.customerId());
}

Trade-offs:

  • Read model is eventually consistent. If the user expects to see their own write immediately, either read from the write side for their own data, or wait for projection lag.
  • You maintain projections. Every new UI need = a new projection. That’s a feature, not a bug, but it has a cost.
  • Storage cost goes up. Same data in multiple shapes.

When: when your read patterns differ so much from your write patterns that optimizing for both in one schema produces awkwardness for both. Don’t apply CQRS to CRUD.

Anti-patterns to avoid

Patterns become anti-patterns when applied blindly:

  • Nano-services. A service per class or per database table. You bought all the microservices overhead for none of the benefits.
  • Shared database across services. The single most common way teams accidentally rebuild a distributed monolith.
  • Synchronous chain of six services per request. Every hop compounds latency and failure. Flatten with events or caching.
  • Every service uses every pattern. A tiny internal service doesn’t need CQRS. Start simple, add patterns when pain justifies them.
  • Custom framework for patterns. Resilience4j, Spring Cloud, and Kafka Streams exist. Don’t write your own circuit breaker.

Choosing patterns: a decision guide

  • Crossing service boundaries with a request? → Gateway, Circuit Breaker, Bulkhead
  • Different clients with different needs? → BFF
  • Multi-service business workflow that must be atomic? → Saga
  • Publishing a state change? → Event-Driven + Outbox
  • Reads vs writes have different shapes? → CQRS

Apply in this order: resilience first (circuit breakers + timeouts save more systems than any other pattern), then communication (gateway + events), then data integrity (outbox), then advanced (CQRS, saga orchestration) when you’ve earned the complexity.

Checklist: which patterns does your system need?

  • At least one gateway in front of the services
  • Every external call behind a circuit breaker with a deliberate fallback
  • Bulkheads for services with multiple downstream dependencies
  • Transactional outbox (or CDC) wherever DB writes produce events
  • Saga with compensations for any multi-service workflow
  • Event schemas in a registry with compatibility rules enforced
  • Idempotency keys on every write endpoint and every event handler
  • CQRS only where read and write shapes genuinely diverge

Closing thought

Patterns aren’t features you collect. They’re answers to specific failure modes. When you apply a pattern, you should be able to say: “I’m adding this because X broke in this specific way.” If you can’t, you’re cargo-culting — and the complexity cost will outlast the supposed benefit. The best microservices systems look boring on the inside: a small set of patterns, used consistently, with clear reasons for each one.