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-cardTwo 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, notSendEmail. - 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)
▼
OPENResilience4j 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: 3Fallbacks 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.