system-design45 min read

CQRS & Event Sourcing: The Complete Guide — A Senior+ Guide | Ayodhyya

CQRS & Event Sourcing: The Complete Guide — A Senior+ Guide

Published:  |  Category: System Design  |  Reading time: ~45 min
CQRS and Event Sourcing complete guide for senior software engineers

Most enterprise applications follow a simple pattern: a CRUD interface backed by a relational database. The client sends a request, the controller reads the current state, mutates it, and persists the mutation. This works until it doesn't. As systems grow, you discover that the read path and the write path have fundamentally different performance characteristics, scaling requirements, and complexity profiles. The write side needs transactional consistency, business rule validation, and conflict resolution. The read side needs fast, denormalized queries across multiple aggregates. Bolting these two concerns onto a single model creates friction at every level of the architecture.

CQRS (Command Query Responsibility Segregation) and Event Sourcing are two architectural patterns that, when applied thoughtfully, solve this tension. CQRS separates the read and write models, allowing each to be optimized independently. Event Sourcing replaces mutable state with an immutable log of events, providing a complete audit trail, temporal queries, and natural integration points for distributed systems. Together, they form the backbone of systems built by organizations like Microsoft, Uber, Netflix, and Amazon.

This guide goes beyond surface-level explanations. We will implement every concept in C#, walk through aggregate design, projection rebuilding, saga orchestration, schema evolution strategies, and performance optimization. We will also cover when these patterns are overkill, because knowing when not to use something is as important as knowing how to use it. By the end, you will have a complete mental model for designing and operating CQRS and Event Sourcing systems in production.

1. Why Traditional CRUD Falls Short

In a typical CRUD application, a single data model serves both reads and writes. An Order entity in Entity Framework maps directly to an Orders table. When a user views an order, the application reads that row. When a user places an order, the application inserts or updates that same row. This symmetry is elegant for simple domains, but it breaks down under real-world pressures.

Consider an e-commerce platform. The write side must enforce invariants: an order cannot contain more than 50 items, a discount code must be valid, inventory must be available. These rules require loading the full aggregate, validating, and persisting. The read side, however, needs a completely different shape: a dashboard showing order summaries with customer names, product titles, shipping status, and total amounts across millions of rows. Serving both from the same model means either over-fetching on reads or under-validating on writes.

The problems compound at scale. Read queries join across five tables; write transactions lock rows and cause contention. The read replicas lag behind the primary. The ORM generates queries optimized for neither path. Teams find themselves writing raw SQL for reads and fighting the ORM for writes, effectively implementing a primitive CQRS without realizing it.

Key Insight: If your read and write models have different performance profiles, different scaling needs, or different complexity levels, a single model is a compromise that satisfies neither. CQRS formalizes what many teams already do informally.

Event Sourcing addresses a different but related problem: state opacity. When you store only the current state of an order, you lose the history of how it arrived there. Was the order ever cancelled and re-placed? What was the state at midnight on January 1st? Who changed the shipping address and when? Traditional CRUD requires you to bolt on audit tables, change data capture, or event logs as an afterthought. Event Sourcing makes the history first-class.

2. CQRS Fundamentals — Separating Reads from Writes

CQRS is an acronym for Command Query Responsibility Segregation. It was formalized by Greg Young in 2010, building on Bertrand Meyer's Command-Query Separation (CQS) principle from Object-Oriented Software Construction (1988). The core idea is simple: separate the components that change state (commands) from the components that read state (queries). Each side gets its own model, its own data store, and its own API.

Commands vs. Queries

A command represents an intent to change state. It is named in the imperative: PlaceOrder, CancelOrder, ShipOrder. Commands are validated, may be rejected, and produce side effects. A query represents an intent to read state. It is named in the declarative: GetOrderSummary, ListCustomerOrders. Queries have no side effects and always return data.

AspectCommands (Write Side)Queries (Read Side)
PurposeEnforce business rules, change stateRetrieve data for display
NamingImperative: PlaceOrder, CancelOrderDeclarative: GetOrder, ListOrders
Return ValueVoid, event, or acknowledgementDTOs, projections, or view models
ValidationBusiness rule validation (invariants)Query parameter validation only
Data ModelNormalized, aggregate-basedDenormalized, flat, read-optimized
DatabaseNormalized relational (PostgreSQL, SQL Server)Any: Elasticsearch, Redis, PostgreSQL, Cosmos DB
ScalingVertical (bigger primary)Horizontal (more read replicas)

Architecture Overview

graph LR Client --> CommandAPI["Command API"] Client --> QueryAPI["Query API"] CommandAPI --> CommandHandler["Command Handler"] CommandHandler --> WriteDB[("Write DB\n(PostgreSQL)")] CommandHandler --> EventBus["Event Bus"] EventBus --> Projection["Projection"] Projection --> ReadDB[("Read DB\n(Elasticsearch)")] QueryAPI --> ReadDB

The diagram above shows the canonical CQRS architecture. The Command API receives commands and delegates to command handlers. Command handlers load the domain model, enforce business rules, persist changes to the write database, and publish events. Projections consume those events and update the read database. The Query API serves read requests directly from the read database. The two sides are completely independent. They can use different technologies, scale independently, and evolve at different rates.

C#
// Command definition
public record PlaceOrderCommand(
    Guid CustomerId,
    List<OrderItemDto> Items,
    string ShippingAddress);

// Command handler
public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, OrderResult>
{
    private readonly IOrderRepository _repository;
    private readonly IEventStore _eventStore;
    private readonly IEventBus _eventBus;

    public PlaceOrderCommandHandler(
        IOrderRepository repository,
        IEventStore eventStore,
        IEventBus eventBus)
    {
        _repository = repository;
        _eventStore = eventStore;
        _eventBus = eventBus;
    }

    public async Task<OrderResult> Handle(
        PlaceOrderCommand command, CancellationToken ct)
    {
        // Load existing events for this aggregate
        var events = await _eventStore.GetEventsAsync(command.CustomerId, ct);
        var aggregate = OrderAggregate.Replay(events);

        // Execute command — validates business rules internally
        var newEvents = aggregate.PlaceOrder(
            command.CustomerId,
            command.Items,
            command.ShippingAddress);

        // Persist new events (optimistic concurrency via expected version)
        await _eventStore.AppendEventsAsync(
            aggregate.Id, newEvents, aggregate.Version, ct);

        // Publish for projections
        foreach (var @event in newEvents)
        {
            await _eventBus.PublishAsync(@event, ct);
        }

        return new OrderResult(aggregate.Id, "Order placed successfully");
    }
}
When CQRS is enough on its own: You do not need Event Sourcing to benefit from CQRS. Many teams separate read and write models using the same relational database with different schemas (or even different tables), and that alone delivers significant performance and clarity improvements. CQRS is the foundation; Event Sourcing is an optional enhancement.

3. Event Sourcing — State as an Immutable Log

In traditional state-based systems, you store the current state of an entity. An order record holds the current status, items, and total. When the order ships, you update the status column to "Shipped." The previous status is lost unless you explicitly wrote it to an audit table. Event Sourcing inverts this model entirely. Instead of storing the current state, you store every state change as an immutable event. The current state is derived by replaying all events for an entity.

Core Concepts

An event is a fact — something that happened in the past. It is immutable, timestamped, and carries all the data needed to describe the change. OrderPlaced, OrderShipped, OrderCancelled are events. You never delete or modify events. If you made a mistake, you append a correcting event. This immutability is what gives Event Sourcing its power: the event log is the single source of truth, and every other view is derived from it.

An aggregate is the consistency boundary. It loads its state by replaying its event stream, enforces business invariants when handling a command, and emits new events. The aggregate is never persisted directly — only its events are. This means "loading" an aggregate means replaying its events, and "saving" an aggregate means appending new events.

An event store is the database that persists events. It is append-only, optimized for sequential writes by aggregate ID, and supports optimistic concurrency control. The event store is NOT a message queue — it is a durable, ordered log. Popular implementations include EventStoreDB, Marten (PostgreSQL), DynamoDB, and custom implementations on top of Kafka.

C#
// Event definitions — immutable records
public record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    List<OrderItem> Items,
    decimal Total,
    DateTime OccurredAt);

public record OrderShippedEvent(
    Guid OrderId,
    string TrackingNumber,
    DateTime ShippedAt);

public record OrderCancelledEvent(
    Guid OrderId,
    string Reason,
    DateTime CancelledAt);

// Base event class with metadata
public abstract record DomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime Timestamp { get; init; } = DateTime.UtcNow;
    public int Version { get; init; }
}

How Aggregate Replay Works

