An online shop starts with an orders table and one OrderService class that does everything. A year later, the admin screen needs to list orders with totals, the query that joins five tables runs slowly, and adding indexes slows down writes. That is when many teams hear about CQRS (Command Query Responsibility Segregation), apply it to the whole system, and most of them end up unhappy. This article explains CQRS and Event Sourcing with a Java 21 order that really runs on H2: the write side emits events, the read side builds a flat table, and we cover optimistic locking, replay, and upcasting. More importantly, it shows when not to use CQRS.
This article follows Upgrading to Spring Boot 3: Jakarta EE, Java 17 and observability, where we ended up with an observable application. The domain model used for CQRS here is the Aggregate from DDD in Java: Entity, Value Object and Aggregate. The storage layer differs from Repository pattern and Unit of Work with JPA in that we store events instead of state.
What CQRS is: separating the write model from the read model
Martin Fowler sums it up briefly in his article on CQRS: use one model to update information and a different model to read it. The Azure Architecture Center guide describes the same idea: a command changes data and represents a business intent such as "Book hotel room", while a query never changes data and returns a DTO that fits the screen. That page lists the problems of a CRUD model that uses one schema for both reads and writes, such as different data representations for reads and writes, lock contention, and complex queries.
The easy thing to misunderstand is how far to separate. CQRS has two levels:
- Same database: two different code models (for example a JPA entity for writes, and SQL or a projection that returns a DTO for reads) over the same tables. Low cost, and still immediately consistent.
- Two data stores: the read side has its own tables, indexes, or search engine, updated from the write side through events. It unlocks independent scaling, but brings eventual consistency with it.
Event Sourcing is a different idea that often goes with CQRS but is not required. According to Fowler, Event Sourcing records every state change as an ordered sequence of events; the current state is derived by replaying that sequence. You can use CQRS while still storing plain state, and you can use Event Sourcing with just one simple read model. The two combine naturally because the event store is the write model, and the flat tables built from events are the read model.
The CQRS write side: commands decide, events are the truth
Start with events. An event is something that has already happened, named in the past tense and immutable. Use a sealed interface with record types so the compiler checks that every case is covered in a switch:
package demo.cqrs;
/** Domain event: it has happened, it never changes, and it is named in the past tense. */
public sealed interface OrderEvent {
record OrderPlaced(String orderId, String customer, String currency) implements OrderEvent {}
record ItemAdded(String orderId, String sku, int qty, long unitPriceVnd) implements OrderEvent {}
record OrderPaid(String orderId, long amountVnd) implements OrderEvent {}
record OrderCancelled(String orderId, String reason) implements OrderEvent {}
String orderId();
}The Order aggregate has two separate halves. The decide half receives a command, checks the rules, and returns a list of new events without changing state. The apply half receives an event and changes state; it always succeeds because the event has already happened. The same apply is also used when loading the aggregate back from history, so business rules are not run again during replay:
public List<OrderEvent> addItem(String sku, int qty, long unitPriceVnd) {
if (status != Status.PLACED) throw new IllegalStateException("Items can only be added when the order is PLACED, currently " + status);
if (qty <= 0 || unitPriceVnd <= 0) throw new IllegalArgumentException("Quantity and price must be positive");
return List.of(new ItemAdded(id, sku, qty, unitPriceVnd));
} public void apply(OrderEvent e) {
switch (e) {
case OrderPlaced p -> { id = p.orderId(); status = Status.PLACED; }
case ItemAdded i -> { totalVnd += i.qty() * i.unitPriceVnd(); itemCount += i.qty(); }
case OrderPaid p -> status = Status.PAID;
case OrderCancelled c -> status = Status.CANCELLED;
}
version++;
}The event store is an append-only table. The primary key (stream_id, version) is the optimistic locking mechanism: if two people write the next version of the same order, the second one violates the constraint and is rejected. This is how the Azure Architecture Center describes it: the event store rejects a write if the stream has changed since it was read. The schema_ver column supports the upcasting section below. The append method wraps the whole batch of events in one transaction: if the second event violates the primary key, the first event is rolled back too.
public void append(String streamId, int expectedVersion, List<? extends OrderEvent> events) {
int v = expectedVersion;
try (PreparedStatement ps = cx.prepareStatement(
"INSERT INTO events(stream_id, version, type, schema_ver, payload) VALUES (?,?,?,?,?)")) {
cx.setAutoCommit(false);
try {
for (OrderEvent e : events) {
v++;
ps.setString(1, streamId); ps.setInt(2, v);
ps.setString(3, e.getClass().getSimpleName());
ps.setInt(4, e instanceof OrderPlaced ? 2 : 1);
ps.setString(5, json.writeValueAsString(e));
ps.addBatch();
}
ps.executeBatch();
cx.commit();
} catch (Exception e) { cx.rollback(); throw e; }
finally { cx.setAutoCommit(true); }
} catch (SQLException e) {
if (e.getSQLState() != null && e.getSQLState().startsWith("23"))
throw new ConcurrencyException("Stream " + streamId + " was written by someone else (expected v" + expectedVersion + ")", e);
throw new RuntimeException(e);
} catch (Exception e) { throw new RuntimeException(e); }
}The command handler only loads the aggregate from history, calls the behavior, and writes the events with expectedVersion. Run the first scenario (code/s6-26, command java -cp target/classes:$(cat cp.txt) demo.cqrs.Main, JDK 21, in-memory H2):
=== 1. Write side: commands -> events ===
State after 3 commands: Order[DH001, PLACED, total=390000, items=3, v2]
Stored events:
OrderPlaced[orderId=DH001, customer=Nguyen Van A, currency=VND]
ItemAdded[orderId=DH001, sku=AO-THUN-M, qty=2, unitPriceVnd=150000]
ItemAdded[orderId=DH001, sku=NON-LUOI-TRAI, qty=1, unitPriceVnd=90000]Three commands produce three events. There is no orders table; the state total=390000 is derived from the events. Scenario two shows that the rules live in decide: once an order is paid, the add-item command is rejected and the event count stops at 4 (placed, two item additions, payment).
=== 2. Domain rules live in decide: a bad command is rejected, no event is written ===
Rejected: Items can only be added when the order is PLACED, currently PAID
Event count of DH001: 4The CQRS read side: flat tables built from events
The read side in CQRS knows nothing about Order. The read model is just one flat order_summary table with exactly the columns the screen needs, plus a projector that reads events in global write order (position) to update that table. The projector stores its checkpoint in the same transaction as the table update: if it fails midway, both roll back, so no event is applied twice.
public int runOnce(int batch) throws SQLException {
long pos = checkpoint();
List<EventStore.Stored> events = store.loadAfter(pos, batch);
cx.setAutoCommit(false);
try {
for (EventStore.Stored s : events) { project(s.event()); pos = s.position(); }
try (PreparedStatement ps = cx.prepareStatement("UPDATE projection_checkpoint SET position=? WHERE name='order_summary'")) {
ps.setLong(1, pos); ps.executeUpdate();
}
cx.commit();
} catch (SQLException | RuntimeException e) { cx.rollback(); throw e; }
finally { cx.setAutoCommit(true); }
return events.size();
}The key point of two-store CQRS is the lag between writing and reading. Scenario 3 queries the read model before and after the projector runs:
=== 3. Read side: eventual consistency ===
Query BEFORE the projection runs: (not in the read model yet)
Projection applied 4 events
Query AFTER the projection runs: Nguyen Van A | 3 items | 390,000 VND | PAIDRight after the payment command succeeds, the query still returns "not in the read model yet". In practice the projector runs in the background a few milliseconds to a few seconds later, but the UI has to tolerate that delay. The Azure Architecture Center states plainly that this is the trade-off: when you split the read and write stores, the read data may not yet reflect the latest change. Common ways to handle it are to show the result from the command response instead of querying again, or to return the stream version so the client can wait for the read model to catch up.
In a real environment, writing an event and publishing it to a message broker do not happen in one distributed transaction, so Azure recommends a Transactional Outbox and idempotent consumers to tolerate duplicate delivery. The example in this article lets the projector read the event table directly in the same database, which is the simplest approach. There is a limit: the demo has only one writer. With several concurrent writers, an auto-increment position can commit out of order (a transaction with a smaller position commits after one with a larger position), and a projector using position > checkpoint will skip that event. You then have to handle the gaps, read only up to the committed horizon, or use an outbox.
Concurrency conflicts in CQRS: optimistic locking
Two staff members open order DH002 at version 0; A adds a shirt and B adds trousers. With CRUD that lacks a version column, the later writer overwrites the earlier one (a lost update). With an event store, every write carries an expectedVersion:
=== 4. Optimistic locking: two people edit the same order ===
B was rejected: Stream DH002 was written by someone else (expected v0)
DH002 after the conflict: Order[DH002, PLACED, total=100000, items=1, v1]B is rejected, and what to do next is to reload the aggregate, re-check the rules, then retry or tell the user. The conflict is detected at the level of a single stream, which is cheap and clear.
Replay: rebuilding the CQRS read model when requirements change
Suppose the sales team wants a new report. With CRUD, data that was never stored is gone. With Event Sourcing, all the information is in the events; you only need to change the projector, delete the read model, and replay. Scenario 5 does exactly this:
=== 5. Rebuild the read model from scratch by replay ===
Before rebuild: DH002 = Tran Thi B | 1 items | 100,000 VND | PLACED
Replayed 6 events
After rebuild: DH001 = Nguyen Van A | 3 items | 390,000 VND | PAID
After rebuild: DH002 = Tran Thi B | 1 items | 100,000 VND | PLACEDSix events are replayed and the read model returns to exactly the right state. Fowler also points out the consequence: replay will resend external messages if the event-handling code has side effects (email, payment gateway). That is why a projector must be pure and write only to the read model.
Snapshots: when a stream gets too long
Replaying everything gets slower as a stream grows; a snapshot stores the state at a given version so that only the tail needs to be replayed. The stream of one order usually has only a few dozen events, so snapshots are rarely needed; they matter for long-lived aggregates such as accounts or ledgers. We measured with demo.cqrs.Bench (Apple M4, 16 GB, JDK 21, in-memory H2, average of 200 runs after JIT warm-up, run twice in a row to show the variation):
Events Full replay (ms) Snapshot + 50 events (ms)
100 0.19 0.08
1000 0.52 0.02
10000 4.69 0.02
Events Full replay (ms) Snapshot + 50 events (ms)
100 0.26 0.12
1000 0.48 0.03
10000 3.77 0.02 With 10,000 events, a full load takes about 4 to 5 ms on in-memory H2, while a snapshot plus the 50 tail events takes only about 0.02 ms. The database runs in the same process with no network latency; with a real PostgreSQL or MySQL, read times will be much larger. What to remember is the trend: replay grows with stream length, while the snapshot stays almost flat. The Azure Architecture Center suggests taking a snapshot every N events and stresses that a snapshot is only an optimization; the event stream remains the source of truth and the snapshot can be rebuilt at any time.
Changing the event structure: upcasting
A recorded event is never edited. But requirements change: in March you add a currency field to OrderPlaced, while millions of old events do not have it. The Azure Architecture Center lists four strategies: lenient reading (ignore unknown fields, use defaults for missing ones), versioning events, upcasting (convert old events to the new schema on read, leaving the stored data as is), and in-place migration (the last resort because it breaks immutability). Axon Framework also uses upcasters chained per revision and calls this "non-destructive refactoring". Our example implements simple upcasting in the read function:
private OrderEvent read(String type, int schemaVer, String payload) throws Exception {
JsonNode n = json.readTree(payload);
if (type.equals("OrderPlaced") && schemaVer < 2) ((ObjectNode) n).put("currency", "VND");
return switch (type) {
case "OrderPlaced" -> json.treeToValue(n, OrderPlaced.class);
case "ItemAdded" -> json.treeToValue(n, ItemAdded.class);
case "OrderPaid" -> json.treeToValue(n, OrderPaid.class);
case "OrderCancelled" -> json.treeToValue(n, OrderCancelled.class);
default -> throw new IllegalStateException("Unknown event type: " + type);
};Scenario 6 inserts a schema v1 event directly (without currency) and then reads it with the new code:
=== 6. Upcasting: an old event (schema v1) has no currency ===
Read result: OrderPlaced[orderId=DH000, customer=Legacy customer, currency=VND]The old event is read with currency=VND while the stored row stays unchanged. That is why schema_ver should exist from day one.
When not to use CQRS and Event Sourcing
This is the most important part of an article about CQRS. Fowler writes that CQRS is a significant mental leap, suits only a small minority of domains, and in his experience most attempts to apply CQRS go badly. The Azure Architecture Center says CQRS is not a good fit when the business rules are simple and a CRUD-style interface is enough. About Event Sourcing, that guide warns that moving to or away from it is very costly, and that for most systems traditional data management is enough.
| Situation | Choose | Reason |
|---|---|---|
| CRUD admin pages, catalogs, configuration | Plain CRUD | CQRS does not bring enough benefit to pay for the complexity |
| Slow report queries, normal writes | Read replica, view, reporting table | Fowler: if you only need to offload heavy queries, a reporting database may be enough |
| Reads dozens of times more than writes, need independent scaling | CQRS on the same database, then split the read store if needed | Split gradually, and measure the benefit of each step |
| Need full history, auditing, state reconstruction (ledger, payments) | Event Sourcing for that aggregate only | History is a business requirement, not a convenience |
| Need to read what you just wrote, strong consistency | Do not split the read store | Eventual consistency is inherent to splitting stores |
| Team new to event-driven architecture, MVP, short-lived system | CRUD | High learning and operating cost, hard to turn back |
A sensible rule: apply CQRS per bounded context, not to the whole system, as Fowler makes clear. For example, the payment ledger uses Event Sourcing, while the product catalog and customer profiles stay CRUD behind the familiar ports and adapters.
Practical problems with Event Sourcing
- Personal data and the right to erasure: immutable events conflict with requests to delete personal data. Solutions: store personal data outside the event store and reference it by id, or encrypt per data subject and delete the key (crypto-shredding); this must be designed in from the start.
- Design events around intent:
ItemAddedis more meaningful thanTotalChanged. Events that only record the new state turn the event store into a meaningless change log. - A message broker is not an event store: Kafka is good for delivering events to projectors and other systems, but it lacks per-stream queries and per-entity optimistic locking.
Common mistakes
- Using CQRS for everything: it turns one CRUD page into five classes and two tables.
- Putting rules into
apply: ifapplythrows a business error, old events can no longer be replayed.
Checklist before choosing CQRS for a system
- Before choosing CQRS, have you tried optimizing queries, indexes, read replicas, and views? Do you have measurements showing they are not enough?
- Does the domain have complex rules on the write side that give CQRS something to work on, or is it just CRUD?
- Can the UI tolerate eventual consistency, and have you thought about how to show the result right after a write?
- If you use Event Sourcing: is there a business requirement for history? Is there a plan for upcasting, snapshots, and personal data?
In short, CQRS solves a specific problem, and Event Sourcing is only worth it when history has business value. Start with the lightest version, in one small bounded context, and let measurements decide.
Next article
Once you have split the write and read sides with CQRS, every service still has to call other services over the network, and networks are not reliable. Continue with Circuit Breaker, Retry and Bulkhead with Resilience4j to see how a Spring application protects itself when a dependency is slow or failing.



