CQRS and Event Sourcing Explained cover image

CQRS and Event Sourcing Explained

By Julian Krause · Monday, September 15, 2025 · ~4 min read

CQRS and Event Sourcing are frequently mentioned together, but they are independent patterns that solve different problems. Understanding when and how to apply each one is critical to avoiding unnecessary complexity.

CQRS: Separating Reads from Writes

Command Query Responsibility Segregation means using different models for reading and writing data. In its simplest form, this is just having separate read and write DTOs. In its more advanced form, you might have entirely separate databases.

// Command side - rich domain model
public class PlaceOrderCommand : IRequest<Guid>
{
    public required string CustomerId { get; init; }
    public required List<OrderLineDto> Lines { get; init; }
}

public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _repository;

    public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(new CustomerId(cmd.CustomerId));
        foreach (var line in cmd.Lines)
            order.AddLine(new ProductId(line.ProductId), line.Quantity, line.UnitPrice);

        await _repository.AddAsync(order, ct);
        await _repository.SaveChangesAsync(ct);
        return order.Id;
    }
}

// Query side - thin read model, no domain logic
public class GetOrderSummaryQuery : IRequest<OrderSummaryDto?>
{
    public Guid OrderId { get; init; }
}

public class GetOrderSummaryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto?>
{
    private readonly IDbConnection _db;

    public async Task<OrderSummaryDto?> Handle(GetOrderSummaryQuery query, CancellationToken ct)
    {
        const string sql = """
            SELECT o.Id, o.Status, o.CreatedAt, COUNT(ol.Id) AS LineCount, SUM(ol.Total) AS Total
            FROM Orders o
            LEFT JOIN OrderLines ol ON ol.OrderId = o.Id
            WHERE o.Id = @OrderId
            GROUP BY o.Id, o.Status, o.CreatedAt
            """;

        return await _db.QuerySingleOrDefaultAsync<OrderSummaryDto>(sql, new { query.OrderId });
    }
}

The query side uses Dapper with raw SQL because it does not need the overhead of a full ORM. This is one of the key benefits of CQRS: you can optimize each side independently.

Event Sourcing: Storing State as Events

Event Sourcing takes a fundamentally different approach to persistence. Instead of storing the current state, you store every state change as an immutable event.

public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _uncommittedEvents = new();
    public int Version { get; protected set; }

    protected void RaiseEvent(IDomainEvent @event)
    {
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }

    protected abstract void Apply(IDomainEvent @event);

    public IReadOnlyList<IDomainEvent> GetUncommittedEvents() => _uncommittedEvents;
    public void ClearUncommittedEvents() => _uncommittedEvents.Clear();
}

public class ShoppingCart : AggregateRoot
{
    public Guid Id { get; private set; }
    private readonly Dictionary<string, int> _items = new();

    public void AddItem(string productId, int quantity)
    {
        RaiseEvent(new ItemAdded(Id, productId, quantity, DateTime.UtcNow));
    }

    public void RemoveItem(string productId)
    {
        if (!_items.ContainsKey(productId))
            throw new InvalidOperationException("Item not in cart.");

        RaiseEvent(new ItemRemoved(Id, productId, DateTime.UtcNow));
    }

    protected override void Apply(IDomainEvent @event)
    {
        switch (@event)
        {
            case ItemAdded e:
                _items[e.ProductId] = _items.GetValueOrDefault(e.ProductId) + e.Quantity;
                break;
            case ItemRemoved e:
                _items.Remove(e.ProductId);
                break;
        }
        Version++;
    }
}

The Event Store

Events are persisted in an append-only store. To rebuild current state, you replay all events for an aggregate.

public class EventStore : IEventStore
{
    private readonly IDbConnection _db;

    public async Task SaveEventsAsync(Guid aggregateId, IEnumerable<IDomainEvent> events, int expectedVersion)
    {
        foreach (var @event in events)
        {
            expectedVersion++;
            await _db.ExecuteAsync(
                "INSERT INTO EventStore (AggregateId, Version, EventType, Data, Timestamp) VALUES (@AggregateId, @Version, @EventType, @Data, @Timestamp)",
                new
                {
                    AggregateId = aggregateId,
                    Version = expectedVersion,
                    EventType = @event.GetType().Name,
                    Data = JsonSerializer.Serialize(@event, @event.GetType()),
                    Timestamp = DateTime.UtcNow
                });
        }
    }
}

When to Use Each Pattern

CQRS alone is useful when your read and write patterns differ significantly. Most applications have far more reads than writes, and the read models often look nothing like the write models. You can apply CQRS without Event Sourcing.

Event Sourcing is valuable when you need a complete audit trail, when you need to reconstruct past states, or when your domain is inherently event-driven. Financial systems, logistics tracking, and collaborative editing are good candidates.

Both together make sense when you need Event Sourcing and want to build optimized read projections from the event stream. The events become the single source of truth, and you project them into read-optimized views.

The Honest Trade-Offs

Event Sourcing adds significant complexity. Schema evolution is hard. Debugging requires replaying events. Projections can fall behind. Do not adopt it unless the benefits clearly outweigh these costs. For most line-of-business applications, a well-designed relational model with CQRS is more than sufficient.


Comments (2)

Hannah Tuesday, March 10, 2026 5:08 PM

I have a question: does this also apply to older versions?

Ben Sunday, March 8, 2026 8:08 PM

I agree, great article!

Clara Monday, March 9, 2026 8:08 PM

Good question, I'd like to know too.

Anna Wednesday, March 11, 2026 5:08 PM

Thanks for sharing — very helpful.

Leave a Comment