sequenceDiagram participant Client participant CommandHandler participant EventStore participant Aggregate Client->>CommandHandler: PlaceOrder command CommandHandler->>EventStore: Load events for OrderId EventStore-->>CommandHandler: [OrderCreated, ItemAdded, ItemAdded] CommandHandler->>Aggregate: Replay(events) Aggregate-->>CommandHandler: Aggregate with current state CommandHandler->>Aggregate: Handle(command) Aggregate-->>CommandHandler: [OrderPlacedEvent] CommandHandler->>EventStore: Append(OrderPlacedEvent) EventStore-->>CommandHandler: Ack (version: 3)
C#
public class OrderAggregate
{
    public Guid Id { get; private set; }
    public OrderStatus Status { get; private set; }
    public List<OrderItem> Items { get; private set; } = new();
    public decimal Total { get; private set; }
    public int Version { get; private set; }
    private readonly List<DomainEvent> _uncommittedEvents = new();

    // Replay events to build current state
    public static OrderAggregate Replay(IEnumerable<DomainEvent> events)
    {
        var aggregate = new OrderAggregate();
        foreach (var @event in events.OrderBy(e => e.Version))
        {
            aggregate.Apply(@event);
        }
        return aggregate;
    }

    // Handle command — enforces business rules
    public IReadOnlyList<DomainEvent> PlaceOrder(
        Guid customerId, List<OrderItem> items, string address)
    {
        if (items == null || !items.Any())
            throw new DomainException("Order must contain at least one item");

        if (items.Count > 50)
            throw new DomainException("Order cannot exceed 50 items");

        if (Status != OrderStatus.Draft)
            throw new DomainException("Only draft orders can be placed");

        var total = items.Sum(i => i.Price * i.Quantity);
        var @event = new OrderPlacedEvent(Id, customerId, items, total, DateTime.UtcNow);

        Apply(@event);
        _uncommittedEvents.Add(@event);

        return _uncommittedEvents.AsReadOnly();
    }

    // Apply event to mutate state
    private void Apply(DomainEvent @event)
    {
        switch (@event)
        {
            case OrderPlacedEvent e:
                Id = e.OrderId;
                Status = OrderStatus.Placed;
                Items = e.Items;
                Total = e.Total;
                break;
            case OrderShippedEvent:
                Status = OrderStatus.Shipped;
                break;
            case OrderCancelledEvent:
                Status = OrderStatus.Cancelled;
                break;
        }
        Version = @event.Version;
    }
}

Benefits of Event Sourcing

BenefitDescription
Complete Audit TrailEvery state change is recorded. Compliance teams love this. No need for separate audit tables.
Temporal QueriesReconstruct the state of any entity at any point in time. "What did this order look like at midnight?"
DebuggingReproduce any state by replaying events. No more "we can't reproduce the bug" because you have the full history.
Event-Driven IntegrationOther services consume the event stream. No polling, no CDC — events are the integration mechanism.
Schema EvolutionOld event versions can be upcasted to new versions. The system can evolve without losing data.
Temporal AnalyticsAnalyze trends over time: how many orders were placed, cancelled, and re-placed in Q3?

4. CQRS + Event Sourcing — The Natural Partnership

CQRS and Event Sourcing are independent patterns, but they fit together like puzzle pieces. Event Sourcing naturally produces a stream of events as a byproduct of state changes. CQRS needs a mechanism to update read models from write-side changes. Events are that mechanism. When you combine them, the write side event-sources its aggregates, and the read side consumes those events to build denormalized projections. The event store becomes the integration point between the two sides.

graph TB subgraph "Write Side (Command)" CMD[Command] --> AGG[Aggregate] AGG --> |"emits events"| ES[(Event Store)] ES --> |"publishes"| BUS[Event Bus / Kafka] end subgraph "Read Side (Query)" BUS --> |"consumes"| P1[Projection 1: Order Summary] BUS --> |"consumes"| P2[Projection 2: Customer Dashboard] BUS --> |"consumes"| P3[Projection 3: Analytics] P1 --> RDB1[(Read DB 1)] P2 --> RDB2[(Read DB 2)] P3 --> RDB3[(Read DB 3)] end subgraph "Query Side" QRY[Query API] --> RDB1 QRY --> RDB2 QRY --> RDB3 end

The Full Flow

When a client sends a PlaceOrder command, the command handler loads the order aggregate by replaying its events from the event store. The aggregate validates the business rules and emits an OrderPlaced event. This event is appended to the event store with optimistic concurrency control. The event is then published to a message bus (Kafka, RabbitMQ, or an in-process channel). Projections subscribe to the bus, receive the event, and update their respective read models. A query for order summaries hits the denormalized read model — fast, indexed, and pre-computed.

This separation gives you tremendous flexibility. The write side can use PostgreSQL with strict ACID transactions. The read side can use Elasticsearch for full-text search, Redis for blazing-fast key-value lookups, and Cosmos DB for globally distributed reads — all from the same event stream. Each read model is shaped exactly for its use case. Need a new dashboard? Write a new projection, replay the events, and you have a new read model without touching the write side at all.

C#
// Complete CQRS + Event Sourcing pipeline
public class OrderCommandService
{
    private readonly IEventStore _eventStore;
    private readonly IEventBus _eventBus;

    public async Task<Guid> PlaceOrderAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        // 1. Load aggregate from event stream
        var events = await _eventStore.LoadEventsAsync(cmd.OrderId, ct);
        var order = OrderAggregate.Replay(events);

        // 2. Execute command (validates + emits new events)
        var newEvents = order.PlaceOrder(cmd.CustomerId, cmd.Items, cmd.Address);

        // 3. Append to event store with optimistic concurrency
        await _eventStore.AppendAsync(cmd.OrderId, newEvents, order.Version, ct);

        // 4. Publish for async projection updates
        foreach (var e in newEvents)
        {
            await _eventBus.PublishAsync(e, ct);
        }

        return cmd.OrderId;
    }

    public async Task<OrderSummaryDto> GetOrderSummaryAsync(Guid orderId, CancellationToken ct)
    {
        // Query the read model directly — no aggregate replay needed
        return await _readDb.QueryAsync<OrderSummaryDto>(
            "SELECT * FROM order_summaries WHERE id = @Id",
            new { Id = orderId }, ct);
    }
}
Key Takeaway: The event store is the single source of truth. All read models are derived views. If a read model is corrupted or needs a new shape, replay events from the beginning and rebuild it. This is one of the most powerful properties of CQRS + Event Sourcing.

5. Aggregate Design with Events in C#

Aggregate design is the most critical decision in an Event Sourced system. The aggregate defines the consistency boundary: all commands for a single aggregate are processed sequentially, ensuring invariants are never violated. Poor aggregate design leads to performance bottlenecks (aggregates that are too large), lost consistency (aggregates that are too small), or event storms (too many fine-grained events).

Rules of Thumb

  • One aggregate per command: A single command should only modify one aggregate. If a command needs to modify multiple aggregates, use a saga to orchestrate multiple commands.
  • Keep aggregates small: An order aggregate should contain order lines, not the entire customer profile or product catalog. Reference other aggregates by ID, not by value.
  • Events describe what happened, not what to do: Name events in past tense: OrderPlaced, not PlaceOrder. Include enough data so projections can update without querying back to the write model.
  • Idempotent event handlers: Projections may receive duplicate events. Design handlers to be idempotent (apply the same event twice without side effects).

Example: E-Commerce Order Aggregate

