DEV Community

Cover image for Microservices Architecture Patterns Cheat Sheet: Circuit Breaker, Saga, Outbox, Bulkhead & CQRS
The Architect
The Architect

Posted on

Microservices Architecture Patterns Cheat Sheet: Circuit Breaker, Saga, Outbox, Bulkhead & CQRS

Building scalable, resilient distributed systems requires navigating complex trade-offs around state, network partitions, and service failures. When migrating from monoliths to microservices, traditional single-database ACID guarantees disappear, and network latencies become variable.

This guide serves as an architectural cheat sheet covering six core design patterns for distributed systems: Circuit Breaker, Two-Phase Commit (2PC), Saga Pattern, Transactional Outbox with Debezium CDC, Bulkhead Pattern, and CQRS.


1. Circuit Breaker Pattern

The Circuit Breaker pattern is a fail-fast strategy designed to prevent cascading failures across distributed services. When an upstream dependency degrades or fails, the circuit breaker opens to bypass requests to that service for a predetermined recovery window.

Why Do We Need It?

In distributed systems, a delayed response is far worse than a failed response. If a downstream service hangs for 30 seconds before timing out, callers exhaust thread pools, database connection pools, and memory while waiting for a response that will likely fail anyway. Circuit breakers eliminate thread pool starvation and resource exhaustion caused by degraded dependencies.

Practical Example

Consider an e-commerce platform where the Payment Service goes down:

  • Instead of holding the Order Service threads open while continually retrying payments, a circuit breaker trips open.
  • Fallback Strategy: The system immediately returns a response to the user: "Order placed successfully, but payment processing failed. Please retry within 30 minutes to avoid order cancellation."
  • A direct link to complete payment is generated without locking system resources.

