Async projections in CQRS are how you build eventually consistent read models, but the real magic is that they let you decouple your write-side domain logic from your read-side concerns entirely.
Let’s see this in action. Imagine we have a simple e-commerce system.
Write Side (Domain Logic)
public class OrderSaga : Saga<OrderSagaState>
{
public OrderSaga()
{
RegisterHandler<SubmitOrderCommand>(Handle);
}
public void Handle(SubmitOrderCommand command)
{
if (State.OrderId != Guid.Empty) return; // Already processed
State.OrderId = command.OrderId;
State.CustomerId = command.CustomerId;
State.OrderLines = command.OrderLines;
State.OrderPlacedAt = DateTime.UtcNow;
Emit(new OrderSubmittedEvent
{
OrderId = command.OrderId,
CustomerId = command.CustomerId,
OrderLines = command.OrderLines,
OrderPlacedAt = DateTime.UtcNow
});
}
}
Here, SubmitOrderCommand triggers the creation of an OrderSubmittedEvent. This event is our factual record.
Event Store
This OrderSubmittedEvent is stored in an event store. For example, using Marten, this might look like:
[
{
"Id": "b0a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5",
"StreamId": "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5",
"Timestamp": "2023-10-27T10:00:00.123Z",
"Data": {
"$type": "OrderSubmittedEvent",
"OrderId": "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5",
"CustomerId": "customer-123",
"OrderLines": [
{"ProductId": "prod-abc", "Quantity": 2, "UnitPrice": 19.99},
{"ProductId": "prod-xyz", "Quantity": 1, "UnitPrice": 5.50}
],
"OrderPlacedAt": "2023-10-27T10:00:00.123Z"
}
}
]
Async Projection (Read Model Builder)
Now, an asynchronous projection listens to these events and updates a separate read-optimized data store.
public class OrderSummaryProjection :
IProjection,
IProjection<OrderSubmittedEvent>
{
private readonly IDocumentStore _documentStore; // e.g., Marten, Elasticsearch client
public OrderSummaryProjection(IDocumentStore documentStore)
{
_documentStore = documentStore;
}
public async Task ProjectAsync(IDocumentSession session, OrderSubmittedEvent @event)
{
var orderSummary = new OrderSummary
{
Id = @event.OrderId,
CustomerId = @event.CustomerId,
TotalItems = @event.OrderLines.Sum(ol => ol.Quantity),
OrderDate = @event.OrderPlacedAt
};
await session.StoreAsync(orderSummary);
await session.SaveChangesAsync();
}
}
public class OrderSummary
{
public Guid Id { get; set; }
public string CustomerId { get; set; }
public int TotalItems { get; set; }
public DateTime OrderDate { get; set; }
}
When an OrderSubmittedEvent is published, the OrderSummaryProjection picks it up. It then transforms this event data into a denormalized OrderSummary object and stores it in a document database. This OrderSummary is optimized for querying, e.g., "Show me all orders for customer X placed in the last week."
The core problem this solves is the impedance mismatch between your transactional, domain-focused write model and your reporting/UI-focused read models. By storing everything as a sequence of immutable events, you can rebuild any read model, at any time, from the ground up. This also means you can add new read models later without touching your existing write-side logic. The projection simply subscribes to the event stream and builds the new view.
The "eventually consistent" part comes from the fact that there’s a small delay between the write operation (saving the event) and the read model update. If a user immediately queries for an order right after placing it, the OrderSummary might not yet be available in the read store. The system guarantees that eventually, the read model will catch up to the state of the event stream. This is a fundamental trade-off for achieving high write throughput and flexible read model evolution.
What most people don’t realize is that the projection logic itself can be stateful. While the example above is simple, a projection could also be responsible for aggregating data across multiple event types. For instance, a "CustomerOrderHistory" projection might listen to OrderSubmittedEvent, OrderShippedEvent, and OrderCancelledEvent for a specific customer, maintaining a complex view of their interactions over time. The projection system (like Marten’s) handles managing the state of these aggregations across event streams.
The next step is understanding how to handle event schema evolution and ensure your projections can gracefully adapt to changes in your domain events.