C#
public class OrderAggregate : IAggregateRoot
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    public decimal SubTotal { get; private set; }
    public decimal Tax { get; private set; }
    public decimal Total { get; private set; }
    public string ShippingAddress { get; private set; } = string.Empty;
    public int Version { get; private set; }
    private readonly List<DomainEvent> _changes = new();

    // Factory method for new orders
    public static OrderAggregate Create(Guid customerId, string address)
    {
        var agg = new OrderAggregate();
        var @event = new OrderCreatedEvent(Guid.NewGuid(), customerId, address, DateTime.UtcNow);
        agg.Apply(@event);
        agg._changes.Add(@event);
        return agg;
    }

    // Add item — only allowed in Draft status
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Can only add items to a draft order");

        if (quantity <= 0 || quantity > 100)
            throw new DomainException("Quantity must be between 1 and 100");

        if (Lines.Any(l => l.ProductId == product.Id))
            throw new DomainException("Product already in order. Update quantity instead.");

        var @event = new OrderItemAddedEvent(
            Id, product.Id, product.Name, product.Price, quantity, DateTime.UtcNow);
        Apply(@event);
        _changes.Add(@event);
    }

    // Place order — transition from Draft to Placed
    public void Place()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Only draft orders can be placed");

        if (!Lines.Any())
            throw new DomainException("Cannot place an empty order");

        if (SubTotal > 10000)
            throw new DomainException("Orders over $10,000 require manager approval");

        var tax = Math.Round(SubTotal * 0.08m, 2);
        var @event = new OrderPlacedEvent(
            Id, CustomerId, Lines.ToList(), SubTotal, tax, SubTotal + tax,
            ShippingAddress, DateTime.UtcNow);
        Apply(@event);
        _changes.Add(@event);
    }

    // Cancel — only allowed if not yet shipped
    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Cannot cancel a shipped order");

        if (Status == OrderStatus.Cancelled)
            throw new DomainException("Order is already cancelled");

        var @event = new OrderCancelledEvent(Id, CustomerId, reason, DateTime.UtcNow);
        Apply(@event);
        _changes.Add(@event);
    }

    // Apply event to state
    private void Apply(DomainEvent @event)
    {
        switch (@event)
        {
            case OrderCreatedEvent e:
                Id = e.OrderId;
                CustomerId = e.CustomerId;
                ShippingAddress = e.ShippingAddress;
                Status = OrderStatus.Draft;
                break;
            case OrderItemAddedEvent e:
                Lines.Add(new OrderLine(e.ProductId, e.ProductName, e.Price, e.Quantity));
                RecalculateTotals();
                break;
            case OrderPlacedEvent:
                Status = OrderStatus.Placed;
                break;
            case OrderCancelledEvent:
                Status = OrderStatus.Cancelled;
                break;
        }
        Version++;
    }

    private void RecalculateTotals()
    {
        SubTotal = Lines.Sum(l => l.Price * l.Quantity);
    }

    // Replay from events
    public static OrderAggregate Replay(IEnumerable<DomainEvent> events)
    {
        var agg = new OrderAggregate();
        foreach (var e in events.OrderBy(e => e.Version))
            agg.Apply(e);
        return agg;
    }

    // Get uncommitted changes
    public IReadOnlyList<DomainEvent> GetUncommittedChanges() => _changes.AsReadOnly();

    // Clear uncommitted changes after persistence
    public void ClearUncommittedChanges() => _changes.Clear();
}

Event Naming Conventions

ConventionExampleRationale
Past tenseOrderPlacedEvents are facts that happened. Past tense conveys immutability.
Include aggregate IDOrderPlaced(orderId: ...)Projections need to know which aggregate the event belongs to.
Include all necessary dataItems, totals, timestampsProjections should not query back to the write side. Events are self-contained.
Use value objectsMoney, AddressEncapsulate domain concepts in strongly-typed value objects.
Never include infrastructureNo correlation IDs, trace headersDomain events describe business facts, not infrastructure concerns.

6. Building the Event Store

The event store is the heart of an Event Sourced system. It must provide durable, ordered, append-only storage of events, indexed by aggregate ID, with optimistic concurrency control. Unlike a relational database, the event store is not queried by arbitrary fields — it is accessed by loading all events for a specific aggregate. This access pattern is simple and fast: a sequential read of a contiguous block of rows.

Design Options

ImplementationTechnologyProsCons
Purpose-builtEventStoreDBOptimized for event sourcing, projections, subscriptionsAdditional infrastructure to operate
Relational adapterPostgreSQL + MartenUses existing PostgreSQL, good toolingNot as optimized as EventStoreDB for large streams
NoSQL adapterDynamoDB, Cosmos DBServerless, auto-scaling, global distributionLimited querying, eventual consistency
Message brokerKafka, PulsarDurable log, built-in retention and replayNo built-in aggregate-level indexing or concurrency
Custom SQLAny RDBMSFull control, simple schemaYou build projections, subscriptions, snapshots yourself
C#
// PostgreSQL-based Event Store with Marten
public class MartenEventStore : IEventStore
{
    private readonly IDocumentSession _session;

    public MartenEventStore(IDocumentSession session)
    {
        _session = session;
    }

    public async Task<IReadOnlyList<DomainEvent>> LoadEventsAsync(
        Guid aggregateId, CancellationToken ct = default)
    {
        var events = await _session.Events
            .FetchStreamAsync(aggregateId, token: ct);

        return events.Select(e => (DomainEvent)e.Data).ToList();
    }

    public async Task AppendAsync(
        Guid aggregateId,
        IReadOnlyList<DomainEvent> events,
        int expectedVersion,
        CancellationToken ct = default)
    {
        foreach (var @event in events)
        {
            _session.Events.Append(aggregateId, @event);
        }

        // Marten handles optimistic concurrency via expected version
        await _session.SaveChangesAsync(ct);
    }

    public async Task<IReadOnlyList<DomainEvent>> LoadEventsAsync(
        string eventType, DateTime from, DateTime to, CancellationToken ct = default)
    {
        // Query all events of a type in a time range (for rebuilds)
        var allEvents = await _session.Events
            .QueryRawEventDataOnly<DomainEvent>()
            .Where(e => e.Timestamp >= from && e.Timestamp <= to)
            .ToListAsync(ct);

        return allEvents;
    }
}

Event Store Schema

SQL
CREATE TABLE events (
    global_position   BIGSERIAL PRIMARY KEY,
    stream_id         UUID NOT NULL,
    version           INT NOT NULL,
    event_type        VARCHAR(255) NOT NULL,
    data              JSONB NOT NULL,
    metadata          JSONB,
    created_at        TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(stream_id, version)  -- Optimistic concurrency constraint
);

CREATE INDEX idx_events_stream ON events(stream_id, version);
CREATE INDEX idx_events_type ON events(event_type);
CREATE INDEX idx_events_created ON events(created_at);
Optimistic Concurrency: The UNIQUE(stream_id, version) constraint ensures that two concurrent writes to the same aggregate cannot both succeed. If aggregate A is at version 5, a second writer trying to append with expected version 5 will get a constraint violation. This is the standard concurrency control mechanism in Event Sourcing — no distributed locks needed.

7. Projections and Read Models

Projections are the mechanism that transforms events into query-optimized read models. Each projection subscribes to specific event types, processes them, and updates a denormalized store. Projections are the read side of CQRS. They are typically asynchronous, meaning the read model lags behind the write model by a small margin (usually milliseconds). This eventual consistency is the trade-off for independent scaling and optimized queries.

Projection Types

TypeDescriptionExample
Real-timeProcesses events as they arrive, low latencyOrder status dashboard
BatchProcesses events in batches, higher throughputNightly analytics report
Catch-upStarts from a known position, rebuilds from eventsNew projection added in production
TransientLives in memory, rebuilt on demandTypeahead search index
C#
// Order Summary Projection — updates a denormalized read model
public class OrderSummaryProjection :
    IEventHandler<OrderCreatedEvent>,
    IEventHandler<OrderItemAddedEvent>,
    IEventHandler<OrderPlacedEvent>,
    IEventHandler<OrderCancelledEvent>,
    IEventHandler<OrderShippedEvent>
{
    private readonly ReadDbContext _readDb;

    public OrderSummaryProjection(ReadDbContext readDb)
    {
        _readDb = readDb;
    }

    public async Task HandleAsync(OrderCreatedEvent evt, CancellationToken ct)
    {
        await _readDb.ExecuteAsync(@"
            INSERT INTO order_summaries (id, customer_id, status, address, created_at)
            VALUES (@Id, @CustomerId, 'Draft', @Address, @CreatedAt)",
            new { evt.OrderId, evt.CustomerId, evt.ShippingAddress, evt.CreatedAt }, ct);
    }

    public async Task HandleAsync(OrderItemAddedEvent evt, CancellationToken ct)
    {
        await _readDb.ExecuteAsync(@"
            UPDATE order_summaries
            SET item_count = item_count + 1,
                sub_total = sub_total + (@Price * @Quantity)
            WHERE id = @OrderId",
            new { evt.OrderId, evt.Price, evt.Quantity }, ct);
    }

    public async Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
    {
        await _readDb.ExecuteAsync(@"
            UPDATE order_summaries
            SET status = 'Placed',
                sub_total = @SubTotal,
                tax = @Tax,
                total = @Total,
                item_count = @ItemCount,
                placed_at = @PlacedAt
            WHERE id = @OrderId",
            new
            {
                evt.OrderId, evt.SubTotal, evt.Tax, evt.Total,
                ItemCount = evt.Items.Count,
                evt.PlacedAt
            }, ct);
    }

    public async Task HandleAsync(OrderCancelledEvent evt, CancellationToken ct)
    {
        await _readDb.ExecuteAsync(@"
            UPDATE order_summaries
            SET status = 'Cancelled', cancelled_at = @CancelledAt, cancel_reason = @Reason
            WHERE id = @OrderId",
            new { evt.OrderId, evt.CancelledAt, evt.Reason }, ct);
    }

    public async Task HandleAsync(OrderShippedEvent evt, CancellationToken ct)
    {
        await _readDb.ExecuteAsync(@"
            UPDATE order_summaries
            SET status = 'Shipped', tracking_number = @TrackingNumber, shipped_at = @ShippedAt
            WHERE id = @OrderId",
            new { evt.OrderId, evt.TrackingNumber, evt.ShippedAt }, ct);
    }
}

Projection Rebuild

