CQRS & Event Sourcing
Separating reads from writes: CQRS without event sourcing, event stores and aggregate replay, building projections, snapshots, and an honest account of when not to use either.
On this page
CQRS and event sourcing are two separate ideas that get discussed as one. Understanding them separately is what lets you take the useful half without the expensive half.
Key Takeaways
- CQRS is separate models for reads and writes. That is all it is.
- Event sourcing stores events as the source of truth instead of current state.
- You can do CQRS without event sourcing, and most systems should.
- Read models are eventually consistent — design the UI for it rather than hiding it.
- Neither belongs in a simple CRUD domain.
The two ideas
The motivation is that the two sides have genuinely different requirements. A write model needs to enforce invariants, so it is normalised and loads exactly the data needed to make a decision. A read model needs to answer a specific question fast, so it is denormalised and pre-joined. Forcing one model to do both produces either slow queries or an aggregate that loads half the database to validate one field.
CQRS without event sourcing
The pragmatic version. The write side is ordinary JPA; the read side is projections updated by domain events:
@Service
public class OrderCommandService {
private final OrderRepository orders; // write model: normalised JPA
private final ApplicationEventPublisher events;
@Transactional
public OrderId place(PlaceOrderCommand command) {
// The aggregate is where invariants live. Everything a rule needs
// must be inside this boundary.
Order order = Order.place(command.customerId(), command.lines());
orders.save(order);
// Published after commit so a projection never sees a rolled-back order.
events.publishEvent(new OrderPlaced(order.id(), order.customerId(),
order.total(), Instant.now()));
return order.id();
}
}
@Component
public class OrderSummaryProjection {
private final JdbcTemplate jdbc;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlaced event) {
// Denormalised and pre-joined: one row per order with the customer
// name already resolved, so the list query is a single indexed read.
jdbc.update("""
INSERT INTO order_summary
(order_id, customer_id, customer_name, total_minor_units, status, placed_at)
VALUES (?, ?, (SELECT name FROM customers WHERE id = ?), ?, 'PLACED', ?)
ON CONFLICT (order_id) DO NOTHING
""",
event.orderId(), event.customerId(), event.customerId(),
event.total().minorUnits(), Timestamp.from(event.occurredAt()));
}
}ON CONFLICT DO NOTHING makes the projection idempotent, which matters because event delivery is
at-least-once as soon as it crosses a broker.
This gives you fast, purpose-built queries and a write model free to be normalised, without an event store or replay. For most systems that is the right stopping point.
Event sourcing
Event sourcing goes further: events are the source of truth, and current state is derived by replaying them.
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private String orderId;
private OrderStatus status;
private Money total;
@CommandHandler
public OrderAggregate(PlaceOrderCommand command) {
if (command.lines().isEmpty()) throw new IllegalArgumentException("empty order");
// apply() records the event; it does not mutate state directly.
AggregateLifecycle.apply(new OrderPlacedEvent(
command.orderId(), command.customerId(), command.total()));
}
@CommandHandler
public void handle(CancelOrderCommand command) {
// Decisions are made against state rebuilt from past events.
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException("cannot cancel a shipped order");
}
AggregateLifecycle.apply(new OrderCancelledEvent(orderId, command.reason()));
}
// State transitions happen ONLY here, and this runs both when an event is
// first applied and when replaying history. It must have no side effects.
@EventSourcingHandler
public void on(OrderPlacedEvent event) {
this.orderId = event.orderId();
this.status = OrderStatus.PLACED;
this.total = event.total();
}
@EventSourcingHandler
public void on(OrderCancelledEvent event) {
this.status = OrderStatus.CANCELLED;
}
}The separation between @CommandHandler and @EventSourcingHandler is the discipline that makes it
work. Command handlers decide; event handlers mutate. Because event handlers run again during replay,
any side effect in one — sending an email, calling an API — would fire again every time an aggregate
is loaded.
Replay and snapshots
Replay cost is why aggregate design matters more here than anywhere else. An aggregate accumulating thousands of events is slow to load, and a snapshot is a workaround rather than a fix — the real question is usually whether the aggregate boundary is too large, or whether a long-lived aggregate should be closed and superseded.
The genuine superpower of event sourcing is rebuilding a projection. Deploy a new read model, replay the entire event history through it, and you have a view that has always existed — including for data from before the feature was conceived. That is impossible with state-based storage, and it is the strongest argument for the pattern.
Eventual consistency in the UI
The projection lags the write by milliseconds to seconds. A user who creates an order and is immediately shown a list that does not contain it will report a bug.
Three honest approaches. Return the created resource from the command endpoint so the UI can render it optimistically without querying. Poll briefly with a bounded timeout and a subtle loading state. Or push over WebSocket or SSE when the projection catches up.
What does not work is hoping nobody notices. The lag is real, it grows under load, and it is exactly when the system is busy that users will see it.
When not to use either
Be honest about the cost. CQRS adds a second model to keep in sync and a consistency delay to explain. Event sourcing adds an event store, schema evolution for events retained forever, projection rebuild tooling, and a mental model most developers have not used.
Neither is justified for simple CRUD where reads and writes want the same shape. Neither is justified for a small team already stretched, or an early-stage product where the domain is still changing weekly — event sourcing makes changing your mind about the domain considerably more expensive.
They pay off where the domain is genuinely complex with rich invariants, where read and write loads differ by orders of magnitude, where a complete audit trail is a regulatory requirement, or where temporal queries — what did this look like on that date — are a product feature rather than a nice idea.
A reasonable path: start with a normal model, add CQRS projections when a query genuinely cannot be served well from the write model, and consider event sourcing only for the specific aggregates whose history is itself valuable.
What to take away
CQRS and event sourcing are separable, and CQRS alone gives most of the practical benefit. Keep command handlers deciding and event handlers mutating with no side effects. Make projections idempotent, design the UI for the consistency lag, and apply event sourcing narrowly — to the aggregates where history is the product, not to everything.
Frequently Asked Questions
Can I use CQRS without event sourcing?
How do I handle the eventual consistency a user notices?
When are snapshots necessary?
Related tutorials
- Design Patterns for Java BackendsThe GoF patterns as Spring actually implements them, the ones worth writing yourself, and modern Java alternatives using records, sealed types and pattern matching.
- Domain-Driven Design in PracticeDDD applied rather than described: choosing aggregate boundaries, value objects that enforce invariants, repositories, hexagonal architecture, and running an event storming session.
- Transaction Management Deep DiveTransactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
- API Security & the OWASP API Top 10The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.