Implementing CQRS and Event Sourcing with C
Master Command Query Responsibility Segregation and Event Sourcing Patterns
Build scalable systems with separate read/write models and event-driven persistence.
Introduction: Why CQRS and Event Sourcing?
In the world of software architecture, scalability and maintainability are two of the most sought-after qualities. As systems grow in complexity, developers often struggle with monolithic designs that become fragile and difficult to extend. This is where CQRS (Command Query Responsibility Segregation) and Event Sourcing come in.
CQRS is a design pattern that separates the read and write operations of a system into distinct models, enabling better scalability and clarity. Event Sourcing complements CQRS by storing all changes to the application state as a sequence of immutable events. Together, these patterns empower developers to build systems that are scalable, auditable, and easy to evolve.
In this blog post, you'll learn how to implement CQRS and Event Sourcing in C# with practical examples, tips, and guidance. Let's dive in!
What is CQRS?
CQRS stands for Command Query Responsibility Segregation, a pattern that separates the responsibility of handling commands (write operations) from queries (read operations).
Why Use CQRS?
- Scalability: Queries and commands are handled separately, allowing you to optimize each independently.
- Clarity: By segregating read and write concerns, the codebase becomes easier to reason about.
- Flexibility: Different models can evolve independently, accommodating changing business requirements.
Real-World Analogy
Imagine a library:
- A librarian accepts new books (write operations).
- Readers search and borrow books (read operations). CQRS separates these responsibilities into two distinct workflows for better efficiency.
What is Event Sourcing?
Event Sourcing is an architectural pattern where state changes are stored as a sequence of immutable events. Instead of storing the "current state" in a database, you persist events that describe the changes.
Why Use Event Sourcing?
- Auditability: Every change to the system is recorded as an event, making it easy to replay history.
- Flexibility: Events are immutable, which simplifies state reconstruction and debugging.
- Performance: Event storage can be highly optimized for writes, and read models can be tailored for performance.
Real-World Analogy
Think of your bank statement:
- Instead of storing your current balance, the bank logs every transaction (credit/debit).
- Your balance is derived by replaying these transactions.
Implementing CQRS and Event Sourcing in C
Let’s build a simple system to manage a product catalog using CQRS and Event Sourcing. We'll use commands to add/update products and queries to retrieve product details.
Step 1: Define Commands and Queries
Commands represent intent to change state, while queries retrieve data. In our system, commands will include operations like adding a product, and queries will fetch product details.
// Command: Add Product
public class AddProductCommand
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// Query: Get Product Details
public class GetProductQuery
{
public Guid ProductId { get; set; }
}
Commands are typically handled by a command handler (write model), and queries are handled by a query handler (read model).
Step 2: Create Event Definitions
Events represent state changes in the system. For event sourcing, we store these events in an event store.
// Event: Product Added
public class ProductAddedEvent
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// Event: Product Updated
public class ProductUpdatedEvent
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
Events are immutable, meaning once created, they cannot be modified.
Step 3: Implement Event Store
The event store is responsible for persisting events. For simplicity, we'll use an in-memory store.
using System.Collections.Generic;
public class InMemoryEventStore
{
private readonly List<object> _events = new();
public void SaveEvent(object @event)
{
_events.Add(@event);
}
public IEnumerable<object> GetEvents()
{
return _events;
}
}
Step 4: Handle Commands and Emit Events
Command handlers perform business logic and emit events to reflect state changes.
public class ProductCommandHandler
{
private readonly InMemoryEventStore _eventStore;
public ProductCommandHandler(InMemoryEventStore eventStore)
{
_eventStore = eventStore;
}
public void Handle(AddProductCommand command)
{
var @event = new ProductAddedEvent
{
ProductId = command.ProductId,
Name = command.Name,
Price = command.Price
};
_eventStore.SaveEvent(@event);
}
public void Handle(UpdateProductCommand command)
{
var @event = new ProductUpdatedEvent
{
ProductId = command.ProductId,
Name = command.Name,
Price = command.Price
};
_eventStore.SaveEvent(@event);
}
}
Step 5: Project Events to a Read Model
The read model is a simplified representation of the data, optimized for queries. We'll use the events to build a read model.
using System.Collections.Concurrent;
public class ProductReadModel
{
private readonly ConcurrentDictionary<Guid, ProductDetails> _products = new();
public void Apply(ProductAddedEvent @event)
{
_products[@event.ProductId] = new ProductDetails
{
ProductId = @event.ProductId,
Name = @event.Name,
Price = @event.Price
};
}
public void Apply(ProductUpdatedEvent @event)
{
if (_products.TryGetValue(@event.ProductId, out var product))
{
product.Name = @event.Name;
product.Price = @event.Price;
}
}
public ProductDetails Get(Guid productId)
{
_products.TryGetValue(productId, out var product);
return product;
}
}
public class ProductDetails
{
public Guid ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
Step 6: Wire Everything Together
Finally, let's tie the components together and simulate the workflow.
var eventStore = new InMemoryEventStore();
var commandHandler = new ProductCommandHandler(eventStore);
var readModel = new ProductReadModel();
// Add a product
var productId = Guid.NewGuid();
var addCommand = new AddProductCommand
{
ProductId = productId,
Name = "Laptop",
Price = 1200.00m
};
commandHandler.Handle(addCommand);
// Project events to read model
foreach (var @event in eventStore.GetEvents())
{
switch (@event)
{
case ProductAddedEvent e:
readModel.Apply(e);
break;
case ProductUpdatedEvent e:
readModel.Apply(e);
break;
}
}
// Query product details
var productDetails = readModel.Get(productId);
Console.WriteLine($"Product: {productDetails.Name}, Price: {productDetails.Price}");
Common Pitfalls and How to Avoid Them
- Event Explosion: Avoid creating too many fine-grained events. Group related changes into meaningful events.
- Event Schema Evolution: Handle event versioning carefully to avoid breaking changes when updating event structures.
- Over-Engineering: Don’t use CQRS/Event Sourcing unless your application truly needs them. Simpler solutions often suffice for smaller systems.
Key Takeaways and Next Steps
- CQRS separates read and write concerns, enabling scalability and clarity.
- Event Sourcing stores state changes as immutable events, providing auditability and flexibility.
- Together, they enable robust, scalable, and maintainable systems.
Suggested Next Steps
- Deep Dive into Event Store: Explore libraries like EventStore for production-ready event storage.
- Integrate with Messaging: Use tools like RabbitMQ or Kafka for distributed event handling.
- Learn Advanced CQRS: Explore topics like sagas, aggregate roots, and domain-driven design.
CQRS and Event Sourcing are powerful patterns that can transform how you design and build scalable systems. By implementing these concepts in C#, you can create applications that are not only performant but also resilient and future-proof. Happy coding!