One of the most powerful features of Event Sourcing is the ability to rebuild projections from scratch. If you discover a bug in a projection, fix the code and replay all events. If you need a new read model, write a new projection and replay events. The projection is always correct because it is derived from the source of truth — the event stream.

C#
public class ProjectionRebuilder
{
    private readonly IEventStore _eventStore;
    private readonly IServiceProvider _serviceProvider;

    public async Task RebuildProjectionAsync<TProjection>(CancellationToken ct)
        where TProjection : class
    {
        // 1. Drop and recreate the read model table
        await ResetReadModelAsync<TProjection>(ct);

        // 2. Load ALL events from the event store
        var events = await _eventStore.LoadAllEventsAsync(ct);
        var handler = _serviceProvider.GetRequiredService<TProjection>();

        // 3. Replay every event through the projection
        int processed = 0;
        foreach (var @event in events)
        {
            if (handler is IEventHandler<dynamic> dynamicHandler)
            {
                await dynamicHandler.HandleAsync(@event, ct);
            }
            processed++;

            if (processed % 10000 == 0)
                Console.WriteLine($"Rebuilt {processed} events...");
        }

        Console.WriteLine($"Projection rebuild complete. {processed} events processed.");
    }
}
Powerful Pattern: Projection rebuilds mean you can never lose data. Even if your read database is destroyed, you rebuild it from the event store. This is why the event store is called the "source of truth" — read models are disposable, rebuildable views.

8. Snapshots — Avoiding Replay Overhead

As an aggregate accumulates thousands of events, replaying them all on every load becomes expensive. If an order aggregate has 5,000 events (items added, modified, shipped, etc.), loading it requires deserializing and applying 5,000 events every time. Snapshots solve this by periodically capturing the aggregate state and storing it alongside the events. To load an aggregate, you load the latest snapshot and replay only the events that occurred after it.

Snapshot Strategy

A common threshold is to snapshot every 100 events. When the event count since the last snapshot exceeds this threshold, save a snapshot. On load, check for the latest snapshot, load events after the snapshot version, and apply them to the snapshot state. This turns an O(n) operation into O(100) — a massive improvement for long-lived aggregates.

C#
public class SnapshotStore
{
    private readonly IEventStore _eventStore;
    private readonly IDbConnection _db;
    private const int SnapshotInterval = 100;

    public async Task<OrderAggregate> LoadAggregateAsync(Guid aggregateId, CancellationToken ct)
    {
        // 1. Try loading the latest snapshot
        var snapshot = await _db.QuerySingleOrDefaultAsync<Snapshot>(
            "SELECT * FROM snapshots WHERE aggregate_id = @Id ORDER BY version DESC LIMIT 1",
            new { Id = aggregateId });

        IEnumerable<DomainEvent> events;
        if (snapshot != null)
        {
            // 2a. Load events AFTER the snapshot
            events = await _eventStore.LoadEventsAsync(
                aggregateId, snapshot.Version + 1, ct);

            // Deserialize snapshot state
            var state = JsonSerializer.Deserialize<OrderAggregateState>(snapshot.StateData);
            var aggregate = OrderAggregate.FromSnapshot(state);

            // Apply post-snapshot events
            foreach (var e in events.OrderBy(e => e.Version))
                aggregate.Apply(e);

            return aggregate;
        }
        else
        {
            // 2b. No snapshot — replay all events
            events = await _eventStore.LoadEventsAsync(aggregateId, ct);
            return OrderAggregate.Replay(events);
        }
    }

    public async Task SaveSnapshotIfNeededAsync(
        OrderAggregate aggregate, CancellationToken ct)
    {
        var eventCount = await _eventStore.GetEventCountAsync(aggregate.Id, ct);
        if (eventCount % SnapshotInterval == 0)
        {
            var state = aggregate.ToSnapshot();
            await _db.ExecuteAsync(@"
                INSERT INTO snapshots (aggregate_id, version, state_data, created_at)
                VALUES (@Id, @Version, @StateData, @CreatedAt)",
                new
                {
                    aggregate.Id,
                    aggregate.Version,
                    StateData = JsonSerializer.Serialize(state),
                    CreatedAt = DateTime.UtcNow
                }, ct);
        }
    }
}

Snapshot Schema

SQL
CREATE TABLE snapshots (
    id              BIGSERIAL PRIMARY KEY,
    aggregate_id    UUID NOT NULL,
    version         INT NOT NULL,
    state_data      JSONB NOT NULL,
    created_at      TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_snapshots_aggregate ON snapshots(aggregate_id, version DESC);
Snapshots are a performance optimization, not a requirement. If your aggregates never accumulate more than a few hundred events, snapshots add complexity without benefit. Start without snapshots and introduce them only when profiling shows replay is a bottleneck.

9. Sagas and Process Managers

In a CQRS + Event Sourcing system, each aggregate enforces its own invariants. But many business processes span multiple aggregates and even multiple services. An order lifecycle involves payment processing, inventory reservation, shipping coordination, and notification. These steps must happen in sequence, with compensation logic if any step fails. Sagas (also called Process Managers) coordinate these multi-aggregate workflows.

Saga vs. Process Manager

The terms are often used interchangeably, but there is a subtle distinction. A saga is a simple state machine: it listens for events, issues commands, and handles responses. A process manager is a more complex orchestrator that maintains its own state, can make decisions based on intermediate results, and may coordinate across multiple services. In practice, most implementations are process managers regardless of what they are called.

C#
public class OrderSaga : ISaga
{
    public Guid OrderId { get; private set; }
    public SagaState State { get; private set; }
    public string PaymentId { get; private set; }
    public string ShipmentId { get; private set; }
    private readonly List<DomainEvent> _changes = new();

    public enum SagaState
    {
        WaitingForPayment,
        PaymentReceived,
        PaymentFailed,
        WaitingForShipment,
        Shipped,
        OrderCompleted,
        CompensationRequired,
        Compensated
    }

    // React to OrderPlaced → initiate payment
    public IReadOnlyList<Command> Handle(OrderPlacedEvent evt)
    {
        OrderId = evt.OrderId;
        State = SagaState.WaitingForPayment;

        _changes.Add(new SagaStartedEvent(OrderId, DateTime.UtcNow));

        return new List<Command>
        {
            new ProcessPaymentCommand(
                OrderId,
                evt.CustomerId,
                evt.Total)
        };
    }

    // React to PaymentProcessed → initiate shipping
    public IReadOnlyList<Command> Handle(PaymentProcessedEvent evt)
    {
        if (State != SagaState.WaitingForPayment)
            throw new SagaException($"Unexpected PaymentProcessed in state {State}");

        PaymentId = evt.PaymentId;
        State = SagaState.PaymentReceived;

        return new List<Command>
        {
            new ShipOrderCommand(
                OrderId,
                evt.CustomerId)
        };
    }

    // React to PaymentFailed → cancel the order
    public IReadOnlyList<Command> Handle(PaymentFailedEvent evt)
    {
        if (State != SagaState.WaitingForPayment)
            throw new SagaException($"Unexpected PaymentFailed in state {State}");

        State = SagaState.PaymentFailed;

        return new List<Command>
        {
            new CancelOrderCommand(
                OrderId,
                "Payment failed: " + evt.Reason)
        };
    }

    // React to OrderShipped → saga complete
    public IReadOnlyList<Command> Handle(OrderShippedEvent evt)
    {
        if (State != SagaState.PaymentReceived)
            throw new SagaException($"Unexpected OrderShipped in state {State}");

        ShipmentId = evt.TrackingNumber;
        State = SagaState.OrderCompleted;

        _changes.Add(new SagaCompletedEvent(OrderId, DateTime.UtcNow));
        return new List<Command>();
    }

    // Compensation: if shipment fails after payment, refund
    public IReadOnlyList<Command> Handle(ShipmentFailedEvent evt)
    {
        State = SagaState.CompensationRequired;

        return new List<Command>
        {
            new RefundPaymentCommand(PaymentId, OrderId),
            new CancelOrderCommand(OrderId, "Shipment failed, payment refunded")
        };
    }
}

Saga Lifecycle Diagram

stateDiagram-v2 [*] --> WaitingForPayment: OrderPlaced WaitingForPayment --> PaymentReceived: PaymentProcessed WaitingForPayment --> PaymentFailed: PaymentFailed PaymentReceived --> WaitingForShipment: ShipOrderCommand WaitingForShipment --> Shipped: OrderShipped WaitingForShipment --> CompensationRequired: ShipmentFailed CompensationRequired --> Compensated: RefundProcessed PaymentFailed --> Compensated: OrderCancelled Shipped --> OrderCompleted: SagaComplete OrderCompleted --> [*] Compensated --> [*]
Saga Pitfall: Sagas must be idempotent and handle duplicate events. In distributed systems, at-least-once delivery is the norm. If a saga receives the same PaymentProcessedEvent twice, it should not issue duplicate ShipOrderCommands. Use an idempotency check (e.g., "has this event already been processed?") before issuing commands.

10. Event Schema Evolution and Versioning

Event Sourcing's greatest strength — immutability — is also its greatest challenge. Once an event is stored, it can never be changed. But software evolves. Requirements change, new fields are needed, and old fields become obsolete. You need a strategy for evolving event schemas without breaking existing events or projections.

Strategies

StrategyDescriptionWhen to Use
UpcastingTransform old event versions to the latest version at read timeBreaking changes that require restructuring
Weak SchemaAdd new optional fields, never remove old onesAdditive changes (most common)
Event VersioningStore as OrderPlaced-v2 with different type namesWhen the new version has a fundamentally different structure
Multi-version ProjectionsEach projection version handles its own event versionWhen different consumers need different versions
C#
// Upcaster: transforms v1 events to v2
public class OrderPlacedUpcaster : IEventUpcaster
{
    public string SourceEventType => "OrderPlaced";
    public int SourceVersion => 1;

    public DomainEvent Upcast(DomainEvent oldEvent)
    {
        // v1: no shipping address, no tax
        // v2: adds ShippingAddress and Tax fields
        var v1 = (OrderPlacedEventV1)oldEvent;

        return new OrderPlacedEvent(
            OrderId: v1.OrderId,
            CustomerId: v1.CustomerId,
            Items: v1.Items,
            SubTotal: v1.Total,
            Tax: Math.Round(v1.Total * 0.08m, 2),
            Total: v1.Total + Math.Round(v1.Total * 0.08m, 2),
            ShippingAddress: "Unknown", // default for old events
            OccurredAt: v1.OccurredAt);
    }
}

// Registration in event store
public class EventUpcasterRegistry
{
    private readonly List<IEventUpcaster> _upcasters = new();

    public void Register(IEventUpcaster upcaster) => _upcasters.Add(upcaster);

    public DomainEvent Upcast(DomainEvent @event, int storedVersion)
    {
        var current = @event;
        var upcaster = _upcasters
            .Where(u => u.SourceEventType == @event.GetType().Name)
            .OrderBy(u => u.SourceVersion)
            .FirstOrDefault(u => u.SourceVersion >= storedVersion);

        while (upcaster != null)
        {
            current = upcaster.Upcast(current);
            upcaster = _upcasters
                .Where(u => u.SourceEventType == current.GetType().Name)
                .FirstOrDefault(u => u.SourceVersion > upcaster.SourceVersion);
        }

        return current;
    }
}

Rules for Schema Evolution

  • Never remove fields from existing events. Old projections depend on them.
  • Never rename fields in existing events. Add a new field and deprecate the old one.
  • Make new fields optional with sensible defaults so old events remain valid.
  • Use semantic versioning for event types: OrderPlaced (v1), OrderPlacedV2, etc.
  • Test upcasters thoroughly. A bug in an upcaster corrupts every old event of that type.
  • Document schema changes in a changelog. Future developers need to understand why a field was added.
C#
// V1 and V2 event definitions
public record OrderPlacedEventV1(
    Guid OrderId,
    Guid CustomerId,
    List<OrderItem> Items,
    decimal Total,
    DateTime OccurredAt) : DomainEvent;

public record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    List<OrderItem> Items,
    decimal SubTotal,
    decimal Tax,
    decimal Total,
    string ShippingAddress,
    DateTime OccurredAt) : DomainEvent;

