Implementing CQRS and Event Sourcing in ASP.NET Core: Separating Reads and Writes
Breaking Free from CRUD: How CQRS and Event Sourcing Transform Complex Domain Models
If you’ve been building ASP.NET Core applications for a while, you’ve probably hit that wall where traditional CRUD operations start feeling awkward. Maybe you’re dealing with complex business logic where reads and writes have completely different requirements, or you need a complete audit trail of every change in your system. CQRS (Command Query Responsibility Segregation) and Event Sourcing offer a powerful alternative approach that separates your read and write operations into distinct models, giving you flexibility, scalability, and a full history of your application’s state changes.
In this article, we’ll explore how to implement these patterns in ASP.NET Core, when they make sense for your project, and how to avoid common pitfalls along the way.
Let’s be honest: most applications start with a simple CRUD approach, and that’s perfectly fine. You create entities, map them to database tables, and build controllers that handle create, read, update, and delete operations. It’s straightforward, well-understood, and gets you to market quickly. But as your application grows and your domain becomes more complex, you might notice some friction. Your read queries become increasingly complicated as you try to optimize for various display scenarios, while your write operations get bogged down with validation logic and business rules that don’t quite fit into your entity models.
This is where CQRS enters the picture. The core idea is beautifully simple: separate the models you use for reading data from the models you use for writing data. Instead of forcing a single entity model to serve both purposes, you create dedicated command models for writes and query models for reads. This separation might sound like extra work at first, but it opens up some interesting possibilities that become invaluable as your system scales.
Understanding CQRS: More Than Just Separation
CQRS isn’t a new pattern. Greg Young and Udi Dahan popularized it in the mid-2000s, but it’s gained traction in modern application development because it solves real problems. At its heart, CQRS recognizes that reads and writes have fundamentally different characteristics and requirements. When you’re reading data, you care about performance, denormalization for efficient queries, and presenting information in formats optimized for your UI. When you’re writing data, you care about business rules, validation, consistency, and capturing intent.
In a traditional ASP.NET Core application, you might have a Product entity that you use everywhere. Your controller receives a Product object, validates it, saves it to the database, and then retrieves Product objects to display in your views. As your application evolves, you start adding more properties to Product, some only relevant for reads, others only for writes. You end up with a bloated model that tries to be everything to everyone, and neither reads nor writes are as efficient as they could be.
With CQRS, you’d instead have commands like CreateProductCommand or UpdateProductPriceCommand that represent specific write operations. These commands capture the intent of the operation and contain only the data necessary to execute that specific action. On the read side, you’d have query models like ProductListViewModel or ProductDetailViewModel that are shaped exactly for how you need to display the data. Your read models might even be denormalized, pulling data from multiple sources to create the perfect view without expensive joins at query time.
The separation becomes even more powerful when you introduce different storage mechanisms for reads and writes. Your write side might use a relational database that enforces referential integrity and supports transactions, while your read side uses a document database or even a search engine optimized for complex queries. This is where implementing health checks and monitoring becomes crucial, as you’re now managing multiple data stores that need to stay synchronized.
Event Sourcing: Capturing the Story of Your Data
Event Sourcing takes things a step further by fundamentally changing how you persist data. Instead of storing the current state of your entities, you store a sequence of events that represent every change that has ever happened. Think of it like a ledger in accounting: you don’t erase and rewrite entries when something changes; you add new entries that describe what changed.
In an Event Sourced system, when a user changes a product’s price, you don’t update a Price column in the database. Instead, you append a ProductPriceChanged event to the event store. This event contains all the information about what happened: the product ID, the old price, the new price, who made the change, and when it occurred. To determine the current state of a product, you replay all its events in order.
This might sound inefficient, but it provides some remarkable benefits. First, you have a complete audit trail of everything that ever happened in your system. Need to know who changed that price six months ago? Just look at the events. Want to see what the state of an order was at a specific point in time? Replay events up to that moment. This level of traceability is invaluable in domains like finance, healthcare, or any regulated industry where audit requirements are strict.
Second, events are immutable. Once written, they never change. This eliminates a whole class of concurrency problems and makes your system more resilient. You can’t accidentally corrupt historical data because you simply don’t modify it. If you made a mistake, you add a new compensating event rather than trying to edit history.
Third, events capture intent and context that gets lost in traditional CRUD systems. When you see an OrderCancelled event, you know exactly what happened. Compare this to seeing a Status column change from “Pending” to “Cancelled” in a database. You know the outcome, but you’ve lost the richness of why it happened or what triggered the change.
Implementing CQRS in ASP.NET Core
Let’s get practical and look at how to implement CQRS in an ASP.NET Core application. We’ll build a simple order management system to illustrate the concepts. The beauty of CQRS is that you can start small and gradually introduce it into your application. You don’t need to refactor everything at once.
First, let’s set up the command side. Commands represent write operations and should be named as imperative actions. In our order system, we might have commands like CreateOrder, AddOrderItem, or CancelOrder. Each command is a simple class that contains all the data needed to execute the operation.
Here’s what a CreateOrder command might look like:
public class CreateOrderCommand
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public string ShippingAddress { get; set; }
public List<OrderItemDto> Items { get; set; }
}
public class OrderItemDto
{
public Guid ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}Notice that the command is just data. There’s no behavior here. The logic for executing the command lives in a command handler, which follows the single responsibility principle by focusing solely on processing one type of command. Using a mediator pattern library like MediatR makes this architecture clean and testable.
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result>
{
private readonly IOrderRepository _repository;
private readonly IEventBus _eventBus;
public CreateOrderCommandHandler(IOrderRepository repository, IEventBus eventBus)
{
_repository = repository;
_eventBus = eventBus;
}
public async Task<Result> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
{
// Validate the command
if (command.Items == null || !command.Items.Any())
return Result.Failure("Order must contain at least one item");
// Create the domain entity
var order = Order.Create(
command.OrderId,
command.CustomerId,
command.ShippingAddress,
command.Items
);
// Persist to the write store
await _repository.SaveAsync(order);
// Publish domain events for read model updates
await _eventBus.PublishAsync(new OrderCreatedEvent
{
OrderId = order.Id,
CustomerId = order.CustomerId,
CreatedAt = order.CreatedAt,
TotalAmount = order.TotalAmount
});
return Result.Success();
}
}On the query side, you create query objects that represent specific read operations. These queries return view models shaped exactly for your UI needs, and they’re handled by separate query handlers. This keeps your code organized and makes it easy to optimize read operations independently from writes.
public class GetOrderDetailsQuery : IRequest<OrderDetailsViewModel>
{
public Guid OrderId { get; set; }
}
public class GetOrderDetailsQueryHandler : IRequestHandler<GetOrderDetailsQuery, OrderDetailsViewModel>
{
private readonly IOrderReadRepository _readRepository;
public GetOrderDetailsQueryHandler(IOrderReadRepository readRepository)
{
_readRepository = readRepository;
}
public async Task<OrderDetailsViewModel> Handle(GetOrderDetailsQuery query, CancellationToken cancellationToken)
{
return await _readRepository.GetOrderDetailsAsync(query.OrderId);
}
}Your read repository can use a completely different database or data access strategy than your write repository. You might use Entity Framework Core for writes but Dapper for reads to maximize query performance. Or you might maintain denormalized read models in a document database while your write side uses a traditional relational database.
This separation also makes it easier to implement background services for processing tasks like updating read models asynchronously after write operations complete, ensuring your system remains responsive even under heavy load.
Adding Event Sourcing to the Mix
Now let’s layer Event Sourcing into our CQRS implementation. Instead of saving the current state of our Order entity, we’ll save the events that led to that state. This requires rethinking how we design our domain entities.
In an Event Sourced system, your domain entities become event-driven. They apply events to change their state, and they expose events that other parts of the system can subscribe to. Here’s a simplified Order aggregate that uses Event Sourcing:
public class Order : AggregateRoot
{
private Guid _id;
private Guid _customerId;
private List<OrderItem> _items = new();
private OrderStatus _status;
private decimal _totalAmount;
public static Order Create(Guid orderId, Guid customerId, string shippingAddress, List<OrderItemDto> items)
{
var order = new Order();
order.ApplyChange(new OrderCreatedEvent
{
OrderId = orderId,
CustomerId = customerId,
ShippingAddress = shippingAddress,
Items = items,
CreatedAt = DateTime.UtcNow
});
return order;
}
public void AddItem(Guid productId, int quantity, decimal unitPrice)
{
if (_status != OrderStatus.Pending)
throw new InvalidOperationException("Cannot add items to a non-pending order");
ApplyChange(new OrderItemAddedEvent
{
OrderId = _id,
ProductId = productId,
Quantity = quantity,
UnitPrice = unitPrice
});
}
public void Cancel(string reason)
{
if (_status == OrderStatus.Shipped)
throw new InvalidOperationException("Cannot cancel a shipped order");
ApplyChange(new OrderCancelledEvent
{
OrderId = _id,
Reason = reason,
CancelledAt = DateTime.UtcNow
});
}
private void Apply(OrderCreatedEvent @event)
{
_id = @event.OrderId;
_customerId = @event.CustomerId;
_status = OrderStatus.Pending;
_items = @event.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList();
_totalAmount = _items.Sum(i => i.Quantity * i.UnitPrice);
}
private void Apply(OrderItemAddedEvent @event)
{
var item = new OrderItem
{
ProductId = @event.ProductId,
Quantity = @event.Quantity,
UnitPrice = @event.UnitPrice
};
_items.Add(item);
_totalAmount += item.Quantity * item.UnitPrice;
}
private void Apply(OrderCancelledEvent @event)
{
_status = OrderStatus.Cancelled;
}
}Notice the pattern here: public methods that change state don’t directly modify properties. Instead, they create events and call ApplyChange, which records the event and then applies it to update the internal state. This ensures that all state changes flow through events, maintaining consistency between what’s stored and what’s in memory.
The AggregateRoot base class handles the mechanics of collecting events and managing version numbers for optimistic concurrency. When you save an aggregate, you’re really saving its events to an event store. When you load an aggregate, you’re replaying all its events in sequence to reconstruct its current state.
Building an Event Store
An event store is a specialized database optimized for appending events and reading event streams. You can use existing event store solutions like EventStoreDB or build your own using a relational database. For simpler scenarios, SQL Server or PostgreSQL work perfectly fine as event stores.
Here’s a basic event store implementation using Entity Framework Core:
public class EventStore
{
private readonly EventStoreContext _context;
public EventStore(EventStoreContext context)
{
_context = context;
}
public async Task SaveEventsAsync(Guid aggregateId, IEnumerable<DomainEvent> events, int expectedVersion)
{
var currentVersion = await _context.Events
.Where(e => e.AggregateId == aggregateId)
.MaxAsync(e => (int?)e.Version) ?? 0;
if (currentVersion != expectedVersion)
throw new ConcurrencyException($"Expected version {expectedVersion} but found {currentVersion}");
var version = expectedVersion;
foreach (var @event in events)
{
version++;
_context.Events.Add(new StoredEvent
{
AggregateId = aggregateId,
EventType = @event.GetType().Name,
EventData = JsonSerializer.Serialize(@event),
Version = version,
Timestamp = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
}
public async Task<List<DomainEvent>> GetEventsAsync(Guid aggregateId)
{
var storedEvents = await _context.Events
.Where(e => e.AggregateId == aggregateId)
.OrderBy(e => e.Version)
.ToListAsync();
return storedEvents.Select(e => DeserializeEvent(e.EventType, e.EventData)).ToList();
}
private DomainEvent DeserializeEvent(string eventType, string eventData)
{
var type = Type.GetType($"YourNamespace.{eventType}");
return (DomainEvent)JsonSerializer.Deserialize(eventData, type);
}
}This implementation stores events as JSON in a simple table with the aggregate ID, event type, version number, and timestamp. The version number is crucial for implementing optimistic concurrency. If two processes try to modify the same aggregate simultaneously, one will fail when it tries to save events with an outdated expected version.
In production systems, you’d want to add more sophistication here. You might partition your event store by aggregate type for better performance, add snapshotting to avoid replaying thousands of events for long-lived aggregates, or use a specialized event store database that provides these features out of the box.
Projections and Read Models
With events as your source of truth, how do you handle queries? This is where projections come in. A projection is a process that listens to events and builds read models optimized for specific queries. When an OrderCreated event is published, a projection handler picks it up and updates a denormalized read model that your query handlers can use.
public class OrderDetailsProjection : IEventHandler<OrderCreatedEvent>,
IEventHandler<OrderItemAddedEvent>,
IEventHandler<OrderCancelledEvent>
{
private readonly OrderReadContext _readContext;
public OrderDetailsProjection(OrderReadContext readContext)
{
_readContext = readContext;
}
public async Task Handle(OrderCreatedEvent @event)
{
var orderDetails = new OrderDetailsReadModel
{
OrderId = @event.OrderId,
CustomerId = @event.CustomerId,
ShippingAddress = @event.ShippingAddress,
Status = "Pending",
CreatedAt = @event.CreatedAt,
Items = @event.Items.Select(i => new OrderItemReadModel
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
};
orderDetails.TotalAmount = orderDetails.Items.Sum(i => i.Quantity * i.UnitPrice);
_readContext.OrderDetails.Add(orderDetails);
await _readContext.SaveChangesAsync();
}
public async Task Handle(OrderItemAddedEvent @event)
{
var orderDetails = await _readContext.OrderDetails
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.OrderId == @event.OrderId);
orderDetails.Items.Add(new OrderItemReadModel
{
ProductId = @event.ProductId,
Quantity = @event.Quantity,
UnitPrice = @event.UnitPrice
});
orderDetails.TotalAmount = orderDetails.Items.Sum(i => i.Quantity * i.UnitPrice);
await _readContext.SaveChangesAsync();
}
public async Task Handle(OrderCancelledEvent @event)
{
var orderDetails = await _readContext.OrderDetails
.FirstOrDefaultAsync(o => o.OrderId == @event.OrderId);
orderDetails.Status = "Cancelled";
orderDetails.CancelledAt = @event.CancelledAt;
await _readContext.SaveChangesAsync();
}
}Projections can run synchronously right after events are saved, or they can run asynchronously in the background. Running them asynchronously introduces eventual consistency. There’s a brief window where the read model might not reflect the very latest writes, but it also improves performance and scalability. For most applications, the delay is measured in milliseconds and isn’t noticeable to users.
You can build multiple projections from the same event stream, each optimized for different use cases. You might have one projection that creates a detailed order view, another that maintains customer order history, and a third that feeds into a reporting dashboard. Each projection can use whatever storage mechanism makes sense: relational databases, document stores, or even in-memory caches for frequently accessed data.
Handling Eventual Consistency
One of the trickier aspects of CQRS with Event Sourcing is dealing with eventual consistency. When you save a command, the events are persisted immediately, but the read models get updated asynchronously. This means a user might submit an order and then immediately query for it, only to get a “not found” response if the projection hasn’t run yet.
There are several strategies to handle this. The simplest is to return enough information in the command response that the UI can optimistically update itself without waiting for the read model. When you create an order, return the order ID and key details so the UI can show a confirmation page without querying the read model.
Another approach is to use correlation IDs and polling. When a command completes, return a correlation ID that the client can use to poll for the corresponding read model. Once the projection completes, the read model will be available and the polling succeeds. This adds complexity but provides a consistent user experience.
For truly critical scenarios where you absolutely need read-after-write consistency, you can run specific projections synchronously within the same transaction as the event save. This sacrifices some scalability for consistency guarantees where you really need them.
When to Use CQRS and Event Sourcing
Let’s be clear: CQRS and Event Sourcing add complexity to your application. They’re powerful patterns, but they’re not appropriate for every scenario. Simple CRUD applications with straightforward domain logic don’t benefit much from this architecture. You’d just be adding overhead without meaningful gains.
Consider CQRS when your read and write requirements diverge significantly. If you’re building complex reports that aggregate data from multiple sources while also handling transactional updates with strict business rules, CQRS gives you the flexibility to optimize each side independently. It’s particularly valuable in domains with complex workflows where capturing intent matters, or when you need to scale reads and writes independently.
Event Sourcing shines in domains where audit trails are critical. Financial systems, healthcare applications, and any regulated industry where you need to prove compliance benefit enormously from the complete history that Event Sourcing provides. It’s also valuable when you need temporal queries (the ability to see what your data looked like at any point in the past) or when you’re building event-driven architectures where different parts of your system need to react to state changes.
Don’t use these patterns just because they’re interesting or because you want to learn something new on a production project. They introduce genuine complexity around eventual consistency, event schema evolution, and operational concerns like event store maintenance. Start with the simplest architecture that meets your needs, and evolve toward CQRS and Event Sourcing when you have concrete problems they solve.
Practical Considerations and Challenges
If you decide to implement CQRS and Event Sourcing, be prepared for some operational challenges. Event schema evolution is a real concern. Once you’ve written events to the store, you can’t easily change their structure without affecting your ability to replay them. You’ll need strategies for handling schema changes, like event upcasting where old events are transformed into new formats when read, or maintaining multiple versions of event handlers.
Performance can also be tricky with Event Sourcing. Replaying thousands of events every time you load an aggregate gets expensive. Snapshotting helps here. You periodically save the current state of an aggregate so you only need to replay events since the last snapshot. But snapshotting introduces its own complexity around snapshot storage and invalidation.
Testing becomes more involved because you’re dealing with eventual consistency and asynchronous processes. Your integration tests need to account for the time it takes for projections to process events, and you’ll want comprehensive unit tests for your event handlers to ensure they correctly maintain aggregate state.
You’ll also need robust monitoring and observability tooling to track event processing latency, projection lag, and system health. When projections fall behind or fail, you need to know immediately and have strategies for catching them up or rebuilding them from scratch.
Moving Forward
CQRS and Event Sourcing represent a significant shift in how you think about application architecture. Instead of focusing on current state stored in tables, you think in terms of commands that express intent, events that capture what happened, and projections that build views optimized for specific queries. It’s a more event-driven, message-oriented approach that aligns well with modern distributed systems and microservices architectures.
The patterns work beautifully together, but you can also use them independently. You might implement CQRS without Event Sourcing, using separate read and write models but still persisting current state in a traditional database. Or you might use Event Sourcing for specific aggregates that need audit trails while using conventional persistence for others. The flexibility to mix approaches lets you apply these patterns where they add value without forcing your entire application into one architectural style.
As you explore CQRS and Event Sourcing in your ASP.NET Core applications, start small. Pick a bounded context or module where the benefits are clear, implement the patterns there, and learn from the experience before expanding. The patterns have a learning curve, but they provide powerful tools for managing complexity in sophisticated domains.
Join The Community
Ready to dive deeper into advanced ASP.NET Core patterns and architectures? Subscribe to ASP Today for weekly insights on building better .NET applications, and join our community on Substack Chat to discuss CQRS, Event Sourcing, and other architectural patterns with fellow developers.


