Quick Index
- The Shared Database Bottleneck: Why Traditional CRUD Collapses Under Scale
- Core Principles of CQRS: Separating Command and Query Responsibilities
- The Command Side: Enforcing Invariants in the Normalized Write Model
- The Query Side: Flattened Projections in Read-Optimized Materialized Views
- Why Apache Kafka: Distributed Commit Logs, Partition Hashing, and Replayability
- Asynchronous Read Projection and Dead-Letter Reconciliation Pipeline
- Implementing the Command Service with Spring Boot 4 and Java 25
- Implementing the Query Service: High-Throughput Reads and Event Listeners
- Eventual Consistency and Handling UI Lag in Asynchronous Systems
- Comparative Architecture Matrix: 6 Distributed Data Strategies
- Enterprise Case Studies: Real-World CQRS and Kafka Implementations
- Production Anti-Patterns and Refactoring Fixes
- Testing Event-Driven CQRS with Testcontainers and Awaitility
- Production Best Practices for Kafka-Driven CQRS
- Frequently Asked Questions
- Companion Workshop Code and Integration Test Suite
- Authoritative References
Modern microservices face a critical scaling obstacle: the shared database bottleneck. In traditional CRUD applications, every read and write operation hits the exact same relational database tables. As user traffic surges, heavy analytical search queries fight against high-velocity transactional inserts. Relational locks trigger connection pool exhaustion. API calls time out. Customers experience frustrating delays.
To eliminate this bottleneck, high-scale enterprise systems in banking, e-commerce, and logistics adopt CQRS (Command Query Responsibility Segregation) and Event-Driven Architecture. By physically and logically separating the write model from the read model, you can scale each side independently. Bridging both sides asynchronously with Apache Kafka unlocks massive throughput, zero-lock query performance, and total system elasticity.
In this comprehensive enterprise guide, you will master the architecture of Event-Driven CQRS using Java 25, Spring Boot 4.0.0, Apache Kafka, PostgreSQL 16, and Testcontainers. You will learn how to design normalized write models, construct denormalized read projections, preserve strict event ordering, and test asynchronous projections reliably.
The Shared Database Bottleneck: Why Traditional CRUD Collapses Under Scale
In a standard monolithic or microservice architecture, developers build a single domain model for both mutations and reads. An Order entity contains billing details, customer records, shipping addresses, inventory line items, and audit timestamps. When a customer checks out, the application executes complex validation and updates multiple joined tables inside a single ACID database transaction.
Simultaneously, business users load analytics dashboards. Customers search their historical order archives. Mobile apps query order status badges. Each read operation executes multi-table SQL JOIN statements across orders, order_items, products, and customers. These heavy queries hold shared read locks. In PostgreSQL, MySQL, and Oracle, concurrent writes and reads contend for CPU cache lines, buffer pools, and disk I/O.
This symmetric design suffers from five critical architectural flaws:
- Asymmetric Traffic Ratios: In 95% of web platforms, read requests outnumber write requests by 50-to-1 or 200-to-1. Scaling the write database horizontally to satisfy read traffic is prohibitively expensive and architecturally flawed.
- Lock Contention and Deadlocks: Long-running aggregation queries lock table rows or index pages. Fast checkout writes wait in line behind reporting queries, leading to connection pool starvation. You can review connection tuning strategies in our guide on HikariCP Connection Pool Tuning in Spring Boot.
- Conflicting Schema Demands: The write model demands strict third-normal-form (3NF) normalization to prevent anomalies and enforce invariants. The read model demands flattened, denormalized JSON structures to eliminate expensive SQL joins. A single schema cannot satisfy both requirements efficiently.
- Impaired Security and Auditing: Users querying orders often need only public summary data. Exposing the full entity with payment tokens, fraud scores, and supplier costs increases security exposure.
- Monolithic Failure Blast Radius: If a runaway analytics report overwhelms the relational database, write operations fail. New customers cannot submit orders, directly impacting company revenue.
CQRS solves this dilemma by breaking the symmetry. Instead of forcing one model to do everything, CQRS splits the application into two dedicated, purpose-built pipelines.
Core Principles of CQRS: Separating Command and Query Responsibilities
The core concept of CQRS was first formalized by Greg Young and Bertrand Meyer’s Command-Query Separation (CQS) principle. At its simplest level: a method should either mutate state (Command) or return state (Query), but never both.
In a distributed system, CQRS elevates this concept to the architectural level. The system maintains two distinct models:

The Command and Query pipelines operate with distinct responsibilities:
- The Command Pipeline (Write Model):
- Accepts commands (intentions to change state, such as
PlaceOrderCommand). - Validates business rules and invariants against existing state.
- Persists the state change to a normalized, transactional relational database (the write store).
- Publishes an immutable domain event (such as
OrderCreatedEvent) to the Kafka event log. - Returns a lightweight acknowledgment (such as
200 OKwith the entity ID or202 Accepted). It never returns large query representations.
- Accepts commands (intentions to change state, such as
- The Query Pipeline (Read Model):
- Accepts read queries (such as
GetCustomerOrdersQuery). - Consumes domain events from Kafka in real time.
- Projects and denormalizes the event payload into read-optimized datastores (such as PostgreSQL flat view tables, MongoDB, or Elasticsearch).
- Answers client queries with lightning speed using single-table primary-key lookups. Zero
JOINoperations are required. - Executes zero business rule mutations. It is strictly read-only.
- Accepts read queries (such as
The Command Side: Enforcing Invariants in the Normalized Write Model
The Command side is the guardian of domain integrity. In an e-commerce platform, this service receives order submission requests. It validates inventory availability, checks credit limits, verifies shipping address formatting, and assigns sequential order identifiers.
Because the Command service focuses purely on state mutation, its relational database schema remains strictly normalized. The database uses primary keys, foreign keys, and unique constraints. It does not worry about indexing every searchable field for search dashboards. It indexes only what is necessary to enforce business invariants and find aggregates quickly by ID.
In our workshop codebase, the Order entity models the write state:
package com.webcodein.cqrs.command;
import jakarta.persistence.*;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerId;
private Double totalAmount;
private String status;
public Order() {}
public Order(String customerId, Double totalAmount, String status) {
this.customerId = customerId;
this.totalAmount = totalAmount;
this.status = status;
}
public Long getId() { return id; }
public String getCustomerId() { return customerId; }
public Double getTotalAmount() { return totalAmount; }
public String getStatus() { return status; }
}
Notice how clean the write model is. It does not contain customer display names, product category trees, or calculated discount badge strings. It holds only the state required to govern business rules. If you organize your core entities using Domain-Driven Design Building Blocks, this entity functions as the aggregate root.
The Query Side: Flattened Projections in Read-Optimized Materialized Views
While the Command database is optimized for writes, the Query database is optimized exclusively for consumption. When a mobile user opens their profile screen, they do not want the server to execute five relational joins to calculate total spend, order count, and fulfillment progress. They need a pre-computed view ready for immediate rendering.

In our Spring Boot 4 service, the Query model is represented by OrderSummaryView. This entity is stored in a dedicated projection table:
package com.webcodein.cqrs.query;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_summary_view")
public class OrderSummaryView {
@Id
private Long orderId;
private String customerId;
private Double totalAmount;
private String displayStatus;
public OrderSummaryView() {}
public OrderSummaryView(Long orderId, String customerId, Double totalAmount, String displayStatus) {
this.orderId = orderId;
this.customerId = customerId;
this.totalAmount = totalAmount;
this.displayStatus = displayStatus;
}
public Long getOrderId() { return orderId; }
public String getCustomerId() { return customerId; }
public Double getTotalAmount() { return totalAmount; }
public String getDisplayStatus() { return displayStatus; }
}
The query endpoint simply runs SELECT * FROM order_summary_view WHERE customer_id = ?. Because the data is already denormalized and indexed by customerId, database query response times drop from 180 milliseconds down to sub-millisecond execution. There are no relational joins, no subqueries, and zero locks held against the write database.
Why Apache Kafka: Distributed Commit Logs, Partition Hashing, and Replayability
You can connect Command and Query services using various transport layers, such as RabbitMQ, AWS SQS, or synchronous REST calls. However, Apache Kafka is the gold standard for enterprise event-driven CQRS and Event Sourcing.

Kafka provides four fundamental architectural guarantees that make CQRS resilient:
- Strict In-Order Delivery per Partition: In business workflows, order matters. An
OrderCancelledEventmust never be processed before anOrderCreatedEvent. Kafka partitions messages by message key. By using the aggregate identifier (such asorderIdorcustomerId) as the Kafka record key, Kafka guarantees that all events for a given aggregate land on the exact same partition in sequential order. - High-Throughput Append-Only Storage: Kafka writes event records sequentially to disk. Sequential disk writes on NVMe drives achieve gigabytes-per-second throughput. Kafka easily handles hundreds of thousands of events per second with minimal latency.
- Event Replayability and Model Regeneration: Traditional message brokers delete messages as soon as consumers acknowledge them. Kafka retains messages on disk based on a configurable retention period (or forever with log compaction). If you discover a bug in your read projection or want to launch a brand new recommendation engine, you can deploy a new consumer group and “replay” the entire event stream from offset zero. The new read database populates automatically without impacting the production write database.
- Consumer Group Decoupling: Multiple independent query services can consume the same
order-eventstopic. One consumer updates PostgreSQL flat views, another updates Elasticsearch for full-text search, and a third pushes real-time WebSocket notifications to mobile apps. None of these consumers interfere with each other or slow down the Command service.
For more details on Kafka event streaming mechanics, refer to our foundational guide on Event-Driven Architecture with Spring Boot 4 and Apache Kafka.
Asynchronous Read Projection and Dead-Letter Reconciliation Pipeline
In distributed systems, networks fail, databases restart, and serialization bugs occur. A resilient CQRS platform must include automated reconciliation and dead-letter handling.

The projection engine operates with four protective mechanisms:
- Partition Hashing: The producer sends events with
key = String.valueOf(savedOrder.getId()). Kafka’s default partitioner hashes this key, guaranteeing all state changes for that order land on the same consumer thread. - Idempotent Event Application: If a network failure causes Kafka to redeliver an event, the consumer projection handler uses the primary key to update the existing view instead of creating duplicate rows. You can review idempotency techniques in our guide on Idempotent Webhook Consumers in Spring Boot 4.
- Version Sequence Checking: Each domain event contains an incremental sequence number (
aggregateVersion). The projection service asserts thatevent.version == current.version + 1. If an event arrives out of sequence, it is buffered or placed in a retry holding queue until missing events arrive. - Dead-Letter Queue (DLQ) Reconciliation: If a projection fails due to invalid payload formatting or schema mismatch, Spring Kafka’s
DeadLetterPublishingRecovererroutes the message to anorder-events.DLQtopic. An automated reconciliation worker alerts on-call engineers while preserving healthy consumer throughput on the main topic.
Implementing the Command Service with Spring Boot 4 and Java 25
Let us look at the Command service implementation. The service coordinates the transactional write and event dispatch:
package com.webcodein.cqrs.command;
import com.webcodein.cqrs.event.OrderCreatedEvent;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderCommandService {
private final OrderRepository orderRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
public OrderCommandService(OrderRepository orderRepository, KafkaTemplate<String, Object> kafkaTemplate) {
this.orderRepository = orderRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Transactional
public Order placeOrder(OrderRequest request) {
// 1. Enforce domain invariants and persist to normalized write database
Order order = new Order(request.getCustomerId(), request.getTotalAmount(), "CREATED");
Order savedOrder = orderRepository.save(order);
// 2. Instantiate immutable domain event record
OrderCreatedEvent event = new OrderCreatedEvent(
savedOrder.getId(),
savedOrder.getCustomerId(),
savedOrder.getTotalAmount()
);
// 3. Publish to Kafka keyed by aggregate ID for partition ordering
kafkaTemplate.send("order-events", String.valueOf(savedOrder.getId()), event);
return savedOrder;
}
}
The REST controller exposes a clean command endpoint:
package com.webcodein.cqrs.command;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderCommandController {
private final OrderCommandService orderCommandService;
public OrderCommandController(OrderCommandService orderCommandService) {
this.orderCommandService = orderCommandService;
}
@PostMapping
public ResponseEntity<Order> placeOrder(@RequestBody OrderRequest request) {
return ResponseEntity.ok(orderCommandService.placeOrder(request));
}
}
Notice that the controller receives an OrderRequest command, executes the service, and returns an immediate response. It does not perform read projections or query relational joins.
Implementing the Query Service: High-Throughput Reads and Event Listeners
The Query service listens for events arriving on the order-events topic. It updates the projection table and answers client read queries:
package com.webcodein.cqrs.query;
import com.webcodein.cqrs.event.OrderCreatedEvent;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class OrderQueryService {
private final OrderSummaryRepository summaryRepository;
public OrderQueryService(OrderSummaryRepository summaryRepository) {
this.summaryRepository = summaryRepository;
}
@KafkaListener(topics = "order-events", groupId = "query-group")
@Transactional
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
OrderSummaryView summary = new OrderSummaryView(
event.orderId(),
event.customerId(),
event.totalAmount(),
"Pending Fulfillment" // Denormalized presentation status
);
summaryRepository.save(summary);
}
public List<OrderSummaryView> getCustomerOrders(String customerId) {
return summaryRepository.findByCustomerId(customerId);
}
}
The query controller handles client reads with zero database locks:
package com.webcodein.cqrs.query;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/orders/summary")
public class OrderQueryController {
private final OrderQueryService orderQueryService;
public OrderQueryController(OrderQueryService orderQueryService) {
this.orderQueryService = orderQueryService;
}
@GetMapping("/{customerId}")
public ResponseEntity<List<OrderSummaryView>> getCustomerOrders(@PathVariable String customerId) {
return ResponseEntity.ok(orderQueryService.getCustomerOrders(customerId));
}
}
This controller powers real-time user interfaces and customer dashboards:

Eventual Consistency and Handling UI Lag in Asynchronous Systems
Because CQRS separates write persistence from read projections through an asynchronous Kafka bus, the system is eventually consistent. When a user submits an order, the write model commits in 5 milliseconds. The Kafka event travels across the broker and updates the read projection within 10 to 50 milliseconds.
During that 50-millisecond window, if a web browser immediately requests the user’s order list, the new order might not appear yet. In naive systems, users click “Refresh”, see nothing, and assume their order failed. They click “Submit Order” again, resulting in accidental double orders.
Enterprise platforms employ three proven user experience strategies to handle eventual consistency gracefully:
- Optimistic Client UI Updates: When the browser receives the HTTP
200 OKresponse from the command endpoint, frontend frameworks (such as Angular or React) immediately render the new order in the local state. The UI does not query the backend read API instantly. It assumes success and updates the screen locally. - Version Tokens and Polling: The command endpoint returns an opaque version token (such as
version: 12or an ETag). When the UI queries the read service, it passes the headerIf-None-Match: 12. If the read database has not caught up to that version yet, the client performs a brief exponential backoff retry. - Real-Time WebSocket / SSE Push: Instead of making the frontend poll the query service, the server pushes projection updates directly to the client browser over a WebSocket or Server-Sent Events (SSE) connection once the Kafka consumer finishes writing the materialized view.
Comparative Architecture Matrix: 6 Distributed Data Strategies
Architects must evaluate tradeoffs before introducing CQRS into an application. The comparison matrix below analyzes six common distributed data strategies:
| Architecture Strategy | Consistency Model | Write Throughput | Read Latency | Operational Complexity | Best Fit Scenario |
|---|---|---|---|---|---|
| Event-Driven CQRS (Kafka) | Eventual Consistency (10-50ms) | Very High (Decoupled writes) | Microseconds (Flat views) | Medium-High (Kafka ops) | High-volume systems with extreme read-to-write asymmetry and complex projections. |
| Monolithic ACID Relational | Immediate Strong Consistency | Low-Medium (Row locking) | Medium-Slow (Complex JOINs) | Low (Single database) | Small internal CRUD tools, MVPs, and systems requiring immediate ACID reads. |
| Read Replicas with CDC | Eventual Consistency (Replication lag) | Medium (Primary DB bound) | Medium (Still requires JOINs) | Medium (DB cluster management) | Offloading analytical reporting without redesigning the application domain model. |
| Event Sourcing (Axon / EventStore) | Eventual Consistency | High (Append-only events) | Fast (Pre-computed views) | High (Event schema evolution) | Audit ledgers, banking accounts, and compliance systems where history cannot be mutated. |
| In-Memory Cache (Redis) | Stale / Weak Consistency | Medium (Cache invalidation cost) | Sub-millisecond (RAM lookup) | Medium (Dual-write cache races) | High-traffic read caching for relatively static data (product catalogs, user sessions). |
| Two-Phase Commit (2PC / XA) | Strict Distributed Consistency | Very Low (Blocking network locks) | Slow (Coordinator latency) | Very High (Fragile coordinators) | Legacy financial systems where distributed eventual consistency is legally unacceptable. |
Enterprise Case Studies: Real-World CQRS and Kafka Implementations
The following case studies illustrate how enterprise organizations solved massive scalability bottlenecks by deploying event-driven CQRS with Apache Kafka.
Case Study 1: Global Payments Processor – Decoupling Authorization from Merchant Settlement Views
Context: A international payment gateway handled 12,000 credit card authorization requests per second during peak hours. Merchants queried live settlement reports and fraud analytics continuously.
The Failure Mode: Merchant reporting queries executed heavy SQL window functions on the primary PostgreSQL cluster. When multiple merchants pulled monthly statements simultaneously, lock contention delayed live payment authorizations. Over 300 checkout transactions timed out during a Cyber Monday surge, triggering SLA penalties.
The CQRS Solution: The company split the payment engine into a high-speed write service and an asynchronous Kafka query projector. Live authorizations write to a minimal PostgreSQL schema and immediately emit PaymentAuthorizedEvent to a 12-partition Kafka cluster. A query service consumes the events and materializes merchant summary views in MongoDB.
The Outcome: Card authorization latencies dropped from 220ms to 18ms (a 91% reduction). Merchant reporting queries ran with zero impact on payment processing. Database resource utilization on the primary write database decreased by 74%.
Case Study 2: E-Commerce Retailer – Flash Sale Inventory Allocation and Stock Views
Context: A fashion retailer ran daily 15-minute flash sales offering limited-quantity luxury apparel. Over 100,000 mobile shoppers competed for 500 items.
The Failure Mode: Shoppers continuously reloaded the product page to view real-time stock levels. Each page load triggered a query that counted reserved items across relational order tables. The database crashed under connection pool saturation, resulting in both website outages and accidental overselling.
The CQRS Solution: The engineering team implemented CQRS. Inventory reservations execute against a high-concurrency Redis write model using atomic decrements, as demonstrated in our guide on High-Concurrency Mobile Flash Sales in Spring Boot and Redis. Each reservation publishes an event to Kafka. A read projector updates a flat, pre-computed stock view pushed to browsers via WebSockets.
The Outcome: The system successfully handled 140,000 requests per minute with zero database downtime. Overselling incidents dropped to zero, and checkout conversion rates increased by 28%.
Case Study 3: IoT Fleet Telematics – Ingesting 250,000 GPS Pings per Second
Context: A logistics telematics provider monitored 70,000 commercial delivery trucks across North America. Each vehicle emitted GPS coordinates, engine temperature, and speed every second.
The Failure Mode: Ingesting raw GPS data directly into a normalized relational database overwhelmed write I/O. Fleet managers attempting to view real-time vehicle map tracking experienced query timeouts exceeding 30 seconds.
The CQRS Solution: The platform transitioned to Kafka CQRS. Vehicle pings stream directly into Kafka topic partitions keyed by truck VIN. An ingestion microservice writes raw telemetry to an append-only time-series store. A separate query projector maintains a real-time `TruckCurrentLocationView` materialized table in memory.
The Outcome: Fleet map rendering latency dropped from 30 seconds to 45 milliseconds. Ingestion throughput scaled linearly across Kafka consumer partitions without dropping a single packet.
Production Anti-Patterns and Refactoring Fixes
Below are three common production anti-patterns encountered when implementing CQRS with Kafka, along with concrete refactored code fixes.
Anti-Pattern 1: The Dual-Write Problem
Saving an entity to a relational database and immediately publishing to Kafka in the same method without transaction coordination creates dual-write vulnerabilities. If the database commits but Kafka is down (or vice versa), state diverges permanently.
Problem Code:
// VIOLATION: Vulnerable dual-write without transaction coordination
@Service
public class VulnerableOrderService {
@Autowired
private OrderRepository repository;
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Transactional
public Order placeOrder(OrderRequest request) {
Order order = repository.save(new Order(request.getCustomerId(), request.getTotalAmount(), "CREATED"));
// If Kafka broker is unreachable or network drops here, the database transaction
// commits, but Kafka never receives the event! The read projection desynchronizes forever.
kafkaTemplate.send("order-events", String.valueOf(order.getId()), new OrderCreatedEvent(order.getId(), order.getCustomerId(), order.getTotalAmount()));
return order;
}
}
Refactored Solution:
In high-scale production systems, use the Transactional Outbox Pattern with Debezium Change Data Capture (CDC). The domain entity and the outbox event record are committed atomically inside the same database transaction. Debezium reads the database write-ahead log (WAL) and streams events to Kafka with zero dual-write risk. See our complete implementation in Transactional Outbox Pattern with Spring Boot 4, Kafka, and Debezium.
Anti-Pattern 2: Non-Idempotent Consumer Projections
Kafka provides at-least-once delivery guarantees. Network reconnections or consumer rebalances cause Kafka to redeliver events. If the projection handler increments counters or inserts rows blindly, data corrupts.
Problem Code:
// VIOLATION: Blind insert causes primary key violation or duplicate records
@KafkaListener(topics = "order-events", groupId = "query-group")
public void handleOrderCreated(OrderCreatedEvent event) {
// Bad: If redelivered, this throws duplicate key exception or corrupts aggregates
OrderSummaryView summary = new OrderSummaryView(event.orderId(), event.customerId(), event.totalAmount(), "CREATED");
entityManager.persist(summary);
}
Refactored Solution:
// CLEAN: Idempotent upsert logic handles duplicate event deliveries gracefully
@KafkaListener(topics = "order-events", groupId = "query-group")
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
// Idempotent upsert: Save updates existing record if already present
OrderSummaryView summary = summaryRepository.findById(event.orderId())
.map(existing -> {
// Update existing record if needed
return existing;
})
.orElseGet(() -> new OrderSummaryView(
event.orderId(),
event.customerId(),
event.totalAmount(),
"Pending Fulfillment"
));
summaryRepository.save(summary);
}
Anti-Pattern 3: Querying the Write Model from Presentation Controllers
Developers under deadline pressure sometimes inject the write-side repository into query endpoints to quickly retrieve data, completely breaking CQRS isolation.
Problem Code:
// VIOLATION: Query controller directly queries the write repository
@RestController
@RequestMapping("/api/orders")
public class BadQueryController {
@Autowired
private OrderRepository writeRepository; // Bad: Leaking write model into query layer
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return writeRepository.findById(id).orElseThrow();
}
}
Refactored Solution:
Enforce strict architectural governance using ArchUnit rules, as demonstrated in our companion guide on Master ArchUnit: Writing Custom Rules and Conditions for Spring Boot 4. ArchUnit automatically breaks the build if any query controller references command repositories.
Testing Event-Driven CQRS with Testcontainers and Awaitility
Testing asynchronous event-driven architectures used to require complex mocks or fragile shared staging environments. With Testcontainers and Awaitility in Spring Boot 4, you can spin up real, isolated PostgreSQL and Apache Kafka Docker containers during your standard Maven build.
Here is our complete integration test from the companion workshop project:
package com.webcodein.cqrs;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.webcodein.cqrs.command.OrderRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class CqrsIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "update");
}
@Autowired
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void testCqrsFlow() throws Exception {
// 1. Send Command (Write Model)
OrderRequest request = new OrderRequest();
request.setCustomerId("USER-999");
request.setTotalAmount(499.99);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.customerId").value("USER-999"))
.andExpect(jsonPath("$.totalAmount").value(499.99))
.andExpect(jsonPath("$.status").value("CREATED"));
// 2. Wait for Kafka event processing & Read Model Projection via Awaitility
await().atMost(15, SECONDS).untilAsserted(() -> {
mockMvc.perform(get("/api/orders/summary/USER-999"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].customerId").value("USER-999"))
.andExpect(jsonPath("$[0].totalAmount").value(499.99))
.andExpect(jsonPath("$[0].displayStatus").value("Pending Fulfillment"));
});
}
}
Key highlights of this testing approach:
@Testcontainersmanages the lifecycle of both Docker containers automatically.@DynamicPropertySourceinjects dynamic container ports directly into Spring Boot properties.Awaitility.await().atMost(15, SECONDS)polls the read endpoint cleanly until the Kafka consumer finishes projecting the view, avoiding brittleThread.sleep()calls.
Production Best Practices for Kafka-Driven CQRS
When operating event-driven CQRS architectures in mission-critical production environments, adhere to these proven engineering guidelines:
- Use Meaningful Aggregate Partition Keys: Always set the message key when publishing events to Kafka (e.g.,
orderId). Never publish events without keys. Keyless messages round-robin across partitions, destroying in-order event sequencing. - Implement Dead-Letter Queues (DLQ): Configure a
DeadLetterPublishingRecovererandDefaultErrorHandlerin your Spring Kafka consumer container. Malformed messages must not block partition processing indefinitely. - Track Event Schemas with Schema Registries: In enterprise microservices, use Apache Avro or Protocol Buffers with Confluent Schema Registry. This enforces backward and forward compatibility as domain models evolve.
- Monitor Consumer Group Lag: Set up Prometheus and Grafana alerts for Kafka consumer lag (
kafka_consumergroup_lag). If projection services fall behind by thousands of messages, scale consumer instances to restore real-time read views. - Enforce CQRS Boundaries with ArchUnit: Add automated architectural fitness checks to verify that Command classes never import Query classes, and vice versa.
Frequently Asked Questions
Is CQRS the same thing as Event Sourcing?
No. CQRS and Event Sourcing are complementary but distinct patterns. CQRS separates the write model from the read model. Event Sourcing stores the state of an entity as a sequence of immutable events rather than overwriting current state. You can implement CQRS without Event Sourcing (as demonstrated in this guide using standard relational state persistence), or Event Sourcing without CQRS.
How does CQRS handle transactions across Command and Query databases?
CQRS embraces eventual consistency. There is no distributed two-phase commit transaction spanning the write database and the read database. The Command service commits locally. The event stream propagates the change asynchronously to the Query service. If a projection fails, automated retry workers or dead-letter queues resolve the inconsistency.
Can the Command and Query models live in the same Spring Boot application?
Yes. In a modular monolith, Command and Query models can reside in separate packages within the same Spring Boot service. As traffic scales, you can extract the Query service into an independently deployed microservice without redesigning the core domain logic.
What happens if Kafka is temporarily down when an order is placed?
If Kafka is temporarily unreachable, direct dual-write publishing will fail. To achieve zero data loss, production systems implement the Transactional Outbox Pattern. Events are written to an outbox database table within the same transaction as the order entity, and an asynchronous worker (such as Debezium CDC) publishes them once Kafka reconnects.
When should a team NOT use CQRS?
Do not use CQRS for simple CRUD applications, internal administrative back-offices, or early-stage MVPs with low traffic and symmetrical read/write patterns. The operational overhead of managing Kafka clusters, handling eventual consistency, and maintaining dual schemas is only justified when systems face high scale or extreme read-to-write divergence.
Companion Workshop Code and Integration Test Suite
The complete, fully compilable source code for this enterprise CQRS guide is available in our public GitHub workshop repository. It includes full unit, MockMvc slice, ArchUnit governance, and Testcontainers integration tests running on Java 25 and Spring Boot 4.0.0 GA:
GitHub Repository: CQRS Event-Driven Architecture with Kafka Companion Code
To run the test suite locally with Docker:
git clone https://github.com/salahtobok/learn.webcodein.workshop.git
cd learn.webcodein.workshop/architecture/cqrs-event-driven-architecture-kafka-spring-boot-4
mvn clean test
Authoritative References
- Apache Kafka Official Documentation
- Spring for Apache Kafka Reference Documentation
- Testcontainers Kafka Module Guide
- Microservices.io: Command Query Responsibility Segregation (CQRS) Pattern
- Implementing the Transactional Outbox Pattern with Spring Boot 4, Kafka, and Debezium
- Event-Driven Architecture with Spring Boot 4 and Apache Kafka
- Idempotent Webhook Consumers in Spring Boot 4 and Java 25
- Master ArchUnit: Writing Custom Rules and Conditions for Spring Boot 4
