Building synchronous projections in CQRS is how you get strong consistency without a separate eventual consistency phase.
Let’s see it in action. Imagine a simple OrderService that handles PlaceOrder commands. When an order is placed, we want to immediately update an OrderSummary projection so that any subsequent read requests for that order’s status are guaranteed to be up-to-date.
public class OrderService : CommandService<Order>
{
private readonly IEventStore _eventStore;
private readonly IProjectionBus _projectionBus; // For synchronous projections
public OrderService(IEventStore eventStore, IProjectionBus projectionBus)
{
_eventStore = eventStore;
_projectionBus = projectionBus;
}
public async Task Handle(PlaceOrderCommand command)
{
var order = Order.Create(command.OrderId, command.CustomerId, command.Items);
await _eventStore.AppendEventsAsync(order.Events);
// Synchronously dispatch projection events
foreach (var domainEvent in order.Events)
{
await _projectionBus.PublishAsync(domainEvent);
}
}
}
// Projection handler that runs synchronously
public class OrderSummaryProjectionHandler : IProjectionHandler<OrderPlacedEvent>
{
private readonly ISqlDatabase _sqlDatabase; // Or any other read model store
public OrderSummaryProjectionHandler(ISqlDatabase sqlDatabase)
{
_sqlDatabase = sqlDatabase;
}
public async Task Handle(OrderPlacedEvent eventData)
{
var orderSummary = new OrderSummary
{
OrderId = eventData.OrderId,
CustomerId = eventData.CustomerId,
Status = "Placed",
PlacedAt = eventData.Timestamp
};
await _sqlDatabase.SaveAsync(orderSummary);
}
}
Here, when PlaceOrderCommand is handled, the OrderService first appends the OrderPlacedEvent to the event store. Immediately after, it iterates through the generated domain events and publishes them synchronously via an IProjectionBus. This IProjectionBus is configured to directly invoke the relevant projection handlers (like OrderSummaryProjectionHandler) before the PlaceOrder command handler returns. The OrderSummaryProjectionHandler then updates its read model (e.g., a SQL database).
The problem this solves is the inherent latency and potential inconsistency in traditional asynchronous CQRS projections. In asynchronous models, the projection update happens in a separate background process, meaning a read query immediately after a write might return stale data. Synchronous projections eliminate this gap. The write operation (placing an order) is only considered complete after all associated projection updates have also completed successfully. This guarantees that any subsequent read operations targeting the updated data will reflect the latest state.
Internally, the IProjectionBus in this synchronous setup acts as a direct dispatcher. When PublishAsync is called, it doesn’t put a message on a queue. Instead, it looks up the registered handlers for the specific event type and invokes them directly, awaiting their completion. This means the execution flow for a command handler now includes the execution of its corresponding projection handlers. The levers you control are the event handlers and the projection handlers themselves. You define the domain events that signal state changes, and you define the projection handlers that translate these events into a queryable read model. The "synchronous" aspect is an infrastructure choice on how the IProjectionBus is implemented.
The key takeaway is that the AppendEventsAsync and the _projectionBus.PublishAsync calls happen sequentially within the same transaction scope (conceptually, if not always literally in terms of database transactions across different stores). If any synchronous projection handler fails, the entire command operation should ideally fail and be retried, preserving atomicity between the write model persistence and the read model update.
What most people don’t realize is that achieving true ACID-like guarantees across the write and read models in this synchronous setup often requires careful management of transactional boundaries. If your event store and your read model database are separate, you’re likely dealing with distributed transactions or a two-phase commit pattern if you need absolute guarantees that both succeed or both fail. Alternatively, a more common and often sufficient approach is to accept that the command handler’s success is predicated on the event store commit and the synchronous projection’s success. If the projection fails, the command is considered failed, and the system can retry the command, which will re-trigger the event and the projection.
The next conceptual hurdle is managing projections that depend on multiple events or external data for their updates, which can complicate the synchronous execution model.