11. Testing CQRS and Event Sourcing Systems

Testing Event Sourced systems is actually simpler than testing traditional CRUD systems. Because state is derived from events, you can test business logic by asserting which events a command produces, without needing to set up complex database states. You load an aggregate from events, execute a command, and check the resulting events. This is called "output assertions" — you test outputs (events) rather than state mutations.

Testing Patterns

C#
// Test: placing an order should produce an OrderPlaced event
public class OrderAggregateTests
{
    [Fact]
    public void PlaceOrder_WithValidItems_ShouldProduceOrderPlacedEvent()
    {
        // Arrange — build aggregate from existing events
        var customerId = Guid.NewGuid();
        var events = new List<DomainEvent>
        {
            new OrderCreatedEvent(Guid.NewGuid(), customerId, "123 Main St", DateTime.UtcNow)
        };
        var aggregate = OrderAggregate.Replay(events);

        var items = new List<OrderItem>
        {
            new OrderItem(Guid.NewGuid(), "Widget", 9.99m, 2),
            new OrderItem(Guid.NewGuid(), "Gadget", 19.99m, 1)
        };

        // Act
        var resultEvents = aggregate.PlaceOrder(customerId, items, "123 Main St");

        // Assert — check the produced events
        resultEvents.Should().HaveCount(1);
        resultEvents.First().Should().BeOfType<OrderPlacedEvent>();
        var placedEvent = (OrderPlacedEvent)resultEvents.First();
        placedEvent.Total.Should().Be(39.97m);
        placedEvent.Items.Should().HaveCount(2);
    }

    [Fact]
    public void PlaceOrder_EmptyItems_ShouldThrowDomainException()
    {
        // Arrange
        var events = new List<DomainEvent>
        {
            new OrderCreatedEvent(Guid.NewGuid(), Guid.NewGuid(), "123 Main St", DateTime.UtcNow)
        };
        var aggregate = OrderAggregate.Replay(events);

        // Act & Assert
        var act = () => aggregate.PlaceOrder(
            Guid.NewGuid(), new List<OrderItem>(), "123 Main St");
        act.Should().Throw<DomainException>()
            .WithMessage("Cannot place an empty order");
    }

    [Fact]
    public void PlaceOrder_ShouldApplyVersioning()
    {
        var events = new List<DomainEvent>
        {
            new OrderCreatedEvent(Guid.NewGuid(), Guid.NewGuid(), "Addr", DateTime.UtcNow)
            { Version = 1 }
        };
        var aggregate = OrderAggregate.Replay(events);
        aggregate.Version.Should().Be(1);

        aggregate.PlaceOrder(Guid.NewGuid(),
            new List<OrderItem> { new OrderItem(Guid.NewGuid(), "X", 1m, 1) }, "Addr");

        aggregate.Version.Should().Be(2);
    }
}

Projection Tests

C#
public class OrderSummaryProjectionTests
{
    [Fact]
    public async Task OrderPlaced_ShouldUpdateSummaryTable()
    {
        // Arrange
        var readDb = new InMemoryReadDb();
        var projection = new OrderSummaryProjection(readDb);
        var orderId = Guid.NewGuid();
        var customerId = Guid.NewGuid();

        // Act — simulate event sequence
        await projection.HandleAsync(
            new OrderCreatedEvent(orderId, customerId, "123 Main St", DateTime.UtcNow),
            CancellationToken.None);

        await projection.HandleAsync(
            new OrderPlacedEvent(orderId, customerId,
                new List<OrderItem>
                {
                    new OrderItem(Guid.NewGuid(), "Widget", 10m, 3)
                },
                subTotal: 30m, tax: 2.40m, total: 32.40m,
                "123 Main St", DateTime.UtcNow),
            CancellationToken.None);

        // Assert
        var summary = await readDb.QuerySingleAsync<OrderSummaryDto>(
            "SELECT * FROM order_summaries WHERE id = @Id",
            new { Id = orderId });

        summary.Should().NotBeNull();
        summary.Status.Should().Be("Placed");
        summary.Total.Should().Be(32.40m);
        summary.ItemCount.Should().Be(3);
    }

    [Fact]
    public async Task Projection_ShouldBeIdempotent()
    {
        // Same event applied twice should not change result
        var readDb = new InMemoryReadDb();
        var projection = new OrderSummaryProjection(readDb);
        var evt = new OrderShippedEvent(
            Guid.NewGuid(), "TRACK123", DateTime.UtcNow);

        await projection.HandleAsync(evt, CancellationToken.None);
        await projection.HandleAsync(evt, CancellationToken.None); // duplicate

        var summary = await readDb.QuerySingleAsync<OrderSummaryDto>(
            "SELECT * FROM order_summaries WHERE id = @Id",
            new { Id = evt.OrderId });

        summary.Status.Should().Be("Shipped"); // not double-applied
    }
}
Testing Advantage: Event Sourcing makes testing easier because you can reconstruct any aggregate state. No more complex test fixtures or database seeding. Just create the event sequence you need and call Replay(). This is a significant productivity boost compared to traditional CRUD testing.

12. CQRS in Microservices — Integration Patterns

CQRS and Event Sourcing shine in microservice architectures. Each service owns its aggregates and event store. Services communicate through events, not direct API calls. When the Order Service places an order, it publishes an OrderPlaced event. The Payment Service subscribes to that event and initiates payment. The Inventory Service reserves stock. The Notification Service sends a confirmation email. No service knows about or calls another service directly.

