CQRS Pattern in Spring Boot — Separating Reads and Writes for Scale
A complete production guide to the CQRS pattern in Spring Boot — write models, query models, event projections, Kafka integration, consistency trade-offs and real-world scaling patterns.
Introduction
Most Spring Boot applications start as a single REST controller backed by a single JPA repository and a single relational schema. Reads and writes share the same model, the same transactions and the same database. That works beautifully — until the read traffic outgrows the write traffic by an order of magnitude, the query joins become unmanageable, or a single product page starts needing data from five aggregates at once.
CQRS (Command Query Responsibility Segregation) is the architectural pattern that separates the model that mutates state (commands) from the model that returns state (queries). Each side can be designed, scaled and stored independently. This tutorial is a complete production walkthrough of CQRS with Spring Boot: when to use it, how to wire commands and queries, how to project events into a read model with Kafka, and how to operate the resulting system safely.
Why CQRS
Three concrete pressures push teams toward CQRS:
- Asymmetric load. Read traffic typically dwarfs write traffic by 10–1000×. Caching helps, but eventually you want a storage shape optimized for queries (denormalized rows, search index, materialized view) that the write model cannot provide.
- Diverging models. The write model wants a normalized, invariant-protecting schema. The read model wants whatever shape the UI needs. Forcing both into one schema produces brittle joins and slow queries.
- Independent scalability. Commands need consistency and durability; queries need throughput. Splitting them lets you scale the right axis without overpaying for the other.
CQRS is *not* free. You add a projection pipeline, eventual consistency between sides, and two data stores to operate. Use it where the asymmetry justifies the complexity — and resist using it everywhere.
Architecture
Traditional CRUD Architecture
CQRS architecture overview
In a CQRS system the API layer routes each request to one of two pipelines:
- The command side validates input, loads the aggregate, applies business rules and persists the result. It emits a domain event when state changes.
- The query side reads from a denormalized store that is kept up to date by projecting those domain events. Queries never touch the write model.
Architecture
CQRS Architecture — Split Command & Query Sides
The event bus in the middle (Kafka, RabbitMQ, the Outbox pattern, or a database CDC stream) is what makes the two sides independent. The write model owns *what is true*; the read model owns *how it is presented*.
Real-world use cases
- E-commerce product catalogs. Writes are infrequent (admin updates), reads are massive (every page view). A read model in Elasticsearch or Redis serves search and detail pages.
- Order management. Orders go through a strict state machine on the write side; dashboards, analytics and customer-facing order history live on the read side.
- Banking and ledgers. The write model enforces double-entry invariants; the read model serves account statements and balance summaries from pre-aggregated views.
- SaaS reporting. Operational data is normalized; reports are pre-computed projections refreshed by events.
Step 1 — Define commands and the command handler
A command is an intent: "create this order", "ship this shipment". It is a plain immutable record, validated at the edge of the system.
public record CreateOrderCommand(
UUID customerId,
List<OrderLine> lines,
String shippingAddress
) {}
The command handler is a Spring @Service that loads (or constructs) the aggregate, applies the command, persists and emits an event.
```java
@Service
@RequiredArgsConstructor
public class CreateOrderHandler {private final OrderRepository orders; private final DomainEventPublisher events;
@Transactional public UUID handle(CreateOrderCommand cmd) { Order order = Order.create(cmd.customerId(), cmd.lines(), cmd.shippingAddress()); orders.save(order); events.publish(new OrderCreatedEvent(order.getId(), order.getCustomerId(), order.getTotal())); return order.getId(); } } ```
Two things to notice: the handler is @Transactional, and the event publish happens inside the same transaction. In production you typically combine this with the Outbox pattern (covered in our Outbox tutorial) so the event is never lost if Kafka is unavailable.
Step 2 — Define queries and the query handler
A query is a read-only request. It returns a DTO shaped exactly for its caller.
```java
public record OrderSummaryQuery(UUID orderId) {}public record OrderSummaryDto( UUID id, String customerName, BigDecimal total, String status, Instant lastUpdated ) {}
@Service @RequiredArgsConstructor public class OrderQueryHandler { private final OrderSummaryViewRepository views;
public OrderSummaryDto handle(OrderSummaryQuery q) { return views.findById(q.orderId()) .orElseThrow(() -> new NotFoundException("order " + q.orderId())); } } ```
The OrderSummaryView is a denormalized JPA entity stored in a separate schema (or a separate database). It contains exactly the columns the UI needs — no joins required at read time.
Step 3 — Wire the REST layer
The controller is thin. It only translates HTTP to commands and queries.
```java
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {private final CreateOrderHandler create; private final OrderQueryHandler query;
@PostMapping public ResponseEntity<Map<String, UUID>> create(@RequestBody @Valid CreateOrderCommand cmd) { UUID id = create.handle(cmd); return ResponseEntity.created(URI.create("/orders/" + id)) .body(Map.of("id", id)); }
@GetMapping("/{id}") public OrderSummaryDto get(@PathVariable UUID id) { return query.handle(new OrderSummaryQuery(id)); } } ```
Step 4 — Publish domain events
Domain events are the contract between sides. Define them as immutable records and route them through a publisher abstraction so you can swap the transport without touching the domain.
```java
public sealed interface DomainEvent permits OrderCreatedEvent, OrderShippedEvent {}public record OrderCreatedEvent(UUID orderId, UUID customerId, BigDecimal total) implements DomainEvent {}
@Component @RequiredArgsConstructor public class KafkaDomainEventPublisher implements DomainEventPublisher { private final KafkaTemplate<String, DomainEvent> kafka;
@Override public void publish(DomainEvent event) { String topic = "orders.events"; kafka.send(topic, event.getClass().getSimpleName(), event); } } ```
Step 5 — Project events into the read model
The projection worker is a Kafka consumer that maintains the read model.
```java
@Component
@RequiredArgsConstructor
public class OrderProjection {private final OrderSummaryViewRepository views; private final CustomerRepository customers;
@KafkaListener(topics = "orders.events", groupId = "order-projection") public void on(DomainEvent event) { switch (event) { case OrderCreatedEvent e -> { var customer = customers.findById(e.customerId()).orElseThrow(); views.save(new OrderSummaryView( e.orderId(), customer.getName(), e.total(), "CREATED", Instant.now())); } case OrderShippedEvent e -> { var v = views.findById(e.orderId()).orElseThrow(); v.setStatus("SHIPPED"); v.setLastUpdated(Instant.now()); views.save(v); } } } } ```
Architecture
CQRS Request Flow — End to End
The projection is idempotent: replaying the same event must produce the same row. This is what lets you rebuild the read model from scratch by re-consuming the topic from offset zero.
Production best practices
- Embrace eventual consistency. Document the expected lag (usually <1s) and design UX accordingly — show "processing" states instead of pretending the read is instant.
- Use the Outbox pattern. Never publish events from the application directly in a separate step; pair every command with an outbox row written in the same transaction.
- Version your events. Add a
schemaVersionfield from day one. Future you will thank you. - Make projections idempotent and keyed. Use the aggregate ID as the Kafka message key so all events for one aggregate land on the same partition in order.
- Snapshot the read model. For large catalogs, rebuilding by replay is slow; periodically take a snapshot so cold rebuilds start from a recent checkpoint.
- Monitor projection lag. Alert when the consumer group lag on
orders.eventsexceeds your SLO. - Test rebuilds in staging. A read model you cannot rebuild is a liability.
Security considerations
- Apply authorization at both sides. Commands check "can this user perform this action?"; queries enforce row-level filters so a customer never sees another customer's data.
- Encrypt PII at rest in the read model. Denormalization spreads sensitive fields across rows — keep the encryption envelope consistent.
- Audit every command. Persist who issued it and what it changed; commands are the only place state moves.
- Validate event payloads on the consumer side. Treat the Kafka topic as untrusted input even when it comes from your own services.
Common mistakes
1. Using CQRS for a CRUD app. If your reads and writes look the same and the load is symmetrical, you don't need CQRS — you need a clean repository. 2. Sharing the same database between sides. This re-couples them and removes most of the benefit. Use at least separate schemas, ideally separate stores. 3. Synchronous projection inside the command transaction. This makes the write path as slow as the read path's worst case and re-introduces dual-write failures. 4. Forgetting idempotency. Kafka is at-least-once. A non-idempotent projection will eventually double-count. 5. No event versioning. Adding a field later breaks every consumer at once. 6. Querying the write side "just this once". Once you allow it, the boundary erodes and you end up with neither pattern.
Troubleshooting guide
- Stale reads in the UI. Check consumer lag in Kafka (
kafka-consumer-groups.sh --describe). If lag is growing, the projection is slower than the producer — scale consumers or batch DB writes. - Read model drift. Run a reconciliation job that compares aggregate counts on each side; rebuild the offending projection by replaying from offset zero.
- Duplicate rows in the read model. Your projection is not idempotent or the upsert key is wrong. Use
INSERT … ON CONFLICT DO UPDATEor aggregate ID as the primary key. - Out-of-order updates. Ensure events for one aggregate use the same Kafka key so they hit the same partition. Out-of-order across aggregates is fine.
- Slow rebuilds. Reduce projection work per event (avoid synchronous external calls), use bulk inserts, and add a snapshot.
FAQ
1. Do I need event sourcing to use CQRS? No. Event sourcing and CQRS are independent. You can keep a normal relational write model and still split read/write responsibilities.
2. What if I need strong consistency on a read? Route that single read to the write side, or use a read-your-writes token (e.g. include the write transaction ID and wait until the projection has processed it).
3. Can I use the same database for both sides initially? Yes — start with two schemas in the same Postgres instance, then split when the load demands it. This is a valid evolutionary path.
4. Which broker should I pick? Kafka if you need durable, replayable streams (most CQRS systems). RabbitMQ if you mainly need RPC-style routing. Outbox + Debezium if you want to keep the transactional guarantee without changing producers.
5. How big is the eventual consistency lag in practice? Sub-second for healthy systems. Set an SLO (e.g. p99 < 2s) and alert on lag.
6. Where do I put validation — command or controller? Both. The controller rejects malformed input fast; the command handler enforces business invariants.
7. How do I handle queries that span many aggregates? That is exactly where CQRS shines — build a projection that joins them once at write time, then query the flat view.
8. Can I use CQRS inside a modular monolith? Yes, and it is a great way to learn the pattern without committing to microservices. See our modular monolith tutorial.
9. How do I delete data for GDPR? Delete from the write side, emit a redaction event, and have projections null out PII columns. Snapshots and topic compaction handle the broker side.
**10. When should I *not* use CQRS?** Small teams, low traffic, simple CRUD, or any case where the cost of two stores and eventual consistency outweighs the read-side benefit.
Key takeaways
- CQRS separates the model that mutates state from the model that returns it.
- Use it when reads dominate writes, or when the query shape diverges sharply from the domain model.
- Wire it through Kafka with idempotent projections; combine with the Outbox pattern for reliability.
- Embrace eventual consistency, version your events, and monitor projection lag.
- Don't reach for CQRS on a CRUD app — pay the complexity tax only where it earns its keep.
Related tutorials
- The Outbox Pattern — Reliable Event Publishing in Microservices
- Designing Event-Driven Microservices with Kafka and Spring Boot
- Spring Boot Microservices Architecture Explained Step by Step
- Hexagonal Architecture with Spring Boot
- Domain-Driven Design with Spring Boot
- Redis Distributed Caching Architecture for High-Traffic APIs
Architecture
REST API — Layered Backend
TL;DR
Key takeaways
- Understand the core concepts behind CQRS Pattern in Spring Boot — Separating Reads and Writes for Scale in a production context.
- Apply the patterns to real Software Design & Architecture systems, not just toy examples.
- Recognize the trade-offs, failure modes, and operational concerns before adopting them.
- Get a clear path to the next step — related tutorials, tools, and reference architectures.
Avoid these
Common mistakes
1. Copy-pasting code without understanding the trade-offs
It's tempting to ship a snippet from a blog post into production, but Software Design & Architecture patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.
2. Skipping observability from day one
Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging Software Design & Architecture systems without them is painful and expensive.
3. Optimizing too early
Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.
4. Ignoring security defaults
Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.
Ship it safely
Production best practices
Apply these before promoting CQRS Pattern in Spring Boot — Separating Reads and Writes for Scale to a real production environment.
Scalability
Design Software Design & Architecture services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.
Monitoring & Observability
Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.
Logging
Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.
Security
Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.
Testing
Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For Software Design & Architecture systems, also run chaos and load tests before a major release.
Reliability & Rollouts
Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.
Questions
Frequently asked questions
Is this tutorial up to date?
Yes. This tutorial was last reviewed and updated on June 3, 2026. We revisit popular Software Design & Architecture tutorials regularly to keep them aligned with current best practices.
What level is this tutorial aimed at?
It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.
Do I need to follow every step in order?
The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.
Where can I find the source code?
The full source code is available on GitHub: https://github.com/masterlabsystems/cqrs-spring-boot-demo. Fork it, run it locally, and adapt it to your own project.
Go deeper
Further reading
Source Code
Get the full project on GitHub
More From the Channel
Follow the full tutorial series on YouTube
The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.
Stay in the Loop
Get the next tutorial in your inbox
next tutorial →
The Outbox Pattern — Reliable Event Publishing in Microservices
Related tutorials
Essential Software Design Principles Every Developer Should Know
A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.
DRY, KISS, and YAGNI Explained
Three short acronyms that prevent most over-engineering — what DRY, KISS and YAGNI mean and how to apply them without going too far.
Composition Over Inheritance in Java
Why Java developers should prefer composition over inheritance — with side-by-side refactoring examples that show why composition scales better.
