Table of Contents
- Introduction
- Core Concepts
2.1. What Is Event Sourcing?
2.2. What Is CQRS? - Why Combine Event Sourcing and CQRS?
- Designing a Resilient Architecture
4.1. Event Store Selection
4.2. Command Side Design
4.3. Query Side Design
4.4. Event Publishing & Messaging - Practical Implementation Example
5.1. Domain Model: Order Management
5.2. Command Handlers
5.3. Event Handlers & Projections
5.4. Sample Code (C# with EventStoreDB & MediatR) - Operational Concerns
6.1. Event Versioning & Schema Evolution
6.2. Idempotency & Exactly‑Once Processing
6.3. Consistency Models
6.4. Testing Strategies
6.5. Monitoring & Observability - Real‑World Case Studies
- Best‑Practice Checklist
- Conclusion
- Resources
Introduction
Modern distributed systems must cope with high traffic volumes, evolving business rules, and ever‑changing infrastructure. Traditional CRUD‑centric designs often become brittle under these pressures: they mix read and write concerns, hide domain intent, and make scaling unpredictable.
Event sourcing and Command‑Query Responsibility Segregation (CQRS) are two architectural patterns that, when combined, provide a clear separation of concerns, immutable audit trails, and natural pathways to scalability and resilience. This article delves deep into both patterns, explains why they complement each other, and walks through a complete, production‑ready example of building a resilient data architecture for a distributed order‑management service.
Note: While the concepts are language‑agnostic, the code snippets use C# and the open‑source EventStoreDB to keep the example concrete and runnable.
Core Concepts
What Is Event Sourcing?
Event sourcing stores domain events—facts that have happened—as the primary source of truth. Instead of persisting the current state of an entity (e.g., a row in a relational table), you persist a chronological series of immutable events that, when replayed, reconstruct the entity’s state.
Key properties:
| Property | Description |
|---|---|
| Immutability | Events never change; they are append‑only. |
| Auditability | Complete history is available for compliance and debugging. |
| Temporal Queries | You can reconstruct state at any point in time (time‑travel). |
| Event Replay | Enables rebuilding projections, migration, and debugging. |
What Is CQRS?
CQRS separates the command (write) model from the query (read) model. Commands validate intent and mutate state, while queries retrieve data optimized for read performance.
Benefits:
- Scalable reads – independent read stores (e.g., denormalized tables, caches).
- Tailored models – write models reflect domain invariants; read models fit UI or reporting needs.
- Isolation of concerns – teams can evolve write and read sides independently.
Why Combine Event Sourcing and CQRS?
| Challenge | Traditional CRUD | Event Sourcing + CQRS |
|---|---|---|
| State Reconstruction | Hard; requires snapshots, backups. | Replay events to any point in time. |
| Audit & Compliance | Additional logging needed. | Events are the audit log. |
| Scalability | Read/write contention on same tables. | Independent scaling of write and read stores. |
| Domain Complexity | Business rules often scattered. | Commands encapsulate intent; events capture outcomes. |
| Fault Tolerance | DB failures affect both reads/writes. | Event store can be replicated; read side can be rebuilt from events. |
The synergy arises because event sourcing naturally produces a stream of events that can be consumed by read‑side projections (the “Query” in CQRS). This decoupling yields a resilient architecture where failures on one side rarely affect the other.
Designing a Resilient Architecture
Event Store Selection
Choosing an event store is a foundational decision. You need:
- Append‑only semantics with optimistic concurrency (expected version).
- Scalable storage (partitioning, clustering).
- Built‑in subscription mechanisms for projecting events.
Popular options:
- EventStoreDB – purpose‑built, supports streams, subscriptions, and snapshots.
- Apache Kafka – log‑based, excellent for high‑throughput event streaming.
- Amazon DynamoDB Streams – serverless option with built‑in durability.
Command Side Design
- Define Commands – intent objects (e.g.,
CreateOrder,AddItemToOrder). - Validate Business Rules – guard invariants before persisting events.
- Emit Events – on successful validation, create domain events (
OrderCreated,ItemAdded). - Persist Atomically – write events with expected version to guarantee consistency.
Query Side Design
- Read Model (Projection) – materialized view built by handling events.
- Storage Choice – relational DB for reporting, NoSQL for low‑latency UI, or in‑memory cache.
- Update Strategy – Eventual Consistency is typical; projections are updated asynchronously.
Event Publishing & Messaging
While the event store itself can act as a broker, many teams add a message bus (e.g., RabbitMQ, NATS) to:
- Decouple services – external microservices subscribe without touching the event store.
- Support fan‑out – multiple projections or downstream processes.
- Implement retry & dead‑letter handling – improve fault tolerance.
Practical Implementation Example
Domain Model: Order Management
We’ll model a simple e‑commerce order lifecycle:
- Commands:
CreateOrder,AddItem,RemoveItem,ConfirmOrder,CancelOrder. - Events:
OrderCreated,ItemAdded,ItemRemoved,OrderConfirmed,OrderCancelled.
Command Handlers
Each command handler:
- Loads the aggregate’s current event stream.
- Rehydrates the aggregate by applying past events.
- Executes the command on the aggregate (business logic).
- Collects new events and appends them to the event store.
Event Handlers & Projections
A projection (read model) subscribes to the event stream and updates a denormalized table OrderSummary:
| Column | Meaning |
|---|---|
OrderId | Primary key. |
CustomerId | Owner of the order. |
Status | Pending, Confirmed, Cancelled. |
ItemCount | Number of distinct items. |
TotalAmount | Calculated from line items. |
LastUpdated | Timestamp of the latest event. |
Sample Code (C# with EventStoreDB & MediatR)
// ---------- Domain Events ----------
public record OrderCreated(Guid OrderId, Guid CustomerId) : IEvent;
public record ItemAdded(Guid OrderId, Guid ProductId, int Quantity, decimal Price) : IEvent;
public record OrderConfirmed(Guid OrderId) : IEvent;
// ---------- Commands ----------
public record CreateOrder(Guid OrderId, Guid CustomerId) : IRequest;
public record AddItem(Guid OrderId, Guid ProductId, int Quantity, decimal Price) : IRequest;
// ---------- Aggregate ----------
public class OrderAggregate {
private readonly List<IEvent> _uncommitted = new();
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public List<OrderLine> Lines { get; } = new();
public void Apply(OrderCreated e) {
Id = e.OrderId;
CustomerId = e.CustomerId;
}
public void Apply(ItemAdded e) {
var line = Lines.FirstOrDefault(l => l.ProductId == e.ProductId);
if (line == null) Lines.Add(new OrderLine(e.ProductId, e.Quantity, e.Price));
else line.Increase(e.Quantity);
}
// Command execution
public void Handle(CreateOrder cmd) {
if (Id != Guid.Empty) throw new InvalidOperationException("Order already exists.");
var ev = new OrderCreated(cmd.OrderId, cmd.CustomerId);
Apply(ev);
_uncommitted.Add(ev);
}
public void Handle(AddItem cmd) {
if (Id == Guid.Empty) throw new InvalidOperationException("Order not created.");
var ev = new ItemAdded(cmd.OrderId, cmd.ProductId, cmd.Quantity, cmd.Price);
Apply(ev);
_uncommitted.Add(ev);
}
public IEnumerable<IEvent> GetUncommitted() => _uncommitted;
}
// ---------- Command Handler ----------
public class OrderCommandHandler :
IRequestHandler<CreateOrder>,
IRequestHandler<AddItem> {
private readonly IEventStoreConnection _store;
private readonly JsonSerializerOptions _json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public OrderCommandHandler(IEventStoreConnection store) => _store = store;
public async Task<Unit> Handle(CreateOrder request, CancellationToken ct) {
var stream = $"order-{request.OrderId}";
var agg = new OrderAggregate();
// Load existing events (if any)
var events = await _store.ReadStreamEventsForwardAsync(stream, 0, 4096, false, ct);
foreach (var ev in events.Events) {
var domainEvent = JsonSerializer.Deserialize<IEvent>(ev.Event.Data, _json)!;
((dynamic)agg).Apply((dynamic)domainEvent);
}
agg.Handle(request);
var newEvents = agg.GetUncommitted()
.Select(e => new EventData(
Uuid.NewUuid(),
e.GetType().Name,
JsonSerializer.SerializeToUtf8Bytes(e, _json)));
await _store.AppendToStreamAsync(stream, events.LastEventNumber, newEvents, ct);
return Unit.Value;
}
public async Task<Unit> Handle(AddItem request, CancellationToken ct) {
// Similar to CreateOrder – load, apply, persist
// Omitted for brevity
return Unit.Value;
}
}
// ---------- Projection ----------
public class OrderProjection {
private readonly IDbConnection _db;
public OrderProjection(IDbConnection db) => _db = db;
public async Task Handle(IEvent @event) {
switch (@event) {
case OrderCreated e:
await _db.ExecuteAsync(
@"INSERT INTO OrderSummary (OrderId, CustomerId, Status, ItemCount, TotalAmount, LastUpdated)
VALUES (@OrderId, @CustomerId, 'Pending', 0, 0, @Now)",
new { e.OrderId, e.CustomerId, Now = DateTime.UtcNow });
break;
case ItemAdded e:
await _db.ExecuteAsync(
@"UPDATE OrderSummary
SET ItemCount = ItemCount + @Qty,
TotalAmount = TotalAmount + (@Qty * @Price),
LastUpdated = @Now
WHERE OrderId = @OrderId",
new { e.OrderId, Qty = e.Quantity, e.Price, Now = DateTime.UtcNow });
break;
// Additional cases for other events...
}
}
}
Explanation of the snippet
- Aggregate – encapsulates domain logic and accumulates uncommitted events.
- Command Handler – loads the event stream, rehydrates the aggregate, executes the command, then appends new events with optimistic concurrency (
events.LastEventNumber). - Projection – uses a simple Dapper query to maintain a read‑optimized
OrderSummarytable. The projection can be wired up via an EventStore subscription (ConnectToPersistentSubscriptionAsync).
Operational Concerns
Event Versioning & Schema Evolution
Events are immutable, so schema changes require forward‑ and backward‑compatible strategies:
| Strategy | When to Use |
|---|---|
| Additive Fields | New data can be optional; old consumers ignore it. |
| Upcasters | Transform older event versions to the latest shape during deserialization. |
| Snapshotting | Periodic state snapshots reduce replay time after schema changes. |
| Event Type Renaming | Keep the original type name; map to new class via configuration. |
Idempotency & Exactly‑Once Processing
Since projections may receive the same event more than once (e.g., after a restart), handlers must be idempotent:
await _db.ExecuteAsync(
@"INSERT INTO ItemLog (EventId, OrderId, ProductId, Qty)
VALUES (@EventId, @OrderId, @ProductId, @Qty)
ON CONFLICT (EventId) DO NOTHING;",
new { EventId = ev.EventId, e.OrderId, e.ProductId, e.Quantity });
Using the event ID as a unique key guarantees exactly‑once semantics.
Consistency Models
- Eventual Consistency – the read side lags behind the write side; acceptable for most UI scenarios.
- Read‑Your‑Writes – can be achieved by serving the request from the write model or by waiting for the projection to catch up (e.g., via a “synchronization point”).
Testing Strategies
- Unit tests – validate aggregate behavior given a sequence of events.
- Specification tests – given a command, assert the emitted events.
- Integration tests – spin up an in‑memory event store (e.g.,
EventStoreDBDocker) and verify end‑to‑end flow. - Chaos testing – simulate network partitions or store failures to confirm resilience.
Monitoring & Observability
- Event store metrics – stream length, write latency, subscription lag.
- Projection health – lag in processed events, rate of dead‑lettered events.
- Distributed tracing – propagate trace IDs through commands → events → projections.
- Alerting – trigger on high lag or failure to persist events.
Real‑World Case Studies
| Company | Use‑Case | Benefits Realized |
|---|---|---|
| Netflix | Playback history & recommendation pipeline | Immutable audit of user actions; ability to rebuild recommendation models from raw events. |
| Uber | Trip lifecycle management | Scalable write path for millions of concurrent trips; separate read side for driver & rider dashboards. |
| Shopify | Order processing & fulfillment | Event sourcing enabled time‑travel debugging of order failures; CQRS allowed independent scaling of order API and analytics dashboards. |
| Airbnb | Property booking and pricing engine | Event versioning facilitated gradual rollout of new pricing algorithms without breaking historic data. |
These examples illustrate how large‑scale platforms rely on the combination of event sourcing and CQRS to achieve high availability, auditability, and flexible evolution.
Best‑Practice Checklist
- Model events as facts, not commands.
- Keep events small – store only data needed to reconstruct state.
- Version events and implement upcasters early.
- Use optimistic concurrency when appending to streams.
- Separate write and read databases; choose the read store that matches query patterns.
- Make projections idempotent – rely on event IDs for deduplication.
- Snapshot frequently for aggregates with long histories.
- Publish events to a message bus for cross‑service communication.
- Implement automated tests for command‑event expectations.
- Monitor lag between event production and projection consumption.
Conclusion
Event sourcing and CQRS together provide a powerful blueprint for building resilient, observable, and scalable data architectures in modern distributed environments. By treating events as the single source of truth and separating command processing from query serving, teams gain:
- Immutable audit trails for compliance and debugging.
- Natural scalability through independent read/write paths.
- Robust fault tolerance, as read models can be rebuilt from the event log.
- Flexibility to evolve domain models without breaking existing data.
While the patterns introduce complexity—especially around versioning, eventual consistency, and operational monitoring—the benefits far outweigh the costs for systems that demand high reliability and rapid evolution. Armed with the concepts, design guidelines, and concrete code examples presented here, you’re ready to embark on building a resilient, event‑driven architecture that can stand the test of scale and change.
Resources
- Event Sourcing Basics – Martin Fowler
- EventStoreDB Documentation
- Axon Framework – CQRS & Event Sourcing for Java
- CQRS Journey – Microsoft Docs
- Designing Event‑Driven Systems – Confluent Blog