graph TB subgraph "Order Service" OC[Order Command] --> OE[(Event Store)] OE --> OEBus[Event Bus] end subgraph "Payment Service" OEBus --> |"OrderPlaced"| PP[Payment Processor] PP --> PE[(Event Store)] PE --> PEBus[Event Bus] end subgraph "Inventory Service" OEBus --> |"OrderPlaced"| IR[Inventory Reserver] IR --> IE[(Event Store)] IE --> IEBus[Event Bus] end subgraph "Notification Service" OEBus --> |"OrderPlaced"| NE[Email Sender] PEBus --> |"PaymentProcessed"| NE IEBus --> |"InventoryReserved"| NE end

Integration Patterns

PatternDescriptionProsCons
Shared Event BusAll services publish/subscribe to a common bus (Kafka)Simple, well-understoodShared infrastructure coupling
Event IntermediaryAn API Gateway or event relay transforms and routes eventsDecouples services from event formatAdditional component to maintain
ChoreographyServices react independently to events, no central coordinatorFully decoupledHard to reason about overall flow
OrchestrationA saga orchestrator issues commands to servicesClear flow, easy to understandSingle point of coordination
C#
// Cross-service event consumption with idempotency
public class PaymentEventHandler : IEventHandler<OrderPlacedEvent>
{
    private readonly IPaymentRepository _payments;
    private readonly IEventBus _eventBus;

    public async Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
    {
        // Idempotency check: has this order already been processed?
        var existing = await _payments.FindByOrderIdAsync(evt.OrderId, ct);
        if (existing != null)
        {
            // Already processed — skip
            return;
        }

        // Process payment
        var payment = Payment.Create(evt.OrderId, evt.CustomerId, evt.Total);
        await _payments.SaveAsync(payment, ct);

        // Publish payment result
        var paymentEvent = new PaymentProcessedEvent(
            evt.OrderId, payment.Id, evt.Total, DateTime.UtcNow);
        await _eventBus.PublishAsync(paymentEvent, ct);
    }
}
Anti-Corruption Layer: When integrating with external systems or legacy services, use an Anti-Corruption Layer (ACL) to translate between your domain events and the external system's data model. This prevents external concerns from leaking into your domain.

13. Performance Considerations and Benchmarks

CQRS + Event Sourcing introduces overhead compared to direct CRUD. You write events instead of updating rows. You project events instead of reading current state. You manage eventual consistency. However, when implemented correctly, these systems outperform CRUD at scale because reads are served from denormalized, indexed, purpose-built stores, and writes are append-only with no read-side contention.

Performance Characteristics

OperationCRUD LatencyCQRS + ES LatencyNotes
Write (command)5-50ms5-30msEvent append is sequential I/O, faster than random I/O updates
Read (simple query)10-100ms1-10msDenormalized read models with indexes are fast
Read (complex query)100-5000ms5-50msJoins eliminated by denormalization
Aggregate load1 queryN events (until snapshot)Snapshots mitigate this for long-lived aggregates
Projection lag0ms (sync)1-100ms (async)Acceptable for most use cases

Optimization Techniques

  • Snapshots: Store aggregate state every N events to avoid replaying thousands of events on every load.
  • Parallel projections: Different projections can be updated concurrently on different threads or machines.
  • Batch processing: Consume events in batches for high-throughput projections instead of one-by-one.
  • Event compression: Periodically merge old events into a single "compacted" event (e.g., merge 100 OrderItemAdded events into a single OrderItemsBatchAdded event).
  • Read model caching: Cache frequently accessed read models in Redis with appropriate TTLs.
  • Partitioning: Partition event streams by aggregate type or tenant for horizontal scaling.
C#
// Batch projection consumer for high throughput
public class BatchProjectionConsumer : BackgroundService
{
    private readonly IEventBus _eventBus;
    private readonly IServiceScopeFactory _scopeFactory;
    private const int BatchSize = 500;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var handler = scope.ServiceProvider.GetRequiredService<IProjectionBatchHandler>();

            // Fetch a batch of unprocessed events
            var batch = await _eventBus.GetBatchAsync(BatchSize, stoppingToken);
            if (batch.Count == 0)
            {
                await Task.Delay(100, stoppingToken); // backoff
                continue;
            }

            // Process entire batch in a single transaction
            using var tx = await handler.BeginTransactionAsync(stoppingToken);
            foreach (var @event in batch)
            {
                await handler.HandleAsync(@event, stoppingToken);
            }
            await handler.CommitTransactionAsync(tx, stoppingToken);

            // Mark batch as processed
            await _eventBus.AcknowledgeBatchAsync(batch, stoppingToken);
        }
    }
}

When CQRS Wins

CQRS + Event Sourcing wins when reads vastly outnumber writes (100:1 or more), when reads require complex joins across many tables, when multiple different read views are needed from the same data, and when audit trails and temporal queries are required. It loses when the domain is simple CRUD with no complex business rules, when consistency must be immediate and synchronous, or when the team is small and the added complexity is not justified by the benefits.

14. When to Use (and When NOT to Use) These Patterns

Use CQRS When:

  • Read and write workloads have different scaling requirements
  • Multiple read views (dashboards, reports, search) are needed from the same data
  • The domain has complex business rules that benefit from a rich write model
  • Read performance is degraded by write contention (and vice versa)
  • You need to optimize reads and writes independently

Use Event Sourcing When:

  • Regulatory requirements mandate a complete audit trail
  • Temporal queries ("state at time T") are business-critical
  • The event stream is consumed by multiple downstream systems
  • You need to rebuild read models from scratch (new projections, bug fixes)
  • Complex event-driven workflows span multiple services

Do NOT Use These Patterns When:

ScenarioWhy NotWhat to Do Instead
Simple CRUD appOverkill. No complex business rules or audit needs.Standard layered architecture with DDD-lite
Immediate consistency requiredCQRS introduces eventual consistency by design.Use synchronous projections or read from write model
Small team, tight deadlineLearning curve is steep. Productivity drops initially.Monolith with good separation of concerns
Reporting-heavy appEvent sourcing is not optimized for analytical queries.CQRS with separate OLAP database, no event sourcing
Frequent schema changes to core eventsEvent schema evolution is complex and error-prone.Mutable state with database migrations
Start Simple: The majority of applications do not need CQRS or Event Sourcing. Start with a well-structured monolith using Domain-Driven Design principles. Introduce CQRS when you feel the pain of mixed read/write models. Add Event Sourcing only when you have a clear need for audit trails, temporal queries, or event-driven integration. Patterns should solve problems, not create them.

15. Tools, Frameworks, and Libraries

Several mature frameworks and tools support CQRS and Event Sourcing. Choosing the right one depends on your language, infrastructure, and complexity requirements.

ToolLanguageFocusDescription
EventuousC# / .NETEvent SourcingLightweight, modern C# event sourcing framework with built-in support for aggregates, projections, and subscriptions. Works with EventStoreDB.
MartenC# / .NETEvent Sourcing + Document DBUses PostgreSQL as an event store and document database. Great for teams already on PostgreSQL. Includes projections, subscriptions, and aggregate storage.
Axon FrameworkJava / KotlinCQRS + ESFull-featured Java framework for CQRS and Event Sourcing. Includes command bus, event bus, saga support, andaxon server for monitoring.
EventStoreDBMulti-languageEvent StorePurpose-built event store with projections, subscriptions, and persistent subscriptions. The gold standard for event sourcing infrastructure.
MasstransitC# / .NETMessage BusAdvanced message bus for .NET with saga support, consumers, and multiple transport support (RabbitMQ, Azure Service Bus, Amazon SQS).
BrighterC# / .NETCQRSCommand processor and mediator with pipeline-based middleware. Supports command handlers, event handlers, and message stores.
NEventStoreC# / .NETEvent StorePersistence abstraction for event sourcing. Supports SQL Server, MySQL, PostgreSQL, MongoDB, and Azure Table Storage.
EventuateJavaCQRS + ESMicroservices framework with event sourcing, sagas, and CDC-based event publishing. Includes Eventuate Tram for transactional messaging.
C#
// Eventuous setup in ASP.NET Core
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEventuous(builder.Configuration);
builder.Services.AddEventuousMongo(builder.Configuration);
builder.Services.AddEventuousProjections();

var app = builder.Build();

app.MapEventuous(); // exposes event store HTTP endpoints

app.Run();

// Aggregate registration
public static class AggregateRegistration
{
    public static void Register(IServiceCollection services)
    {
        services.AddAggregate<OrderAggregate>();
        services.AddAggregate<PaymentAggregate>();
        services.AddProjection<OrderSummaryProjection>();
        services.AddSubscription<OrderEventSubscription>();
    }
}
Recommendation for .NET teams: Start with Marten if you already use PostgreSQL. It provides event sourcing, document storage, and projections in a single package with minimal infrastructure overhead. Move to EventStoreDB when you need dedicated event store features like persistent subscriptions and catch-up subscriptions.

16. Real-World Case Studies

