Event Sourcing and CQRS: Building Scalable Systems
Event sourcing CQRS is one of the most powerful — and most misused — architectural patterns in software engineering. Instead of storing the current state of your data, event sourcing stores every change as an immutable event. CQRS (Command Query Responsibility Segregation) separates the write model from the read model, allowing each to be optimized independently. Together, they enable audit trails, temporal queries, and extreme scalability. But they also add significant complexity. This guide covers when to use them, how to implement them correctly, and when to avoid them.
Understanding Event Sourcing
In a traditional CRUD system, you update rows in place. If an order’s status changes from “pending” to “shipped,” you update the status column and the previous state is lost. With event sourcing, you instead store each state change as an event: OrderCreated, PaymentReceived, ItemsPacked, OrderShipped. The current state is derived by replaying these events in sequence.
// Event definitions
public sealed interface OrderEvent {
UUID orderId();
Instant occurredAt();
record OrderCreated(UUID orderId, UUID customerId,
List<OrderItem> items, Money total,
Instant occurredAt) implements OrderEvent {}
record PaymentReceived(UUID orderId, UUID paymentId,
Money amount, String method,
Instant occurredAt) implements OrderEvent {}
record OrderShipped(UUID orderId, String trackingNumber,
String carrier, Instant occurredAt) implements OrderEvent {}
record OrderCancelled(UUID orderId, String reason,
Instant occurredAt) implements OrderEvent {}
}
// Aggregate rebuilds state from events
public class Order {
private UUID id;
private OrderStatus status;
private Money totalPaid = Money.ZERO;
private final List<OrderEvent> uncommittedEvents = new ArrayList<>();
public static Order reconstitute(List<OrderEvent> history) {
Order order = new Order();
history.forEach(order::apply);
return order;
}
public void ship(String trackingNumber, String carrier) {
if (status != OrderStatus.PAID) {
throw new IllegalStateException("Cannot ship unpaid order");
}
raise(new OrderShipped(id, trackingNumber, carrier, Instant.now()));
}
private void apply(OrderEvent event) {
switch (event) {
case OrderCreated e -> { id = e.orderId(); status = OrderStatus.PENDING; }
case PaymentReceived e -> { totalPaid = totalPaid.add(e.amount()); status = OrderStatus.PAID; }
case OrderShipped e -> { status = OrderStatus.SHIPPED; }
case OrderCancelled e -> { status = OrderStatus.CANCELLED; }
}
}
private void raise(OrderEvent event) {
apply(event);
uncommittedEvents.add(event);
}
}Event Sourcing CQRS: The Event Store
The event store is a specialized database optimized for append-only writes and sequential reads per aggregate. PostgreSQL works well for most use cases, though dedicated event stores like EventStoreDB offer additional features like persistent subscriptions and built-in projections.
-- PostgreSQL event store schema
CREATE TABLE events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_id UUID NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
version INTEGER NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(aggregate_id, version) -- Optimistic concurrency
);
CREATE INDEX idx_events_aggregate ON events(aggregate_id, version);
CREATE INDEX idx_events_type ON events(event_type, occurred_at);
-- Append event with optimistic locking
INSERT INTO events (aggregate_id, aggregate_type, event_type, event_data, version)
VALUES ($1, 'Order', $2, $3, $4)
-- Fails if another process already wrote this version
ON CONFLICT (aggregate_id, version) DO NOTHING
RETURNING event_id;CQRS: Separating Reads from Writes
CQRS pairs naturally with event sourcing because events can be projected into read-optimized views. The write side handles commands through aggregates, while the read side maintains denormalized views optimized for specific queries. These projections update asynchronously as new events arrive.
// Command side: handles business logic
@Service
public class OrderCommandHandler {
private final EventStore eventStore;
public void handle(ShipOrderCommand cmd) {
List<OrderEvent> history = eventStore.loadEvents(cmd.orderId());
Order order = Order.reconstitute(history);
order.ship(cmd.trackingNumber(), cmd.carrier());
eventStore.appendEvents(cmd.orderId(), order.uncommittedEvents());
}
}
// Query side: optimized read model
@Component
public class OrderSummaryProjection implements EventHandler {
@EventListener
public void on(OrderCreated event) {
jdbcTemplate.update("""
INSERT INTO order_summaries (order_id, customer_id, total, status, created_at)
VALUES (?, ?, ?, 'PENDING', ?)
""", event.orderId(), event.customerId(), event.total(), event.occurredAt());
}
@EventListener
public void on(OrderShipped event) {
jdbcTemplate.update("""
UPDATE order_summaries SET status = 'SHIPPED',
tracking_number = ?, shipped_at = ? WHERE order_id = ?
""", event.trackingNumber(), event.occurredAt(), event.orderId());
}
}
// Query API: reads from projections (fast, denormalized)
@RestController
public class OrderQueryController {
@GetMapping("/orders/{customerId}/summary")
public List<OrderSummary> getOrders(@PathVariable UUID customerId) {
return jdbcTemplate.query(
"SELECT * FROM order_summaries WHERE customer_id = ? ORDER BY created_at DESC",
orderSummaryMapper, customerId);
}
}Snapshots for Performance
Replaying thousands of events to rebuild an aggregate is slow. Snapshots periodically save the current state so you only need to replay events since the last snapshot.
public Order loadOrder(UUID orderId) {
// Try loading from snapshot first
Optional<Snapshot> snapshot = snapshotStore.loadLatest(orderId);
List<OrderEvent> events;
Order order;
if (snapshot.isPresent()) {
order = deserialize(snapshot.get().data());
events = eventStore.loadEventsAfter(orderId, snapshot.get().version());
} else {
order = new Order();
events = eventStore.loadAllEvents(orderId);
}
events.forEach(order::apply);
// Save snapshot every 100 events
if (events.size() > 100) {
snapshotStore.save(orderId, order.version(), serialize(order));
}
return order;
}When NOT to Use Event Sourcing and CQRS
Event sourcing adds substantial complexity. Don’t use it for: simple CRUD applications, projects with fewer than 3 developers, domains without audit requirements, or cases where eventual consistency is unacceptable. The overhead of maintaining event schemas, projections, and replay mechanisms is only justified when you genuinely need: complete audit trails, temporal queries (“what was the state last Tuesday?”), or independent scaling of reads and writes. Most applications are better served by traditional CRUD with a separate audit log table.
Key Takeaways
For further reading, refer to the Martin Fowler architecture guides and the Microservices patterns catalog for comprehensive reference material.
Key Takeaways
- Start with a solid foundation and build incrementally based on your requirements
- Test thoroughly in staging before deploying to production environments
- Monitor performance metrics and iterate based on real-world data
- Follow security best practices and keep dependencies up to date
- Document architectural decisions for future team members
Event sourcing CQRS provides powerful capabilities for complex domains: complete history, temporal queries, and independent scalability. But the pattern introduces significant operational complexity including event versioning, projection management, and eventual consistency. Start with CQRS alone if you just need read/write separation, and add event sourcing only when immutable event history is a hard requirement. Always prototype with your actual domain before committing — the pattern that feels elegant in a blog post can become burdensome in a simple CRUD app.
In conclusion, Event Sourcing Cqrs Scalable is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.