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
- 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.
- 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.
- Upstream Contagion: Service A experiences high latency from Service B, exhausts its own thread pools, and crashes.
- 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
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."
);
}
}
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%
-
Count-Based Sliding Window:
- Uses a circular array of size $N$.
- Stores
SUCCESS,FAILURE, orSLOW_SUCCESSstates. - Overwrites old values sequentially as new calls execute.
-
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}}$$
-
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();
}
}
}
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);
}
}
}
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
- Dangling Committed State: Inventory stock is permanently deducted, but payment failsโleaving the warehouse inventory inaccurate.
- 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.");
}
}
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
}
}
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
-
Application Layer: Inserts both domain model updates (
paymentstable) and the event payload (outbox_eventstable) within a single local database transaction. - Database Engine: Atomically commits both records to its Write-Ahead Log (WAL / Redo Log).
- 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
SELECTqueries 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).
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);
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; }
}
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
}
}
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"
}
}
-
Cleanup Strategies:
-
Debezium Auto-Removal: Set
"transforms.outbox.remove.after.insert": "true"to automatically issue aDELETEquery once a row is read from the WAL and produced to Kafka. -
Partitioning Sweeper: Partition
outbox_eventsby date (e.g., daily partitions) and run a background task to drop old table partitions if events need to be retained briefly for debugging.
-
Debezium Auto-Removal: Set
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\"]");
}
}
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
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
-
Write Phase: Users submit a Command (e.g.,
PlaceOrder). The Command Handler validates domain rules and commits to the Write Database (RDBMS). - Event Emission Phase: The Transactional Outbox pattern records the change, and Debezium CDC streams the event into Apache Kafka.
- 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.















Top comments (0)