Case Study 1: E-Commerce Order Management

A mid-size e-commerce company processing 50,000 orders per day migrated from a monolithic CRUD application to CQRS + Event Sourcing. The write side used PostgreSQL with Marten for event storage. The read side used three separate stores: Elasticsearch for product search, Redis for real-time order status lookups, and PostgreSQL for analytics dashboards.

Results: Read latency dropped from 200ms (complex joins across 8 tables) to 15ms (denormalized Elasticsearch queries). Write throughput increased 3x because event appending eliminated row-level lock contention. The audit trail replaced a custom-built audit system, reducing development time by 40%. Projection rebuilds for new dashboard features took minutes instead of weeks of development.

Case Study 2: Financial Trading Platform

A financial services company required complete audit trails for regulatory compliance (MiFID II). Every trade, position change, and risk calculation had to be traceable to its source. Event Sourcing provided this naturally: every state change was an event, and the event store was the audit trail. Temporal queries allowed compliance officers to reconstruct the state of any account at any point in time, answering questions like "what was the risk exposure of account X at 3:45 PM on March 15th?"

The write side processed 100,000 events per second during peak trading. Snapshots were taken every 50 events to keep aggregate loads under 5ms. Read models included real-time P&L dashboards (updated via Kafka streams), end-of-day reports (batch projections), and regulatory submissions (complex aggregations over event data).

Case Study 3: IoT Device Management

A smart home company used Event Sourcing to manage device state changes. Every command sent to a device (turn on, adjust temperature, update firmware) was stored as an event. The current device state was derived by replaying events. This provided a complete history of device interactions, enabled debugging of device issues by replaying the event sequence, and allowed temporal queries for customer support ("what was the thermostat setting when the customer called?").

Key Lessons from Production

LessonDetails
Start with snapshots earlyEven if aggregates are small initially, they grow. Add snapshotting from day one with a generous interval (e.g., every 200 events).
Monitor projection lagSet up dashboards for projection lag (time between event published and projection updated). Alert when lag exceeds threshold.
Test projection rebuilds regularlyA projection that cannot rebuild from events is a liability. Test rebuild in CI/CD pipeline.
Version events from the startYou WILL need to change event schemas. Plan for it from day one with upcaster infrastructure.
Keep events smallEvents are loaded into memory. Include only data needed by projections. Reference large data by ID.

17. Event Store Implementation Patterns

Choosing the right event store implementation is a foundational decision that impacts performance, scalability, and operational complexity. The event store must guarantee durable, append-only writes, support optimistic concurrency per aggregate, and provide efficient streaming for projection rebuilds. While purpose-built solutions like EventStoreDB exist, many teams build event stores on top of existing infrastructure they already operate.

Common Implementation Approaches

The simplest approach is a relational database with a single events table keyed by stream (aggregate) ID and version number. PostgreSQL with JSONB columns offers a compelling middle ground: you get ACID transactions, indexing on event type and timestamp, and the flexibility to query events for debugging. For high-throughput scenarios, Apache Kafka or Apache Pulsar can serve as the event store, though they lack built-in aggregate-level indexing and concurrency primitives. Document databases like MongoDB or DynamoDB work well for serverless deployments where auto-scaling is critical.

C#
// Generic event store with optimistic concurrency using PostgreSQL
public class PostgresEventStore : IEventStore
{
    private readonly NpgsqlConnection _connection;

    public async Task AppendAsync(
        Guid streamId,
        IReadOnlyList<DomainEvent> events,
        int expectedVersion,
        CancellationToken ct = default)
    {
        await using var tx = await _connection.BeginTransactionAsync(ct);

        foreach (var @event in events)
        {
            var nextVersion = expectedVersion + 1;
            var sql = @"
                INSERT INTO events (stream_id, version, event_type, data, metadata)
                VALUES (@StreamId, @Version, @EventType, @Data::jsonb, @Metadata::jsonb)";

            var parameters = new
            {
                StreamId = streamId,
                Version = nextVersion,
                EventType = @event.GetType().Name,
                Data = JsonSerializer.Serialize(@event, @event.GetType()),
                Metadata = JsonSerializer.Serialize(new EventMetadata
                {
                    EventId = Guid.NewGuid(),
                    Timestamp = DateTime.UtcNow,
                    CorrelationId = CorrelationContext.CurrentId
                })
            };

            var rowsAffected = await _connection.ExecuteAsync(sql, parameters, tx);
            if (rowsAffected == 0)
                throw new ConcurrencyException(
                    $"Expected version {expectedVersion} for stream {streamId} " +
                    $"but stream has been modified. Retry the command.");

            expectedVersion = nextVersion;
        }

        await tx.CommitAsync(ct);
    }

    public async Task<IReadOnlyList<DomainEvent>> LoadStreamAsync(
        Guid streamId,
        int? fromVersion = null,
        CancellationToken ct = default)
    {
        var sql = @"
            SELECT event_type, data
            FROM events
            WHERE stream_id = @StreamId
            AND version >= @FromVersion
            ORDER BY version ASC";

        var rows = await _connection.QueryAsync<(string EventType, string Data)>(
            sql, new { StreamId = streamId, FromVersion = fromVersion ?? 0 }, ct);

        return rows.Select(r =>
            (DomainEvent)JsonSerializer.Deserialize(r.Data, GetEventType(r.EventType))!)
            .ToList();
    }
}

Event Store Comparison

FeaturePostgreSQL + MartenEventStoreDBDynamoDBKafka
Optimistic ConcurrencyUnique constraintNative stream revisionConditional writesManual (transactional producer)
Catch-up SubscriptionsPolling / tail functionNative persistent subscriptionsDynamoDB StreamsConsumer groups
Snapshot SupportMarten built-inCustom implementationSeparate tableCustom implementation
Global OrderingSerializable transactionSystem-wide positionNo global orderingPer-partition ordering
Operational ComplexityLow (existing PostgreSQL)Medium (new infrastructure)Low (managed service)Medium (cluster management)
Best ForTeams already on PostgreSQLEvent-sourcing-first systemsServerless, auto-scalingHigh-throughput event streaming
Practical Guidance: If your team already operates PostgreSQL, start with Marten. It provides event sourcing, document storage, and projections with minimal new infrastructure. Move to EventStoreDB when you need native subscription management, projection lifecycle hooks, or when aggregate counts exceed what a single PostgreSQL instance handles efficiently. Avoid using Kafka as the primary event store unless you need the throughput for other reasons — it lacks aggregate-level primitives that event sourcing requires.

18. Projections and Read Model Optimization

Projections are the bridge between the event stream and query-optimized read models. A projection subscribes to specific event types, processes each event, and updates a denormalized store designed for fast reads. The quality of your projections directly determines read performance. Poorly designed projections produce slow queries; well-designed projections eliminate joins, pre-compute aggregations, and deliver sub-millisecond response times.

Projection Lifecycle

flowchart LR ES["Event Store"] --> |"stream"| CP["Catch-up Subscription"] CP --> |"events"| PH["Projection Handler"] PH --> |"transform"| RV["Read View Update"] RV --> |"write"| RDB[(("Read Store\n(Postgres / ES)"))] RDB --> |"query"| QA["Query API"] QA --> |"response"| CL["Client"] style ES fill:#dbeafe,stroke:#2563eb,color:#0f172a style RDB fill:#dcfce7,stroke:#059669,color:#0f172a style PH fill:#fef3c7,stroke:#d97706,color:#0f172a

The diagram shows the projection pipeline: events flow from the store through a catch-up subscription to the projection handler, which transforms them into read view updates written to the read store. The query API serves client requests directly from the read store without touching the event store. This one-way flow ensures reads never impact write performance and vice versa.

Optimization Strategies

Idempotent handlers are essential. Distributed systems guarantee at-least-once delivery, meaning the same event may arrive multiple times. Use a processed-events table or a natural key (event ID plus stream position) to detect duplicates. Batch inserts dramatically improve throughput — instead of executing one SQL statement per event, accumulate changes in memory and flush them in a single transaction every 100-500 events. Selective projection filtering avoids unnecessary work: if a projection only cares about OrderPlaced events, skip all other event types at the subscription level rather than inside the handler.

C#
// Optimized projection with batch processing and idempotency
public class BatchOrderProjection : IProjectionBatchHandler
{
    private readonly IDbConnection _db;
    private readonly IIdempotencyTracker _tracker;
    private const int BatchSize = 200;

