CQRS & Event Sourcing: The Complete Guide — A Senior+ Guide
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.
- Why Traditional CRUD Falls Short
- CQRS Fundamentals — Separating Reads from Writes
- Event Sourcing — State as an Immutable Log
- CQRS + Event Sourcing — The Natural Partnership
- Aggregate Design with Events in C#
- Building the Event Store
- Projections and Read Models
- Snapshots — Avoiding Replay overhead
- Sagas and Process Managers
- Event Schema Evolution and Versioning
- Testing CQRS and Event Sourcing Systems
- CQRS in Microservices — Integration Patterns
- Performance Considerations and Benchmarks
- When to Use (and When NOT to Use) These Patterns
- Tools, Frameworks, and Libraries
- Real-World Case Studies
- Interview Questions and Answers
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.
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.
| Aspect | Commands (Write Side) | Queries (Read Side) |
|---|---|---|
| Purpose | Enforce business rules, change state | Retrieve data for display |
| Naming | Imperative: PlaceOrder, CancelOrder | Declarative: GetOrder, ListOrders |
| Return Value | Void, event, or acknowledgement | DTOs, projections, or view models |
| Validation | Business rule validation (invariants) | Query parameter validation only |
| Data Model | Normalized, aggregate-based | Denormalized, flat, read-optimized |
| Database | Normalized relational (PostgreSQL, SQL Server) | Any: Elasticsearch, Redis, PostgreSQL, Cosmos DB |
| Scaling | Vertical (bigger primary) | Horizontal (more read replicas) |
Architecture Overview
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");
}
}
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
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
| Benefit | Description |
|---|---|
| Complete Audit Trail | Every state change is recorded. Compliance teams love this. No need for separate audit tables. |
| Temporal Queries | Reconstruct the state of any entity at any point in time. "What did this order look like at midnight?" |
| Debugging | Reproduce any state by replaying events. No more "we can't reproduce the bug" because you have the full history. |
| Event-Driven Integration | Other services consume the event stream. No polling, no CDC — events are the integration mechanism. |
| Schema Evolution | Old event versions can be upcasted to new versions. The system can evolve without losing data. |
| Temporal Analytics | Analyze 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.
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);
}
}
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, notPlaceOrder. 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
| Convention | Example | Rationale |
|---|---|---|
| Past tense | OrderPlaced | Events are facts that happened. Past tense conveys immutability. |
| Include aggregate ID | OrderPlaced(orderId: ...) | Projections need to know which aggregate the event belongs to. |
| Include all necessary data | Items, totals, timestamps | Projections should not query back to the write side. Events are self-contained. |
| Use value objects | Money, Address | Encapsulate domain concepts in strongly-typed value objects. |
| Never include infrastructure | No correlation IDs, trace headers | Domain 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
| Implementation | Technology | Pros | Cons |
|---|---|---|---|
| Purpose-built | EventStoreDB | Optimized for event sourcing, projections, subscriptions | Additional infrastructure to operate |
| Relational adapter | PostgreSQL + Marten | Uses existing PostgreSQL, good tooling | Not as optimized as EventStoreDB for large streams |
| NoSQL adapter | DynamoDB, Cosmos DB | Serverless, auto-scaling, global distribution | Limited querying, eventual consistency |
| Message broker | Kafka, Pulsar | Durable log, built-in retention and replay | No built-in aggregate-level indexing or concurrency |
| Custom SQL | Any RDBMS | Full control, simple schema | You 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);
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
| Type | Description | Example |
|---|---|---|
| Real-time | Processes events as they arrive, low latency | Order status dashboard |
| Batch | Processes events in batches, higher throughput | Nightly analytics report |
| Catch-up | Starts from a known position, rebuilds from events | New projection added in production |
| Transient | Lives in memory, rebuilt on demand | Typeahead 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.");
}
}
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);
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
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
| Strategy | Description | When to Use |
|---|---|---|
| Upcasting | Transform old event versions to the latest version at read time | Breaking changes that require restructuring |
| Weak Schema | Add new optional fields, never remove old ones | Additive changes (most common) |
| Event Versioning | Store as OrderPlaced-v2 with different type names | When the new version has a fundamentally different structure |
| Multi-version Projections | Each projection version handles its own event version | When 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
}
}
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.
Integration Patterns
| Pattern | Description | Pros | Cons |
|---|---|---|---|
| Shared Event Bus | All services publish/subscribe to a common bus (Kafka) | Simple, well-understood | Shared infrastructure coupling |
| Event Intermediary | An API Gateway or event relay transforms and routes events | Decouples services from event format | Additional component to maintain |
| Choreography | Services react independently to events, no central coordinator | Fully decoupled | Hard to reason about overall flow |
| Orchestration | A saga orchestrator issues commands to services | Clear flow, easy to understand | Single 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);
}
}
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
| Operation | CRUD Latency | CQRS + ES Latency | Notes |
|---|---|---|---|
| Write (command) | 5-50ms | 5-30ms | Event append is sequential I/O, faster than random I/O updates |
| Read (simple query) | 10-100ms | 1-10ms | Denormalized read models with indexes are fast |
| Read (complex query) | 100-5000ms | 5-50ms | Joins eliminated by denormalization |
| Aggregate load | 1 query | N events (until snapshot) | Snapshots mitigate this for long-lived aggregates |
| Projection lag | 0ms (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
OrderItemAddedevents into a singleOrderItemsBatchAddedevent). - 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:
| Scenario | Why Not | What to Do Instead |
|---|---|---|
| Simple CRUD app | Overkill. No complex business rules or audit needs. | Standard layered architecture with DDD-lite |
| Immediate consistency required | CQRS introduces eventual consistency by design. | Use synchronous projections or read from write model |
| Small team, tight deadline | Learning curve is steep. Productivity drops initially. | Monolith with good separation of concerns |
| Reporting-heavy app | Event sourcing is not optimized for analytical queries. | CQRS with separate OLAP database, no event sourcing |
| Frequent schema changes to core events | Event schema evolution is complex and error-prone. | Mutable state with database migrations |
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.
| Tool | Language | Focus | Description |
|---|---|---|---|
| Eventuous | C# / .NET | Event Sourcing | Lightweight, modern C# event sourcing framework with built-in support for aggregates, projections, and subscriptions. Works with EventStoreDB. |
| Marten | C# / .NET | Event Sourcing + Document DB | Uses PostgreSQL as an event store and document database. Great for teams already on PostgreSQL. Includes projections, subscriptions, and aggregate storage. |
| Axon Framework | Java / Kotlin | CQRS + ES | Full-featured Java framework for CQRS and Event Sourcing. Includes command bus, event bus, saga support, andaxon server for monitoring. |
| EventStoreDB | Multi-language | Event Store | Purpose-built event store with projections, subscriptions, and persistent subscriptions. The gold standard for event sourcing infrastructure. |
| Masstransit | C# / .NET | Message Bus | Advanced message bus for .NET with saga support, consumers, and multiple transport support (RabbitMQ, Azure Service Bus, Amazon SQS). |
| Brighter | C# / .NET | CQRS | Command processor and mediator with pipeline-based middleware. Supports command handlers, event handlers, and message stores. |
| NEventStore | C# / .NET | Event Store | Persistence abstraction for event sourcing. Supports SQL Server, MySQL, PostgreSQL, MongoDB, and Azure Table Storage. |
| Eventuate | Java | CQRS + ES | Microservices 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>();
}
}
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
| Lesson | Details |
|---|---|
| Start with snapshots early | Even if aggregates are small initially, they grow. Add snapshotting from day one with a generous interval (e.g., every 200 events). |
| Monitor projection lag | Set up dashboards for projection lag (time between event published and projection updated). Alert when lag exceeds threshold. |
| Test projection rebuilds regularly | A projection that cannot rebuild from events is a liability. Test rebuild in CI/CD pipeline. |
| Version events from the start | You WILL need to change event schemas. Plan for it from day one with upcaster infrastructure. |
| Keep events small | Events 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
| Feature | PostgreSQL + Marten | EventStoreDB | DynamoDB | Kafka |
|---|---|---|---|---|
| Optimistic Concurrency | Unique constraint | Native stream revision | Conditional writes | Manual (transactional producer) |
| Catch-up Subscriptions | Polling / tail function | Native persistent subscriptions | DynamoDB Streams | Consumer groups |
| Snapshot Support | Marten built-in | Custom implementation | Separate table | Custom implementation |
| Global Ordering | Serializable transaction | System-wide position | No global ordering | Per-partition ordering |
| Operational Complexity | Low (existing PostgreSQL) | Medium (new infrastructure) | Low (managed service) | Medium (cluster management) |
| Best For | Teams already on PostgreSQL | Event-sourcing-first systems | Serverless, auto-scaling | High-throughput event streaming |
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
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 Need | Recommended Store | Rationale |
|---|---|---|
| Full-text search | Elasticsearch | Inverted indexes, relevance scoring, fuzzy matching |
| Low-latency key-value lookups | Redis | Sub-millisecond reads, TTL support for cache invalidation |
| Complex analytical queries | PostgreSQL (materialized views) | SQL flexibility, JOINs, window functions |
| Time-series data | InfluxDB, TimescaleDB | Optimized for time-range queries and downsampling |
| Global distribution | Cosmos DB | Multi-region replication, configurable consistency |
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.
Originally published on Ayodhyyya. Last updated June 15, 2026.