Event Sourcing and CQRS: From Theory to Production
Event Sourcing CQRS implementation is one of the most discussed yet misunderstood architectural patterns. Event Sourcing stores state changes as an immutable sequence of events, while CQRS (Command Query Responsibility Segregation) separates read and write models. Therefore, you get a complete audit trail, temporal queries, and optimized read/write paths — but at the cost of increased complexity.
Many teams adopt the approach for the wrong reasons or apply it too broadly. Moreover, it introduces challenges around eventual consistency, event schema evolution, and debugging that aren’t apparent from conference talks. Consequently, this guide provides a realistic, production-focused perspective on when and how to apply these patterns effectively.
Event Store Implementation
The event store is the heart of an event-sourced system — an append-only log of all domain events. Each aggregate has its own event stream identified by a unique ID. Furthermore, optimistic concurrency control ensures that concurrent writes to the same aggregate are detected and handled correctly. The expectedVersion argument is the mechanism: if another writer has appended since you loaded the stream, the append is rejected and you retry.
// Event Store interface
public interface EventStore {
void appendToStream(String streamId, long expectedVersion, List<DomainEvent> events);
List<DomainEvent> readStream(String streamId);
List<DomainEvent> readStream(String streamId, long fromVersion);
}
// Domain events as records
public sealed interface OrderEvent permits OrderCreated, ItemAdded, OrderConfirmed, OrderShipped {
String orderId();
Instant timestamp();
}
public record OrderCreated(String orderId, String customerId, Instant timestamp) implements OrderEvent {}
public record ItemAdded(String orderId, String productId, int quantity, BigDecimal price, Instant timestamp) implements OrderEvent {}
public record OrderConfirmed(String orderId, BigDecimal total, Instant timestamp) implements OrderEvent {}
public record OrderShipped(String orderId, String trackingNumber, Instant timestamp) implements OrderEvent {}
// Aggregate rebuilt from events
public class Order {
private String id;
private String customerId;
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
private BigDecimal total;
// Rebuild state from event history
public static Order fromEvents(List<OrderEvent> events) {
Order order = new Order();
events.forEach(order::apply);
return order;
}
private void apply(OrderEvent event) {
switch (event) {
case OrderCreated e -> { id = e.orderId(); customerId = e.customerId(); status = DRAFT; }
case ItemAdded e -> { items.add(new OrderItem(e.productId(), e.quantity(), e.price())); }
case OrderConfirmed e -> { status = CONFIRMED; total = e.total(); }
case OrderShipped e -> { status = SHIPPED; }
}
}
}
Commands, Invariants, and Optimistic Concurrency
Rebuilding state is only half the write path. Before emitting an event, the aggregate must validate business invariants against its current state — a draft order can accept items, but a shipped order cannot. The command handler loads the stream, replays it into the aggregate, invokes a behavior method, and appends the resulting events under the version it observed.
public class OrderCommandHandler {
private final EventStore store;
public void handle(AddItemCommand cmd) {
List<DomainEvent> history = store.readStream(cmd.orderId());
Order order = Order.fromEvents(cast(history));
// Invariant enforcement lives in the aggregate, not the handler
OrderEvent event = order.addItem(cmd.productId(), cmd.quantity(), cmd.price());
// expectedVersion = history.size(); rejected if a concurrent write happened
store.appendToStream(cmd.orderId(), history.size(), List.of(event));
}
}
When two requests try to modify the same order at once, one append succeeds and the other fails the version check. At that point you either retry the command against the fresh stream or surface a conflict to the caller. Crucially, this keeps invariants correct without database-level locks, because the event store enforces ordering per stream rather than across the whole system.
Event Sourcing CQRS Implementation: Read Projections
CQRS separates the write model (events) from read models (projections). Projections subscribe to events and build optimized views for specific queries. Furthermore, you can have multiple projections from the same stream — one for the dashboard, one for search, one for reporting — each tuned for its access pattern.
// Read projection — optimized for order listing queries
@Component
public class OrderListProjection {
private final JdbcTemplate jdbc;
@EventHandler
public void on(OrderCreated event) {
jdbc.update("""
INSERT INTO order_list_view (order_id, customer_id, status, created_at)
VALUES (?, ?, 'DRAFT', ?)
""", event.orderId(), event.customerId(), event.timestamp());
}
@EventHandler
public void on(ItemAdded event) {
jdbc.update("""
UPDATE order_list_view
SET item_count = item_count + 1,
updated_at = ?
WHERE order_id = ?
""", event.timestamp(), event.orderId());
}
@EventHandler
public void on(OrderShipped event) {
jdbc.update("""
UPDATE order_list_view
SET status = 'SHIPPED', tracking_number = ?, updated_at = ?
WHERE order_id = ?
""", event.trackingNumber(), event.timestamp(), event.orderId());
}
}
// Query service — reads from projection, not events
@Service
public class OrderQueryService {
public List<OrderSummary> getRecentOrders(String customerId, int limit) {
return jdbc.query("""
SELECT * FROM order_list_view
WHERE customer_id = ? ORDER BY created_at DESC LIMIT ?
""", orderSummaryMapper, customerId, limit);
}
}
Two non-obvious requirements separate a toy projection from a production one. First, handlers must be idempotent, because at-least-once delivery means an event can arrive twice after a restart; tracking the last-processed event position per projection lets you skip duplicates. Second, projections are eventually consistent — a freshly placed order may not appear in the list for a few milliseconds, so the UI should account for that lag rather than assume read-after-write.
Schema Evolution: The Problem Nobody Warns You About
Events are immutable and live forever, which means a record you serialized two years ago must still deserialize today. As a result, you cannot simply rename a field or change its type the way you would in a mutable table. In production teams typically reach for an upcasting layer that transforms old event versions into the current shape as they are read.
A common pattern is to add fields with safe defaults and never remove or repurpose existing ones. When a breaking change is unavoidable, you introduce a new event type (for example OrderCreatedV2) and write an upcaster that maps the old version forward. The docs for mature frameworks recommend versioning events explicitly from day one, because retrofitting versioning onto a million existing events is far harder than starting with it.
Snapshots for Performance
As event streams grow, rebuilding aggregates from thousands of events becomes slow. Snapshots periodically save the current state, allowing reconstruction from the snapshot plus only recent events. Additionally, snapshots can be created asynchronously without blocking writes. A typical threshold is to snapshot every few hundred events, then load by reading the latest snapshot and replaying only events appended after it.
However, treat snapshots strictly as a cache, never as the source of truth. If a snapshot is corrupt or its serialization format drifts after a refactor, you must be able to discard it and rebuild from raw events. Therefore, store the aggregate version inside each snapshot so a mismatched or stale snapshot is detected and safely ignored rather than silently trusted.
When NOT to Use Event Sourcing
This pattern is overkill for simple CRUD applications, content management systems, and user preference storage. Moreover, it adds significant complexity to debugging (you must replay events to understand state), testing (projections are eventually consistent), and schema evolution (old events must remain compatible). Therefore, apply it selectively to domains where the audit trail and temporal queries provide genuine business value.
A useful litmus test: if your stakeholders never ask "what did this look like last Tuesday" or "who changed this and why," the temporal machinery is pure cost. In contrast, financial ledgers, order management, inventory, and compliance-heavy workflows benefit enormously because the history is the product. For a lighter alternative that still captures intent, many teams start with the transactional outbox pattern described in Outbox Pattern for Reliable Event Publishing, and only graduate to full event sourcing once the audit requirement is real. See EventStore’s guide and CQRS with the Axon Framework for deeper implementation details.
Key Takeaways
- Enforce invariants inside aggregates and rely on per-stream optimistic concurrency rather than global locks
- Make projection handlers idempotent and track processed positions to survive at-least-once delivery
- Version events from day one and use upcasters so old records keep deserializing
- Treat snapshots as a disposable cache that can always be rebuilt from raw events
- Reserve the pattern for domains where history is a genuine business requirement
In conclusion, Event Sourcing CQRS implementation is a powerful pattern when applied to the right problems. Use it for financial systems, order management, and any domain where knowing “what happened and when” is a business requirement. But be honest about the trade-offs — eventual consistency, increased complexity, and the need for robust event schema evolution strategies.