    public async Task ProcessBatchAsync(
        IReadOnlyList<DomainEvent> events,
        CancellationToken ct)
    {
        var pending = new List<OrderProjectionUpdate>();

        foreach (var @event in events)
        {
            if (!await _tracker.IsProcessedAsync(@event.EventId, ct))
            {
                pending.Add(BuildUpdate(@event));
            }
        }

        if (pending.Count == 0) return;

        // Single transaction for the entire batch
        await using var tx = await _db.BeginTransactionAsync(ct);
        var sql = @"
            INSERT INTO order_summaries (id, customer_id, status, total, updated_at)
            VALUES (@Id, @CustomerId, @Status, @Total, @UpdatedAt)
            ON CONFLICT (id) DO UPDATE SET
                status = @Status,
                total = @Total,
                item_count = @ItemCount,
                updated_at = @UpdatedAt";

        foreach (var update in pending)
        {
            await _db.ExecuteAsync(sql, update, tx);
        }

        // Mark events as processed
        foreach (var @event in events.Where(e => pending.Any(p => p.Id == GetAggregateId(e))))
        {
            await _tracker.MarkProcessedAsync(@event.EventId, ct);
        }

        await tx.CommitAsync(ct);
    }

    private OrderProjectionUpdate BuildUpdate(DomainEvent @event) => @event switch
    {
        OrderPlacedEvent e => new OrderProjectionUpdate
        {
            Id = e.OrderId, CustomerId = e.CustomerId,
            Status = "Placed", Total = e.Total, ItemCount = e.Items.Count,
            UpdatedAt = e.OccurredAt
        },
        OrderShippedEvent e => new OrderProjectionUpdate
        {
            Id = e.OrderId, Status = "Shipped",
            UpdatedAt = e.ShippedAt
        },
        _ => throw new InvalidOperationException($"Unhandled event: {@event.GetType().Name}")
    };
}

Read Model Storage Choices

Read Model NeedRecommended StoreRationale
Full-text searchElasticsearchInverted indexes, relevance scoring, fuzzy matching
Low-latency key-value lookupsRedisSub-millisecond reads, TTL support for cache invalidation
Complex analytical queriesPostgreSQL (materialized views)SQL flexibility, JOINs, window functions
Time-series dataInfluxDB, TimescaleDBOptimized for time-range queries and downsampling
Global distributionCosmos DBMulti-region replication, configurable consistency
Key Insight: Projections are disposable and rebuildable. If your read model has performance issues, you can redesign the projection, drop the read store table, and replay all events from the event store. This "throw away and rebuild" capability means you can iterate on read model design rapidly without risking data loss. The event store is always the source of truth.

17. Interview Questions and Answers

Q1: Explain CQRS in simple terms. How does it differ from MVC?

CQRS separates the read and write models. In MVC, a single model handles both reads and writes — the same entity is used for GET and POST requests. CQRS splits this: the command side (write) uses a rich domain model with business rules, validation, and invariants. The query side (read) uses denormalized, pre-computed views optimized for fast queries. This separation allows each side to scale independently and use different data stores. MVC is a presentation pattern; CQRS is an architectural pattern for the data layer.

Q2: What is the relationship between CQS and CQRS?

CQS (Command-Query Separation) is a class-level design principle: every method is either a command (mutates state) or a query (returns data), never both. CQRS applies this at the architecture level: the entire write side (commands, handlers, aggregates) is separate from the entire read side (queries, projections, DTOs). CQS is about method design; CQRS is about system architecture. CQRS is CQS applied at scale.

Q3: Why would you use Event Sourcing instead of just CQRS?

CQRS alone separates reads and writes but still uses mutable state. Event Sourcing replaces mutable state with an immutable event log. The benefits of adding Event Sourcing to CQRS: complete audit trail (required in finance, healthcare, legal), temporal queries (reconstruct past states), projection rebuilds (fix bugs or add new read models by replaying events), and natural event-driven integration (other services consume the event stream). The cost: event schema evolution, eventual consistency, and snapshot management.

Q4: How do you handle eventual consistency in a CQRS system?

Accept that read models lag behind writes. For most user interfaces, the lag (typically 1-50ms) is imperceptible. The user places an order, the UI shows a "processing" state, and the read model updates within milliseconds. For scenarios requiring read-your-writes consistency (e.g., "after I save, show me the saved version"), either query the write model directly for a short window, or embed a version token in the response and poll the read model until it reaches that version. Use optimistic UI patterns: show the expected result immediately and reconcile when the read model catches up.

Q5: What are the challenges of event schema evolution?

Events are immutable — you cannot change stored events. Challenges include: adding new fields without breaking old event handlers, removing deprecated fields, restructuring event data, and handling different event versions across projections. Solutions: upcasting (transform old events to new schema at read time), weak schema (add optional fields, never remove), event versioning (OrderPlaced-v2), and schema registries (enforce forward/backward compatibility). The key rule: never modify or delete stored events; always add new event types.

Q6: Explain sagas in the context of CQRS and Event Sourcing.

Sagas coordinate multi-aggregate workflows. When a command modifies multiple aggregates (e.g., placing an order affects the Order, Payment, and Inventory aggregates), a saga orchestrates the process. The saga listens for events (OrderPlaced), issues commands (ProcessPayment), listens for responses (PaymentProcessed), and issues follow-up commands (ReserveInventory). If any step fails, the saga issues compensating commands (CancelOrder, RefundPayment). Sagas maintain their own state, typically event-sourced, and are idempotent to handle duplicate events.

Q7: How do you test an Event Sourced system?

Test at three levels. Unit tests: load an aggregate from events, execute a command, assert the resulting events. This tests business rules without database access. Projection tests: replay events through a projection, assert the read model state. This verifies that projections correctly transform events. Integration tests: send a command through the full pipeline, verify events are stored, verify projections update. Property-based testing works well: generate random event sequences and verify aggregate invariants hold. The key advantage: no complex test fixtures needed, just event sequences.

Q8: What are snapshots and when should you use them?

Snapshots capture aggregate state at a point in time, stored alongside the event stream. When loading an aggregate, you load the latest snapshot and replay only events after the snapshot version. This transforms O(n) replay into O(threshold). Use snapshots when aggregates accumulate many events (hundreds or thousands). Common threshold: snapshot every 100-200 events. Without snapshots, loading an aggregate with 10,000 events requires deserializing and applying all 10,000 events on every command. With snapshots, you load one snapshot and replay at most 100 events.

Q9: Can you use CQRS without Event Sourcing and vice versa?

Yes, they are independent patterns. CQRS without Event Sourcing: use separate read/write models with a traditional database. The write side uses normalized tables; the read side uses denormalized views updated via triggers, CDC, or application-level synchronization. Event Sourcing without CQRS: read directly from the event store by replaying events. This works for simple domains but doesn't scale well for complex read requirements. Combining them is common because Event Sourcing naturally produces events that feed CQRS projections, but neither requires the other.

Q10: How do you handle debugging in an Event Sourced system?

Event Sourcing actually makes debugging easier than traditional systems. To reproduce any state: load the event stream for the aggregate, replay it, and you have the exact state at any point in time. To debug a bug: find the event sequence that triggers it, write a test that replays those events, and fix the handler. Event sourcing provides "time travel" debugging — you can reconstruct the system state at the moment the bug occurred. The challenge is that asynchronous projections may be in different states, so you need to trace events through projections to understand the full picture.

Q11: How does CQRS impact database design?

CQRS allows (and often encourages) different databases for read and write sides. The write database should be normalized, supporting transactions and referential integrity (PostgreSQL, SQL Server). The read database should be denormalized and optimized for query patterns (Elasticsearch for search, Redis for fast lookups, Cassandra for time-series, or even the same PostgreSQL with different tables). This polyglot persistence approach lets each database do what it does best, but adds operational complexity: you must manage, monitor, and backup multiple database systems.

Q12: What is the role of domain events vs. integration events?

Domain events are internal to a service's bounded context. They represent business facts within the aggregate boundary (OrderItemAdded, OrderPlaced). Integration events are published to external consumers and represent cross-service notifications. They may be a subset of domain events, enriched with data needed by external services, or transformed into a different schema. Keep domain events private; publish only integration events. This prevents internal implementation details from leaking into other services.

Summary

CQRS and Event Sourcing are powerful patterns that, when applied to the right problems, deliver significant benefits: independent read/write scaling, complete audit trails, temporal queries, projection rebuilds, and natural event-driven integration. They are not silver bullets — they add complexity, eventual consistency, and operational overhead. The key is to apply them where they solve real problems and avoid them where simpler approaches suffice.

Start with the fundamentals: separate your read and write models. If you need an audit trail, add event sourcing. If you need multiple read views, add projections. If you need cross-aggregate workflows, add sagas. Build incrementally, measure performance, and introduce complexity only when the problem demands it.

graph TB A[Start: Simple CRUD] -->|Pain: read/write coupling| B[Add CQRS] B -->|Pain: need audit trail| C[Add Event Sourcing] C -->|Pain: aggregate replay slow| D[Add Snapshots] C -->|Pain: multi-aggregate workflow| E[Add Sagas] B -->|Pain: new read views| F[Add Projections] D --> G[Production-Ready System] E --> G F --> G

Originally published on Ayodhyyya. Last updated June 15, 2026.