Pavan Rangani

HomeBlogEvent Sourcing CQRS Pattern: Complete Implementation Guide for Scalable Systems

Event Sourcing CQRS Pattern: Complete Implementation Guide for Scalable Systems

By Pavan Rangani · February 23, 2026 · Architecture

Event Sourcing CQRS Pattern: Complete Implementation Guide for Scalable Systems

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. However, 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 overwrite the status column and the previous state is lost forever. 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.

This inversion has a profound consequence: the event log becomes the single source of truth, and current state is merely a cached projection of it. Because events are immutable and append-only, you never lose history, you can answer questions you did not anticipate when you designed the system, and you can rebuild any read model from scratch by replaying the log.

// 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);
    }
}

Notice the discipline encoded here: business rules live in command methods like ship(), which validate against current state before raising an event. The apply() method, by contrast, must never throw or contain logic — it only mutates state. This separation matters because apply() runs during replay of historical events, and historical events have already happened; rejecting one would corrupt the rebuild.

Event sourcing CQRS architecture design
Event sourcing stores every state change as an immutable event, enabling complete audit trails

The Event Store and Optimistic Concurrency

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 stores like EventStoreDB offer extras such as persistent subscriptions and built-in projections. The single most important constraint is the unique index on (aggregate_id, version), which enforces optimistic concurrency.

-- 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;

When two requests load the same order at version 7 and both try to write version 8, the unique constraint guarantees exactly one succeeds. The loser sees an empty RETURNING result and must reload, re-apply its command, and retry. This is far safer than pessimistic row locks, which would serialize all writes to a hot aggregate and tank throughput under contention.

Separating Reads from Writes with CQRS

CQRS pairs naturally with this approach because events can be projected into read-optimized views. The write side handles commands through aggregates, while the read side maintains denormalized views tuned for specific queries. These projections update asynchronously as new events arrive, which is precisely what lets reads and writes scale on different hardware.

// 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);
    }
}

The critical trade-off here is eventual consistency. After a command succeeds, the projection may lag by milliseconds to seconds, so a user who just shipped an order might briefly see it as “paid” on the next screen. Teams handle this by reading their own writes from the aggregate for the immediate response, showing optimistic UI updates, or tracking a projection checkpoint and waiting until it catches up.

CQRS read write separation architecture
CQRS separates write and read models, allowing each to be optimized independently

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. A long-lived aggregate — say a bank account with years of transactions — could otherwise require replaying tens of thousands of events on every load.

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;
}

One caution: treat snapshots as a disposable optimization, never as a source of truth. If you change the shape of your aggregate, old snapshots may deserialize incorrectly, so always retain the ability to discard every snapshot and rebuild purely from events.

Event Versioning and Schema Evolution

Because events are immutable and stored forever, you cannot simply change their structure when requirements evolve. An event written two years ago must still deserialize today. This is the single hardest operational reality of the pattern, and it deserves a plan before your first production deploy.

The most common strategies are upcasting and weak schema. With upcasting, a small transformation function reads an old event version and converts it to the latest shape in memory during loading — for example, splitting a legacy name field into firstName and lastName. With a weak schema, you only ever add optional fields and never rename or remove existing ones, keeping deserialization tolerant. In practice, teams version event types explicitly (such as OrderShipped_v2) and keep the upcasting logic centralized so the rest of the domain only ever sees the current shape.

When NOT to Use Event Sourcing and CQRS

This approach adds substantial complexity. Do not reach for it on simple CRUD applications, projects with very small teams, domains without audit requirements, or cases where strong read-after-write consistency is non-negotiable. The overhead of maintaining event schemas, projections, upcasters, and replay machinery 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.

Importantly, the two patterns are separable. You can apply CQRS — splitting read and write models — without event sourcing, and many systems benefit from exactly that lighter step. Most applications are still better served by traditional CRUD with a separate audit log table. For related architectural context, see modular monolith vs microservices and the discussion of the saga pattern for distributed transactions, which often appears alongside these ideas.

Software architecture decision making
Event sourcing adds complexity — use it only when audit trails and temporal queries are genuine requirements

Key Takeaways

For further reading, refer to the Martin Fowler architecture guides and the Microservices patterns catalog for comprehensive reference material.

  • Keep apply() logic-free so historical replay can never reject a past event
  • Enforce optimistic concurrency with a unique (aggregate_id, version) constraint
  • Design for eventual consistency in projections from day one
  • Plan event versioning and upcasting before your first production release
  • Treat snapshots as disposable caches, never as the source of truth

These patterns provide powerful capabilities for complex domains: complete history, temporal queries, and independent scalability. Yet they introduce real operational weight, so start with CQRS alone if you just need read and write separation, and add immutable event history only when it is a hard requirement. Always prototype with your actual domain before committing — the design that feels elegant in a blog post can become burdensome in a simple application.

In conclusion, Event Sourcing CQRS 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.

← Back to all articles