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.
Read side and write side, walked in parallel
CQRS is two systems sharing a domain. Most articles walk one side (the write model, usually) and tack the read model on at the end. That ordering hides the actual design tension: every decision on one side has a cost or a benefit on the other. This guide walks them in parallel — write decision, read decision, the trade-off between them — so the shape of the pattern is visible from the first section.
Throughout, the example is a banking-style Account aggregate in a Spring Boot service.
Why two models at all
A single model serves both reads and writes when (a) the read shape and the write shape are the same, (b) the read volume is not orders of magnitude higher than the write volume, and (c) consistency requirements are uniform across operations. Most CRUD apps meet all three. CQRS is the right pattern when at least one breaks — typically read-heavy systems where the queries don't look anything like the writes.
For Account: writes are small (deposit, withdraw, freeze) and demand strict consistency. Reads include accountSummary(id), monthlyStatement(id, month), flaggedAccountsThisWeek() — varied shapes, much higher volume, tolerant of bounded staleness. That asymmetry is the case for CQRS.
Decision pair 1 — the write model is an aggregate; the read model is whatever the query needs
Write side. A rich domain model. Operations are commands; commands are validated against invariants:
```java
@Entity
class Account {
@Id UUID id;
@Version long version;
BigDecimal balance;
boolean frozen;void deposit(BigDecimal amount) { if (frozen) throw new AccountFrozenException(id); if (amount.signum() <= 0) throw new IllegalAmountException(amount); balance = balance.add(amount); } } ```
The write model exists to enforce *invariants* — "you cannot withdraw from a frozen account" — and that's why it's an aggregate, not a row.
Read side. No aggregate. The read model is shaped to the *query*. A view table or document for each query the UI actually issues:
CREATE TABLE account_summary_view (
account_id UUID PRIMARY KEY,
customer_name TEXT,
balance NUMERIC,
last_activity TIMESTAMPTZ,
is_frozen BOOLEAN
);
The summary view denormalises customer name into the row so the dashboard query is a single-row read. The write model would never tolerate that denormalisation; the read model exists for nothing else.
Trade-off. Two models means two places to keep in sync. The next decision pair is the cost.
Decision pair 2 — how the read model stays current
Write side emits events on every committed command — MoneyDeposited, MoneyWithdrawn, AccountFrozen. The aggregate's @Transactional method writes both the state change and the outbox row in the same DB transaction:
@Transactional
public void deposit(UUID id, BigDecimal amount) {
Account a = repo.findById(id).orElseThrow();
a.deposit(amount);
outbox.save(new OutboxEvent("MoneyDeposited", a.id, payload(a, amount)));
}
Read side has an async projector that consumes events and updates the view tables:
@KafkaListener(topics = "account.events")
public void onEvent(AccountEvent evt) {
switch (evt.type()) {
case "MoneyDeposited", "MoneyWithdrawn" -> summaryView.applyBalanceChange(evt);
case "AccountFrozen" -> summaryView.markFrozen(evt.accountId());
}
}
Trade-off. The view is *eventually consistent*. A dashboard refreshed 50 ms after a deposit may show the pre-deposit balance. For an account summary that's fine; for the screen immediately after the deposit confirmation, you compensate by returning the new balance directly from the command response, not by re-querying.
Decision pair 3 — what the API actually exposes
Write side. The command endpoint returns the minimum: 202 Accepted with a command id, or the updated entity if the write is small enough to inline.
@PostMapping("/{id}/deposit")
public DepositResponse deposit(@PathVariable UUID id,
@RequestBody DepositRequest req) {
service.deposit(id, req.amount());
return new DepositResponse(id, "accepted");
}
Read side. A separate controller (or a separate service entirely):
@GetMapping("/accounts/{id}/summary")
public AccountSummary summary(@PathVariable UUID id) {
return summaryView.find(id).orElseThrow();
}
Trade-off. Two controllers, two test suites, twice the surface area to document. The benefit is that you can deploy and scale them independently — the read service often runs at 5–10× the replica count of the write service.
Decision pair 4 — storage
Write side. Whatever your domain model is happiest with. For Account that's Postgres because the invariants benefit from ACID transactions. Could be DynamoDB for high write volumes with carefully designed item keys; could be Cassandra for very large aggregates.
Read side. Whatever the *query* is happiest with. For accountSummary that's the same Postgres (a single indexed lookup). For flaggedAccountsThisWeek it might be Elasticsearch (full-text + faceted). For a real-time risk dashboard it could be Redis sorted sets. Multiple read stores from a single write store is the most common CQRS shape in production.
Trade-off. Each read store is another projector to maintain and a separate failure domain. Add them when the query genuinely doesn't fit your write store; don't add them as architectural decoration.
Decision pair 5 — failure handling
Write side. Familiar Spring transactional rollback. A command either commits (state + outbox in one transaction) or it doesn't.
Read side. The projector can lag, fail, or be replayed. Three behaviours to design for:
- Lag. Expose lag as a metric — events_processed_seconds_since_emit. Alert when it exceeds your tolerance (we use 30 seconds for the summary view).
- Failure. A poison event sends the projector to the DLT just like any other Kafka consumer (see the Kafka failure-mode guide). The view stops updating; the metric alerts; the operator looks at the DLT.
- Replay. The single biggest CQRS superpower. If the view is wrong, drop the view table, replay the topic from the beginning, rebuild. This is only possible because the event log is the source of truth — the view is derived.
Trade-off. The replay capability is real, but it requires that *all* events affecting the view are still in Kafka (or in an event store). Tune your topic retention accordingly.
Decision pair 6 — when you don't need full event sourcing
CQRS does not require event sourcing. The pattern above stores aggregate *state* in Postgres and emits events for the read side. Event sourcing stores events as the *primary* representation and rebuilds aggregates from them.
Use plain CQRS (state + events) when the read/write shape mismatch is the problem you're solving. This covers most cases.
Use event sourcing when you additionally need full audit history, time-travel debugging, or temporal queries ("what did the balance look like on 2025-03-14?"). The price is high: aggregate hydration becomes "replay all my events"; snapshots become an operational concern; schema evolution on events is a separate skill.
Most teams need CQRS without event sourcing. Adopt event sourcing the day a real business requirement demands it.
What CQRS is *not*
It is not "use a different database for reads." That's a read replica; CQRS implies two different *models*. It is not "use Kafka instead of REST." Kafka is one transport for events; you can do CQRS over an in-process bus. It is not a license to denormalise every table — only the ones a query justifies.
The summary, in parallel
| Concern | Write side | Read side | |----------------|----------------------------------|----------------------------------------| | Model | Aggregate, enforces invariants | Whatever the query needs | | Storage | Postgres / domain-fit store | One or more query-fit stores | | Consistency | Strict (ACID) | Eventual (bounded lag) | | API | Commands, minimal responses | Queries, denormalised responses | | Scaling | Replicas equal to write load | Replicas equal to read load | | Failure recovery | Transactional rollback | Replay from event log |
Hold those two columns side by side and CQRS stops being a pattern from a slide deck — it's a set of paired engineering decisions, each with a visible cost on the other side.
When CQRS is the wrong tool
CQRS is fashionable and usually the wrong answer. It earns its complexity in narrow situations: the read model needs to be shaped very differently from the write model (a search index, a denormalised feed), the read and write workloads scale at wildly different rates, or you already have event sourcing and CQRS is the natural read side. Outside those, applying CQRS to a normal CRUD service produces a system with two data models to keep in sync, an eventual-consistency window users will complain about, and a debugging story where 'why does this read show old data' becomes a routine incident. If your service can serve reads from the same model you write to, do that. A common pragmatic middle: run a single write model with a materialized-view table (populated by database triggers or a scheduled job) as a cheap read model — you get most of the read-side benefit without the eventual-consistency complexity.
Go deeper
Further reading
Source Code
Get the full project on GitHub
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Software Design & Architecture →
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.
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.
Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith
Why modern teams are returning to modular monoliths — module boundaries, package-by-feature, internal events and a clean migration path to microservices in Spring Boot.
Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications
A practical guide to Ports and Adapters (Hexagonal Architecture) in Spring Boot — isolate your domain, make your code testable, and keep infrastructure swappable.
