CQRS is often pitched as a way to separate your read and write models, but the real magic happens when that separation allows your write side to publish changes that the read side then consumes to update itself.
Let’s see this in action. Imagine we have a simple Order aggregate. When an order is placed, we want to emit an OrderPlaced event.
// Write side
public class Order : AggregateRoot
{
public Guid OrderId { get; private set; }
public List<OrderItem> Items { get; private set; } = new List<OrderItem>();
public decimal Total { get; private set; }
public void PlaceOrder(IEnumerable<OrderItem> items)
{
if (!items.Any())
throw new InvalidOperationException("Cannot place an empty order.");
OrderId = Guid.NewGuid();
Items.AddRange(items);
Total = items.Sum(i => i.Price * i.Quantity);
// Publish the event
AddDomainEvent(new OrderPlacedEvent(OrderId, Items, Total));
}
}
public class OrderPlacedEvent : DomainEvent
{
public Guid OrderId { get; }
public IEnumerable<OrderItem> Items { get; }
public decimal Total { get; }
public OrderPlacedEvent(Guid orderId, IEnumerable<OrderItem> items, decimal total)
{
OrderId = orderId;
Items = items;
Total = total;
}
}
Now, our read side needs to listen for this OrderPlacedEvent and update its own projections.
// Read side event handler
public class OrderPlacedEventHandler : IEventHandler<OrderPlacedEvent>
{
private readonly ISqlConnectionFactory _connectionFactory;
public OrderPlacedEventHandler(ISqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task HandleAsync(OrderPlacedEvent domainEvent)
{
using (var connection = _connectionFactory.CreateConnection())
{
await connection.OpenAsync();
var sql = @"
INSERT INTO ReadOrders (OrderId, Total)
VALUES (@OrderId, @Total)";
await connection.ExecuteAsync(sql, new { domainEvent.OrderId, domainEvent.Total });
}
}
}
This setup allows the write side to remain focused on business logic and transactional integrity, while the read side can be optimized for querying without impacting write performance. The domain event acts as the glue, decoupling these two concerns.
The core problem this solves is the impedance mismatch between a transactional, often normalized, write model and a denormalized, query-optimized read model. In a traditional monolith, you might update both in a single transaction, risking performance bottlenecks on writes and complex update logic. With CQRS and domain events, you commit the write transaction quickly and then asynchronously propagate changes to the read model. This allows reads to be served from a highly optimized, denormalized structure, such as a SQL table or even a document database, tailored precisely for the queries your application needs. The write side doesn’t care how the read side uses the information, only that it has been correctly recorded.
You’re likely thinking about how to handle eventual consistency. If an OrderPlacedEvent fails to be processed by the read side handler, the read model will be out of sync. This is where reliable message delivery and retry mechanisms come into play, often managed by a dedicated message bus or event store. The read side handler would typically be part of a separate service or process, subscribed to the event stream. If a handler fails, it’s logged, and the message is retried according to the bus’s policy. You might also implement a mechanism to replay events from an event store to rebuild read models if they become corrupted or fall too far behind.
The most surprising part is that the "read model" doesn’t have to be a database table. It could be a materialized view in a search engine like Elasticsearch, a cache like Redis, or even a simple in-memory collection for very specific, low-volume read scenarios. The event handler is just a piece of code that reacts to a change and updates some representation of that data.
The next logical step is handling updates and deletions, and how those translate into different types of domain events and read model adjustments.