The Blast Radius Without Circuit Breakers

  1. Cascading Failure: Every incoming request to Service A triggers a request to Service B, which holds an HTTP thread open waiting 30s for Service C.
  2. Resource Exhaustion: Service B exhausts its thread pool limit (e.g., Tomcat's default 200 max threads) and starts rejecting all incoming requestsโ€”even those unrelated to Service C.
  3. Upstream Contagion: Service A experiences high latency from Service B, exhausts its own thread pools, and crashes.
  4. Self-Inflicted DDoS: When Service C recovers, hundreds of queued upstream threads bombard it simultaneously, immediately knocking it back down.

Architectural Trade-offs

  • Fallback Complexity: Requires designing and maintaining graceful degradation paths (e.g., returning stale cached data, partial responses, or queueing background tasks).
  • Transient False Positives: Misconfigured failure thresholds may trip the breaker during brief, self-correcting network spikes, causing unnecessary feature degradation.

When to AVOID or REJECT It

  • Asynchronous Message Streams: When communicating via message brokers (Kafka, RabbitMQ) with consumer-side backpressure and retry queues, synchronous circuit breaking is redundant.
  • Idempotent Background Jobs with Retries: If immediate feedback isn't required for an HTTP response, exponential backoff with retries is preferred.

Implementation: Spring Boot + Resilience4j

application.yml

resilience4j.circuitbreaker:
  instances:
    paymentService:
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 100                  # Evaluate error rate over last 100 requests
      failureRateThreshold: 50                # Trip if 50% or more fail
      slowCallRateThreshold: 75               # Trip if 75% of calls take longer than slowCallDuration
      slowCallDurationThreshold: 2000ms       # Calls >2s count as "slow"
      permittedNumberOfCallsInHalfOpenState: 10 # Test calls allowed when recovering
      waitDurationInOpenState: 10000ms        # Stay OPEN for 10s before going HALF-OPEN
      automaticTransitionFromOpenToHalfOpenEnabled: true
Enter fullscreen mode Exit fullscreen mode

PaymentGatewayAdapter.java

package com.architecture.resilience.adapter;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentGatewayAdapter {

    private static final Logger log = LoggerFactory.getLogger(PaymentGatewayAdapter.class);
    private final RestTemplate restTemplate;

    public PaymentGatewayAdapter(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    /**
     * The @CircuitBreaker annotation intercepts method execution.
     * 'name' maps to configuration key in application.yml.
     * 'fallbackMethod' handles execution path when OPEN or on Exception.
     */
    @CircuitBreaker(name = "paymentService", fallbackMethod = "processPaymentFallback")
    public PaymentResponse chargeCreditCard(PaymentRequest request) {
        // High-risk synchronous call to 3rd-party vendor
        return restTemplate.postForObject(
            "https://api.stripe.com/v1/charges",
            request,
            PaymentResponse.class
        );
    }

    /**
     * Fallback Method Signature Rules:
     * 1. Must match origin method parameters.
     * 2. Must accept Throwable/Exception as the final parameter.
     */
    public PaymentResponse processPaymentFallback(PaymentRequest request, Throwable exception) {
        log.warn("Circuit Breaker Active or Call Failed. Reason: {}", exception.getMessage());

        // Return degraded/queued response instead of throwing 500 downstream
        return new PaymentResponse(
            request.getTransactionId(),
            PaymentStatus.PENDING_ASYNC_PROCESSING,
            "Gateway unavailable. Request buffered for offline processing."
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Internal Mechanics: Sliding Windows & Memory Efficiency

Resilience4j tracks request outcomes in memory using fixed-size, bounded ring buffers. This guarantees $O(1)$ constant memory usage and lock-free execution paths.

Window Size = 10 requests

Slot:   [ 0 ][ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ][ 9 ]
Value:  [ S ][ F ][ S ][ S ][ F ][ S ][ S ][ F ][ S ][ F ]
                                                       โ–ฒ
                                             Pointer (Next Write)

Totals: Total = 10 | Failures = 4 | Successes = 6
Error Rate = (4 / 10) * 100 = 40%
Enter fullscreen mode Exit fullscreen mode
  1. Count-Based Sliding Window:

    • Uses a circular array of size $N$.
    • Stores SUCCESS, FAILURE, or SLOW_SUCCESS states.
    • Overwrites old values sequentially as new calls execute.
  2. Time-Based Sliding Window:

    • Uses a bucketized ring buffer (e.g., 10 seconds = 10 1-second buckets) to avoid CPU and garbage collection spikes under high throughput (e.g., 1,000+ RPS).
    • Uses atomic primitive reference arrays (AtomicReferenceArray) and lock-free synchronization.
    • Calculates the target bucket using modulo operations: $$\text{Current Bucket} = \left(\frac{\text{Current Epoch Milliseconds}}{1000}\right) \pmod{\text{Window Size}}$$
  3. Self-Healing Mechanics:

    • Instead of running background threads or timers, the circuit breaker evaluates elapsed time lazily upon request arrival: $$\text{Is Wait Time Over} = (\text{Current Timestamp} - \text{Circuit Open Timestamp}) > \text{Wait Duration}$$

State Machine Lifecycle

In-memory vs sidecar

  • In-Memory Circuit Breakers (Resilience4j): Evaluate metrics local to a single application instance (JVM). They handle localized application logic and execute fallbacks immediately.
  • Sidecar Proxies (Envoy / Istio): Operates at the network infrastructure layer (Layer 7). Istio coordinates across all pod instances, performing Outlier Detection to eject unhealthy individual pod IPs from service load balancers.

2. Two-Phase Commit (2PC) Pattern

Two-Phase Commit (2PC) is an atomic distributed transaction protocol that ensures ACID consistency across multiple physical databases or resources.

Problem Statement: Non-Atomic Dual Writes

Updating two separate databases without a transaction coordinator creates data inconsistency risks:

// VIOLATION: Non-atomic dual writes across two separate database connections
public class OrderService {
    private final DataSource inventoryDS;
    private final DataSource orderDS;

    public void createOrderInconsistent(OrderPayload order) throws SQLException {
        // Local Connection 1
        try (Connection conn1 = inventoryDS.getConnection()) {
            PreparedStatement stmt1 = conn1.prepareStatement(
                "UPDATE stock SET qty = qty - 1 WHERE item_id = ?"
            );
            stmt1.setString(1, order.getItemId());
            stmt1.executeUpdate();

            // DANGER ZONE: If JVM crashes, thread dies, or conn2 fails right here,
            // stock is permanently deducted, but the order record is never created!
        }

        // Local Connection 2
        try (Connection conn2 = orderDS.getConnection()) {
            PreparedStatement stmt2 = conn2.prepareStatement(
                "INSERT INTO orders(id, user_id) VALUES(?, ?)"
            );
            stmt2.setString(1, order.getOrderId());
            stmt2.setString(2, order.getUserId());
            stmt2.executeUpdate();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Protocol Execution Phase

Architectural Trade-offs

  • Blocking Protocol: Database row locks and connection pool resources remain open throughout Phase 1 and Phase 2. This significantly increases end-to-end latency and reduces throughput.
  • Single Point of Failure (Coordinator Dependency): If the transaction manager crashes after issuing commit commands to Participant A, Participant B remains locked in an intermediate state holding row locks.

When to Avoid It

  • Microservices operating across network boundaries (use Saga instead).
  • High-throughput web applications (use Transactional Outbox instead).

Refactored Architecture: Java JTA / XA Standard Implementation

import javax.sql.XAConnection;
import javax.sql.XADataSource;
import javax.transaction.UserTransaction;

public class OrderService2PC {

    private final XADataSource inventoryXaDS;
    private final XADataSource orderXaDS;
    private final UserTransaction userTransaction; // JTA Transaction Coordinator

    public OrderService2PC(XADataSource invDS, XADataSource orderDS, UserTransaction utx) {
        this.inventoryXaDS = invDS;
        this.orderXaDS = orderDS;
        this.userTransaction = utx;
    }

    public void createOrderAtomic2PC(OrderPayload order) throws Exception {
        try {
            // STEP 1: Begin the Distributed XA Transaction
            userTransaction.begin();

            // STEP 2: PHASE 1 (PREPARE)
            try (Connection invConn = inventoryXaDS.getXAConnection().getConnection();
                 Connection orderConn = orderXaDS.getXAConnection().getConnection()) {

                // Operation on Resource A (Inventory DB)
                PreparedStatement invStmt = invConn.prepareStatement(
                    "UPDATE stock SET qty = qty - 1 WHERE item_id = ?"
                );
                invStmt.setString(1, order.getItemId());
                invStmt.executeUpdate();

                // Operation on Resource B (Order DB)
                PreparedStatement orderStmt = orderConn.prepareStatement(
                    "INSERT INTO orders(id, user_id) VALUES(?, ?)"
                );
                orderStmt.setString(1, order.getOrderId());
                orderStmt.setString(2, order.getUserId());
                orderStmt.executeUpdate();

                // STEP 3: PHASE 2 (COMMIT)
                // If both prepare phases succeed, commit atomically
                userTransaction.commit();
                System.out.println("2PC Transaction Committed Successfully.");
            }
        } catch (Exception e) {
            // If ANY resource fails during Prepare or network drops, ROLLBACK ALL
            userTransaction.rollback();
            throw new TransactionAbortedException("2PC Transaction failed. Atomically rolled back all databases.", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Saga Pattern

The Saga Pattern manages distributed transactions by breaking them into a sequence of local transactions across microservices. Each service executes its own local transaction and publishes events. If a downstream step fails, the Saga executes compensating transactions in reverse order to undo changes.

The Blast Radius Without Sagas

  1. Dangling Committed State: Inventory stock is permanently deducted, but payment failsโ€”leaving the warehouse inventory inaccurate.
  2. Lack of Automated Recovery: Without built-in compensation paths, system recovery requires manual batch cleanup scripts or direct SQL database updates.

Architectural Trade-off: Lack of Isolation

Because each local transaction commits immediately, intermediate states are visible to other requests (dirty reads). Systems mitigate this by applying semantic locks (e.g., keeping orders in a PENDING state until the Saga completes).


Choreography vs. Orchestration

1. Choreography (Event-Driven)

Services coordinate implicitly by publishing and subscribing to domain events via message brokers.

2. Orchestration (Command-Driven)

A central Orchestrator service explicitly sends commands to participant services and evaluates responses.


Java Code Implementation (Choreography-Based Saga)

OrderService.java

package com.architecture.saga.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;

    // LOCAL TRANSACTION 1 (Executes & Commits immediately)
    @Transactional
    public Order createOrder(OrderRequest request) {
        Order order = new Order(request.getUserId(), request.getAmount(), "PENDING");
        Order savedOrder = orderRepository.save(order);

        OrderCreatedEvent event = new OrderCreatedEvent(
            savedOrder.getId(), request.getItemId(), request.getAmount()
        );
        kafkaTemplate.send("order-created-topic", event);

        return savedOrder; // Local DB connection released immediately
    }

    // COMPENSATING TRANSACTION: Triggered if downstream Payment or Inventory fails
    @KafkaListener(topics = "payment-failed-topic")
    @Transactional
    public void compensateOrder(PaymentFailedEvent event) {
        Order order = orderRepository.findById(event.getOrderId())
            .orElseThrow(() -> new EntityNotFoundException("Order not found: " + event.getOrderId()));

        // Undo business action: Update status to CANCELLED
        order.setStatus("CANCELLED");
        orderRepository.save(order);

        System.out.println("Saga Compensation Complete: Order " + order.getId() + " updated to CANCELLED.");
    }
}
Enter fullscreen mode Exit fullscreen mode

PaymentService.java

package com.architecture.saga.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;

    @Autowired
    private KafkaTemplate<String, Object> kafkaTemplate;

    @KafkaListener(topics = "inventory-reserved-topic")
    @Transactional
    public void processPayment(InventoryReservedEvent event) {
        try {
            boolean paymentSuccess = chargeCreditCard(event.getUserId(), event.getAmount());
            if (!paymentSuccess) {
                throw new PaymentDeclinedException("Insufficient funds");
            }

            paymentRepository.save(new Payment(event.getOrderId(), "SUCCESS"));
            kafkaTemplate.send("payment-success-topic", new PaymentSuccessEvent(event.getOrderId()));

        } catch (Exception e) {
            // TRIGGER COMPENSATING TRANSACTIONS UPSTREAM
            PaymentFailedEvent failureEvent = new PaymentFailedEvent(event.getOrderId(), e.getMessage());
            kafkaTemplate.send("payment-failed-topic", failureEvent);
        }
    }

    private boolean chargeCreditCard(String userId, double amount) {
        return false; // Simulating a payment failure to trigger compensation path
    }
}
Enter fullscreen mode Exit fullscreen mode

Choreography vs. Orchestration Comparison

Dimension Choreography (Event-Driven) Orchestration (Command-Driven)
Control Style Decentralized. Services publish & listen to domain events. Centralized. A dedicated orchestrator workflow engine issues commands.
Coupling Low temporal coupling. Services only track events. Higher structural coupling to orchestrator, but services remain decoupled from each other.
Best Used For Simple workflows (2 to 4 microservices). Complex workflows with branching logic, conditionals, or 5+ microservices.
Complexity Harder to track overall global state; risk of cyclic event loops. Easy to track state centrally (e.g., Temporal.io, Camunda).

4. Transactional Outbox Pattern + Debezium CDC

The Transactional Outbox pattern avoids data inconsistencies caused by non-atomic dual writes across a database and an asynchronous message broker (Kafka).

How It Works

  1. Application Layer: Inserts both domain model updates (payments table) and the event payload (outbox_events table) within a single local database transaction.
  2. Database Engine: Atomically commits both records to its Write-Ahead Log (WAL / Redo Log).
  3. Debezium (CDC Engine): Reads row insertions directly from the database WAL and streams them into Kafka. Application code does not interact with Kafka directly.

Internal Mechanics of Debezium CDC

  • Zero Polling: Debezium does not run SELECT queries against the database. Instead, it connects as a logical replication client.
  • Low Latency: As soon as a transaction commits, the DB engine streams binary WAL log updates to Debezium over a socket connection in sub-milliseconds.
  • Fault Tolerance via LSN Checkpointing: Debezium tracks its reading position using Log Sequence Numbers (LSN) stored inside Kafka offset topics:
1. Debezium reads LSN 1005 from WAL.
2. Debezium attempts to publish LSN 1005 to Kafka.
3. If network drops or Kafka times out:
   โ”œโ”€โ”€ Debezium offset does NOT advance.
   โ”œโ”€โ”€ On recovery, Debezium queries Kafka: "Last committed offset?"
   โ”œโ”€โ”€ Kafka replies: "LSN 1004."
   โ””โ”€โ”€ Debezium re-reads LSN 1005 from WAL and retries publishing (Guarantees At-Least-Once Delivery).
Enter fullscreen mode Exit fullscreen mode

Implementation Details

Database Schema

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(255) NOT NULL, -- e.g., 'PAYMENT', 'ORDER'
    aggregate_id VARCHAR(255) NOT NULL,   -- e.g., order_id for Kafka partition key
    type VARCHAR(255) NOT NULL,           -- e.g., 'PAYMENT_SUCCESSFUL'
    payload JSONB NOT NULL,               -- Event payload JSON
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_outbox_created_at ON outbox_events(created_at);
Enter fullscreen mode Exit fullscreen mode

OutboxEvent.java Entity

package com.architecture.outbox.domain;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    private UUID id;

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "type", nullable = false)
    private String type;

    @Column(name = "payload", columnDefinition = "jsonb", nullable = false)
    private String payload;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    public OutboxEvent() {}

    public OutboxEvent(String aggregateType, String aggregateId, String type, String payload) {
        this.id = UUID.randomUUID();
        this.aggregateType = aggregateType;
        this.aggregateId = aggregateId;
        this.type = type;
        this.payload = payload;
        this.createdAt = Instant.now();
    }

    public UUID getId() { return id; }
    public String getAggregateType() { return aggregateType; }
    public String getAggregateId() { return aggregateId; }
    public String getType() { return type; }
    public String getPayload() { return payload; }
    public Instant getCreatedAt() { return createdAt; }
}
Enter fullscreen mode Exit fullscreen mode

PaymentApplicationService.java

package com.architecture.outbox.service;

import com.architecture.outbox.domain.OutboxEvent;
import com.architecture.outbox.domain.Payment;
import com.architecture.outbox.repository.OutboxRepository;
import com.architecture.outbox.repository.PaymentRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;

@Service
public class PaymentApplicationService {

    private final PaymentRepository paymentRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;

    public PaymentApplicationService(PaymentRepository paymentRepository,
                                     OutboxRepository outboxRepository,
                                     ObjectMapper objectMapper) {
        this.paymentRepository = paymentRepository;
        this.outboxRepository = outboxRepository;
        this.objectMapper = objectMapper;
    }

    @Transactional // GUARANTEES ATOMICITY BETWEEN PAYMENT & OUTBOX EVENT
    public void processPayment(String orderId, String userId, double amount) throws Exception {
        // 1. Save Domain State
        Payment payment = new Payment(orderId, userId, amount, "SUCCESS");
        paymentRepository.save(payment);

        // 2. Prepare Event Payload JSON
        Map<String, Object> eventPayload = Map.of(
            "orderId", orderId,
            "userId", userId,
            "amount", amount,
            "status", "SUCCESS"
        );
        String jsonPayload = objectMapper.writeValueAsString(eventPayload);

        // 3. Write Outbox Event to DB (Same Local Transaction & DB Connection)
        OutboxEvent outboxEvent = new OutboxEvent(
            "ORDER",              // aggregateType
            orderId,              // aggregateId (Used as Kafka Partition Key)
            "PAYMENT_SUCCESSFUL", // event type
            jsonPayload
        );
        outboxRepository.save(outboxEvent);

        // Transaction commits both entries atomically
    }
}
Enter fullscreen mode Exit fullscreen mode

Debezium Outbox Event Router Connector Config

The io.debezium.transforms.outbox.EventRouter single message transform automatically routes outbox events to Kafka topics derived from the aggregate_type column.

{
  "name": "payment-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "plugin.name": "pgoutput",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "postgres",
    "database.password": "secret",
    "database.dbname": "payment_db",
    "database.server.name": "payment-service",
    "table.include.list": "public.outbox_events",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}",
    "transforms.outbox.id.field": "id",
    "transforms.outbox.key.field": "aggregate_id",
    "transforms.outbox.payload.field": "payload",

    "transforms.outbox.table.fields.additional.placement": "type:header:eventType",
    "transforms.outbox.remove.after.insert": "true"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Cleanup Strategies:
    1. Debezium Auto-Removal: Set "transforms.outbox.remove.after.insert": "true" to automatically issue a DELETE query once a row is read from the WAL and produced to Kafka.
    2. Partitioning Sweeper: Partition outbox_events by date (e.g., daily partitions) and run a background task to drop old table partitions if events need to be retained briefly for debugging.

5. Bulkhead Pattern

The Bulkhead Pattern isolates application resources (thread pools, memory, connections) into isolated compartments per downstream dependency. If one dependency experiences high latency, its isolated pool fills up without exhausting resources allocated to other services.


Bulkhead Types

Type How It Works Best Used For
Thread-Pool Bulkhead Assigns a dedicated thread pool and queue per downstream service. Synchronous/blocking calls. Isolates CPU & thread resources completely.
Semaphore Bulkhead Uses atomic counters to cap concurrent requests without creating separate thread pools. High-throughput, asynchronous non-blocking calls (WebFlux/Netty).

Implementation: Resilience4j Thread-Pool Bulkhead

ExternalCatalogService.java

package com.architecture.resilience.service;

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.CompletableFuture;

@Service
public class ExternalCatalogService {

    private final RestTemplate restTemplate;

    public ExternalCatalogService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // THREAD-POOL BULKHEAD: Executed inside isolated thread pool "recommendationBulkhead"
    @Bulkhead(name = "recommendationBulkhead", type = Bulkhead.Type.THREADPOOL, fallbackMethod = "getRecommendationFallback")
    public CompletableFuture<String> getRecommendations(String userId) {
        String response = restTemplate.getForObject(
            "http://recommendation-service/api/recs/" + userId, 
            String.class
        );
        return CompletableFuture.completedFuture(response);
    }

    // FALLBACK METHOD: Triggered immediately when thread pool & queue are full
    public CompletableFuture<String> getRecommendationFallback(String userId, Throwable t) {
        System.err.println("Bulkhead Pool Full or Service Failed: " + t.getMessage());
        return CompletableFuture.completedFuture("[\"Top Seller 1\", \"Top Seller 2\"]");
    }
}
Enter fullscreen mode Exit fullscreen mode

application.yml Config

resilience4j.thread-pool-bulkhead:
  instances:
    recommendationBulkhead:
      maxThreadPoolSize: 20      # Max threads allocated ONLY for Recommendations
      coreThreadPoolSize: 10
      queueCapacity: 50         # Requests beyond this capacity trigger Fallback
      keepAliveDuration: 20ms
    paymentBulkhead:
      maxThreadPoolSize: 100     # Payments get a larger dedicated thread pool
      coreThreadPoolSize: 50
      queueCapacity: 100
Enter fullscreen mode Exit fullscreen mode

Bulkhead vs. Circuit Breaker

Scenario Bulkhead Behavior Circuit Breaker Behavior
Downstream service is 100% DEAD (Returning 500s instantly) Allows requests up to max concurrency limit. Every request hits the dead service, fails, and frees its slot. Trips OPEN. Immediately blocks outbound network calls for a specified wait duration to prevent load on the target service.
Downstream service is EXTREMELY SLOW (30s latency per request) Fills its allocated thread pool. Excess requests fail fast via BulkheadFullException. The rest of the application remains responsive. Tracks high latency/timeouts. Once the error rate threshold is crossed, it trips OPEN to fail fast without waiting for timeouts.
Primary Protection Goal Protects Caller Application Resources (threads/memory) from starvation. Protects Both Caller and Downstream Service from thundering herd problems during recovery.

6. Command Query Responsibility Segregation (CQRS)

CQRS segregates Write operations (Commands) from Read operations (Queries). Instead of using a single shared database schema for both operations, CQRS maintains separate data stores and models optimized for writes and reads independently.

Why Do We Need It?

  • Read-to-Write Asymmetry: High-scale systems often experience read-to-write ratios of 10:1 or 100:1.
  • Model Divergence: Writes require strict transactional consistency, relational integrity, and normalized tables (3NF). Reads require denormalized views, fast key-value lookups, or full-text search indexes (e.g., Elasticsearch, MongoDB).

Data Synchronization Flow

  1. Write Phase: Users submit a Command (e.g., PlaceOrder). The Command Handler validates domain rules and commits to the Write Database (RDBMS).
  2. Event Emission Phase: The Transactional Outbox pattern records the change, and Debezium CDC streams the event into Apache Kafka.
  3. Read Phase: A dedicated consumer updates the Read Database (e.g., Elasticsearch, Redis) with denormalized views tailored for UI screens.

When to AVOID CQRS

  • Simple CRUD Applications: If read views closely match database tables 1:1, CQRS introduces unnecessary operational complexity.
  • Strict Real-Time Consistency Requirements: If applications cannot tolerate eventual consistency (e.g., reading stale data for a few hundred milliseconds after submission), standard single-database transactions are preferred.

Pattern Summary Cheat Sheet

Pattern Core Objective Primary Use Case Key Tech Stack
Circuit Breaker Fail fast during remote dependency failures; prevent thread starvation. Synchronous REST/gRPC service calls. Resilience4j, Envoy, Istio
Two-Phase Commit (2PC) Maintain distributed ACID transactions across multiple databases. Monolithic apps updating multiple XA databases within a single network. JTA, XADataSources
Saga Pattern Manage distributed transactions across microservices via local steps and compensations. Complex microservices business workflows. Spring Boot, Kafka, Temporal
Transactional Outbox Atomically commit database states and domain events; avoid dual-write issues. Event-driven microservice synchronization. PostgreSQL JSONB, Debezium, Kafka
Bulkhead Pattern Isolate resources (thread pools) to prevent localized slowness from exhausting system resources. Multi-tenant apps and downstream integration points. Resilience4j, Hystrix
CQRS Separate write (Command) and read (Query) data paths. High-scale systems with high read-to-write ratios. Kafka, Debezium, Elasticsearch, Redis

๐Ÿ’ก Get the Full 10-Page System Design Guide

Subscribe to The Tech Builder Newsletter to instantly get the full, unredacted guide for free.

Every week, subscribers receive:

  • ๐ŸŽฏ Deep-dive production postmortems & system design trade-off analysis.
  • ๐Ÿ› ๏ธ Real-world architecture playbooks for Senior ICs, Tech Leads, and Architects.
  • ๐ŸŽ Instant Bonus: Get the Full 6-Month Prep Tracker & Study Schedule + 10-Page System Design Cheat Sheet immediately upon subscribing.

๐Ÿ‘‰ Get the Full System Design Cheat Sheet

Top comments (0)