Projections: Building Read Models with Inline, Async, and Live Projections
A projection is a function that turns events into a read model shaped by a specific question; Inline runs in the same transaction as the events, Async runs in the background with the daemon, and Live is never stored. A stock badge, a monthly stock movement report, and reservations waiting to ship from the Bookstore stock ledger; checkpoints, changing a projection and rebuilding it, dead letters, and a failed rebuild emptying a read model.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
It is the first day of September. The warehouse has stock items for Dune and Emma; copies came in at the end of August, Ada's order was shipped, new copies arrived in September, Alan and Grace made reservations, and one copy of Dune turned out to be damaged. All of this was written as events with the StockCommandHandler from post 06:
DateTimeOffset aug = new(2026, 8, 28, 10, 0, 0, TimeSpan.Zero);
DateTimeOffset sep = new(2026, 9, 1, 9, 0, 0, TimeSpan.Zero);
StockItemId dune = await Register("Dune", 20, aug);
StockItemId emma = await Register("Emma", 8, aug);
OrderId ada = new(Guid.CreateVersion7()), alan = new(Guid.CreateVersion7()), grace = new(Guid.CreateVersion7());
await handler.HandleAsync(new ReserveStock(dune, ada, 2, aug.AddHours(1)));
await handler.HandleAsync(new ShipReservedStock(dune, ada, aug.AddDays(1)));
await handler.HandleAsync(new ReceiveStock(dune, 10, sep));
await handler.HandleAsync(new ReserveStock(dune, alan, 1, sep.AddMinutes(10)));
await handler.HandleAsync(new ReserveStock(emma, alan, 1, sep.AddMinutes(10)));
await handler.HandleAsync(new ShipReservedStock(emma, alan, sep.AddHours(2)));
await handler.HandleAsync(new ReserveStock(emma, grace, 3, sep.AddHours(3)));
await handler.HandleAsync(new AdjustStock(dune, -1, AdjustmentReason.Damaged, sep.AddHours(4)));
Right after that we look at two read models: the "how many in stock" badge on the catalog page and the warehouse manager's monthly stock movement report.
await using (IQuerySession query = store.QuerySession())
{
StockLevel? duneLevel = await query.LoadAsync<StockLevel>(dune.Value);
int monthlyRows = await query.Query<MonthlyStockMovement>().CountAsync();
Console.WriteLine($"Inline StockLevel(Dune): OnHand={duneLevel!.OnHand} Reserved={duneLevel.Reserved} Available={duneLevel.Available}");
Console.WriteLine($"Async MonthlyStockMovement rows before daemon: {monthlyRows}");
}
What do the two lines print? Does the badge show the right number? How many rows does the monthly report have? The events are the same and the queries go to the same database; the only thing that decides the answer is the lifecycle these read models were registered with.
Projection: A Read Model Shaped by a Question
In post 06 we built state from a single stream: AggregateStreamAsync ran every event of the stream through Evolve in order. A projection is the generalized form of the same idea: a function that takes events and turns them into a document (or a table) designed for reading. In post 03 of this CQRS (Command Query Responsibility Segregation) series we fed a read model from domain events; with event sourcing the situation flips, because events are the single source of truth, every read model is a projection and can be regenerated from scratch whenever you want.
Marten has two main kinds. A single-stream projection produces one document from one stream; the document's id is the stream id. A multi-stream projection groups events by a key you choose: "stock item and month", "order", "book category", and so on. The Bookstore stock ledger has three questions, and each maps to a separate read model:

Inline: In the Same Transaction as the Events
The catalog badge looks at one stock item and must always be current: when a customer sees "1 in stock" and adds it to the cart, the number has to be real. This is a single-stream projection, and it runs Inline:
// The "3 in stock" badge on the catalog page: single stream, Inline
public sealed record StockLevel(Guid Id, int OnHand, int Reserved)
{
public int Available => OnHand - Reserved;
}
public sealed class StockLevelProjection : SingleStreamProjection<StockLevel, Guid>
{
public override StockLevel Evolve(StockLevel? level, Guid id, IEvent e)
{
level ??= new StockLevel(id, 0, 0);
return e.Data switch
{
StockReceived r => level with { OnHand = level.OnHand + r.Quantity },
StockReserved r => level with { Reserved = level.Reserved + r.Quantity },
ReservationReleased r => level with { Reserved = level.Reserved - r.Quantity },
ReservedStockShipped s => level with { OnHand = level.OnHand - s.Quantity, Reserved = level.Reserved - s.Quantity },
StockAdjusted a => level with { OnHand = level.OnHand + a.Delta },
_ => level
};
}
}
We registered all four projections with the store, each with its lifecycle:
await using DocumentStore store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.DatabaseSchemaName = "inventory";
// Write model: not stored anywhere, built from the events on every command
opts.Projections.Add(new StockItemProjection(), ProjectionLifecycle.Live);
// Updated in the same transaction as the events
opts.Projections.Add(new StockLevelProjection(), ProjectionLifecycle.Inline);
// The async daemon updates these in the background
opts.Projections.Add(new MonthlyStockMovementProjection(), ProjectionLifecycle.Async);
opts.Projections.Add(new PendingReservationProjection(), ProjectionLifecycle.Async);
});
An Inline projection runs inside the SaveChangesAsync call, in the same PostgreSQL transaction where the events are appended. If the events were committed, the read model was updated too; there is no window in between. This has two costs. First, every command takes a bit longer: a document is written next to the event. The second matters more: an exception in the projection rolls back the transaction, so a bug in a read model brings down the command itself. You do not want to stop taking orders because "there is a bug in the report table". Keep Inline for a small number of simple projections that look at a single stream.
Async: In the Background with the Daemon
The monthly report groups several event types by stock item and month. The key comes from the event's business time (At), not from the moment Marten recorded the event (Timestamp): a shipment that happened on the last day of August but was entered into the system after midnight belongs to August.
// Monthly stock movement: one document per stock item and month, Async
public sealed record MonthlyStockMovement(string Id, Guid StockItemId, string Month, int Received, int Shipped, int Adjusted);
public sealed class MonthlyStockMovementProjection : MultiStreamProjection<MonthlyStockMovement, string>
{
public MonthlyStockMovementProjection()
{
Identity<StockReceived>(e => Key(e));
Identity<ReservedStockShipped>(e => Key(e));
Identity<StockAdjusted>(e => Key(e));
}
// The month comes from the event's business time (At), not from when it was recorded
private static string Key(StockEvent e) => $"{e.StockItemId.Value}:{e.At:yyyy-MM}";
public override MonthlyStockMovement Evolve(MonthlyStockMovement? row, string id, IEvent e)
{
StockEvent stockEvent = (StockEvent)e.Data;
row ??= new MonthlyStockMovement(id, stockEvent.StockItemId.Value, $"{stockEvent.At:yyyy-MM}", 0, 0, 0);
return e.Data switch
{
StockReceived r => row with { Received = row.Received + r.Quantity },
ReservedStockShipped s => row with { Shipped = row.Shipped + s.Quantity },
StockAdjusted a => row with { Adjusted = row.Adjusted + a.Delta },
_ => row
};
}
}
The reservations waiting to be shipped are one document per order. A line is added when a reservation arrives and removed when a shipment or a release arrives; when no lines are left, Evolve returns null and Marten deletes the document:
// Reservations waiting to be shipped: one document per order, Async
public sealed record PendingLine(Guid StockItemId, int Quantity, DateTimeOffset ReservedAt);
public sealed record PendingReservation(Guid Id, ImmutableList<PendingLine> Lines)
{
public DateTimeOffset OldestReservedAt => Lines.Min(l => l.ReservedAt);
}
public sealed class PendingReservationProjection : MultiStreamProjection<PendingReservation, Guid>
{
public PendingReservationProjection()
{
Identity<StockReserved>(e => e.OrderId.Value);
Identity<ReservationReleased>(e => e.OrderId.Value);
Identity<ReservedStockShipped>(e => e.OrderId.Value);
}
public override PendingReservation? Evolve(PendingReservation? pending, Guid orderId, IEvent e)
{
switch (e.Data)
{
case StockReserved r:
pending ??= new PendingReservation(orderId, []);
return pending with { Lines = pending.Lines.Add(new PendingLine(r.StockItemId.Value, r.Quantity, r.At)) };
case ReservationReleased or ReservedStockShipped:
if (pending is null)
throw new InvalidOperationException($"No pending reservation for order {orderId}");
Guid stockItemId = ((StockEvent)e.Data).StockItemId.Value;
ImmutableList<PendingLine> rest = pending.Lines.RemoveAll(l => l.StockItemId == stockItemId);
// returning null deletes the document
return rest.IsEmpty ? null : pending with { Lines = rest };
default:
return pending;
}
}
}
These two projections are Async. Marten does not touch them when commands run; the async daemon, a background process, reads mt_events in seq_id order and feeds the projections. Here is the answer to the opening question:
Warning: The async daemon is disabled.
Projections Bookstore.Inventory.ReadModels.MonthlyStockMovementProjection, Bookstore.Inventory.ReadModels.PendingReservationProjection will not be executed without the async daemon enabled
Inline StockLevel(Dune): OnHand=27 Reserved=1 Available=26
Async MonthlyStockMovement rows before daemon: 0
The badge is right: 20 + 10 received, 2 shipped, 1 damaged, Alan's 1 reservation. The monthly report has no rows at all. Marten even warns up front that the daemon is disabled. In a console app we start the daemon ourselves:
using IProjectionDaemon daemon = await store.BuildProjectionDaemonAsync();
await daemon.StartAllAsync();
await daemon.WaitForNonStaleData(TimeSpan.FromSeconds(30));
Dune 2026-08 received=20 shipped=2 adjusted=0
Dune 2026-09 received=10 shipped=0 adjusted=-1
Emma 2026-08 received= 8 shipped=0 adjusted=0
Emma 2026-09 received= 0 shipped=1 adjusted=0
Pending order Alan: Dune x1, waiting 290 min
Pending order Grace: Emma x3, waiting 120 min
In an ASP.NET Core application you do this with builder.Services.AddMarten(...).AddAsyncDaemon(DaemonMode.HotCold). In HotCold mode, even when several instances run, each projection runs in a single process chosen with a PostgreSQL advisory lock; Solo assumes a single instance. WaitForNonStaleData is for tests and demos; we discussed how to live with eventual consistency in front of users in post 03. Marten's QueryForNonStaleData<T>(timeout) query lets you wait for a read model to catch up with the events of a given moment when needed, but if you do this on every query, that projection really wants to be Inline.
The pending reservations read model also feeds a workflow. By the rule from post 05, reservations expire after 30 minutes; the background job that runs the ReleaseExpiredReservations command has to answer "which orders are older than 30 minutes?" without scanning every stream. Alan's reservation has been waiting for 290 minutes; the job finds it in this table, and the decision is still made by the decider, on the stock item's own stream.
Live: Not Storing It at All
The third lifecycle is familiar from post 06: StockItemProjection is Live. It is not stored anywhere; it is built from the events every time FetchForWriting and AggregateStreamAsync are called. That is the natural choice for a write model, because a command's decision must always be made on the latest events, and the read is limited to a single stream anyway. You rarely pick Live for a read model: for a screen a manager opens once a month and that looks at a single stream, keeping a separate table may not be worth it. The measurement in post 06 shows the limit too: the longer the stream, the more expensive every read.
Checkpoints and Rebuilds
The daemon keeps track of where it is for each projection in the mt_event_progression table:
await using NpgsqlDataSource dataSource = NpgsqlDataSource.Create(connectionString);
await using NpgsqlCommand command = dataSource.CreateCommand(
"SELECT name, last_seq_id FROM inventory.mt_event_progression ORDER BY name");
await using NpgsqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine($"{reader.GetString(0),-34} {reader.GetInt64(1)}");
HighWaterAllocationFence 12
HighWaterMark 12
MonthlyStockMovement:All 12
PendingReservation:All 12
HighWaterMark is the highest seq_id for which the daemon says "there are no gaps before this, it is safe to process". Marten solves the commit order problem we hit with our own table in post 06 with this mark. Each projection's row is the last event that projection processed. When the process restarts the daemon continues from here; when you want to rebuild a projection, you start it from zero.
The real value of a rebuild is being able to change a read model's design later. A month later the warehouse manager comes back: "Seeing adjustments as a single number does not help me; separate count corrections from losses and damage." With a relational report table that would mean guessing the data for past months. Here the whole history is in the events. The new version of the projection:
// v2: split adjustments into count corrections and losses/damage
public sealed record MonthlyStockMovement(
string Id, Guid StockItemId, string Month, int Received, int Shipped, int Corrected, int Lost);
public sealed class MonthlyStockMovementProjection : MultiStreamProjection<MonthlyStockMovement, string>
{
public MonthlyStockMovementProjection()
{
Identity<StockReceived>(e => Key(e));
Identity<ReservedStockShipped>(e => Key(e));
Identity<StockAdjusted>(e => Key(e));
}
private static string Key(StockEvent e) => $"{e.StockItemId.Value}:{e.At:yyyy-MM}";
public override MonthlyStockMovement Evolve(MonthlyStockMovement? row, string id, IEvent e)
{
StockEvent stockEvent = (StockEvent)e.Data;
row ??= new MonthlyStockMovement(id, stockEvent.StockItemId.Value, $"{stockEvent.At:yyyy-MM}", 0, 0, 0, 0);
return e.Data switch
{
StockReceived r => row with { Received = row.Received + r.Quantity },
ReservedStockShipped s => row with { Shipped = row.Shipped + s.Quantity },
StockAdjusted { Reason: AdjustmentReason.CountCorrection } a => row with { Corrected = row.Corrected + a.Delta },
StockAdjusted a => row with { Lost = row.Lost - a.Delta },
_ => row
};
}
}
We deploy the new code and run a rebuild:
using IProjectionDaemon daemon = await store.BuildProjectionDaemonAsync();
await daemon.RebuildProjectionAsync<MonthlyStockMovementProjection>(CancellationToken.None);
Before rebuild (old documents, new code):
Dune 2026-08 received=20 shipped=2 corrected=0 lost=0
Dune 2026-09 received=10 shipped=0 corrected=0 lost=0
Emma 2026-08 received= 8 shipped=0 corrected=0 lost=0
Emma 2026-09 received= 0 shipped=1 corrected=0 lost=0
After rebuild:
Dune 2026-08 received=20 shipped=2 corrected=0 lost=0
Dune 2026-09 received=10 shipped=0 corrected=0 lost=1
Emma 2026-08 received= 8 shipped=0 corrected=0 lost=0
Emma 2026-09 received= 0 shipped=1 corrected=0 lost=0
Look at the first four rows: before the rebuild, the new code reads the old documents and the new fields show up as zero. That mismatch between code and read model shows users a wrong report until the rebuild finishes. Marten's recommended way around it is blue/green: you increment ProjectionVersion on the projection class, the new version writes to separate tables and catches up in the background while the old version keeps serving traffic; once it has caught up, you switch traffic to the new version.
Common Pitfalls
Calling external services from a projection
It is tempting to add a call like "email the warehouse when a reservation arrives" or "fetch the book's price from the Catalog API (Application Programming Interface)" to a projection. There are two problems. A rebuild replays the whole history; a projection that sends email will resend hundreds of old emails that day. If the external service is slow or down, the projection's progress is tied to it, and the rebuild time is multiplied by the service's response time. A projection should stay pure: a function from events to a read model. If something has to be told to the outside world, that is the job of a separate subscription or an outbox, the subject of the upcoming Messaging and Event-Driven Architecture series. Marten's RaiseSideEffects hook does not run during rebuilds for exactly this reason.
A projection error stopping the daemon and the read model
While importing data from the old system, a shipment event without a reservation was added to a stream. PendingReservationProjection throws in that situation:
// A record imported from the old system: a shipment without a reservation
StockItemId hobbit = await Register("The Hobbit", 5, DateTimeOffset.UtcNow);
await using (IDocumentSession session = store.LightweightSession())
{
session.Events.Append(hobbit.Value,
new ReservedStockShipped(hobbit, new OrderId(Guid.CreateVersion7()), 1, DateTimeOffset.UtcNow));
await session.SaveChangesAsync();
}
using IProjectionDaemon daemon = await store.BuildProjectionDaemonAsync();
await daemon.StartAllAsync();
await daemon.WaitForNonStaleData(TimeSpan.FromSeconds(30));
await daemon.StopAllAsync();
await using (IQuerySession query = store.QuerySession())
{
IReadOnlyList<DeadLetterEvent> dead = await query.Query<DeadLetterEvent>().ToListAsync();
foreach (DeadLetterEvent d in dead)
Console.WriteLine($"Dead letter: {d.ProjectionName} seq={d.EventSequence} {d.ExceptionType}");
Console.WriteLine($"Pending documents before rebuild: {await query.Query<PendingReservation>().CountAsync()}");
}
try
{
await daemon.RebuildProjectionAsync<PendingReservationProjection>(CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"Rebuild failed: {ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
await using (IQuerySession query = store.QuerySession())
Console.WriteLine($"Pending documents after failed rebuild: {await query.Query<PendingReservation>().CountAsync()}");
Dead letter: PendingReservation seq=15 InvalidOperationException
Pending documents before rebuild: 2
Rebuild failed: ApplyEventException: Failure to apply event #15 Id(457a6300-9b89-4e3a-81ed-c71fed0b4a0a)
Pending documents after failed rebuild: 0
HighWaterAllocationFence 15
HighWaterMark 15
MonthlyStockMovement:All 15
In continuous processing Marten's default is to skip the failing event (SkipApplyErrors = true): the event was written to the mt_doc_deadletterevent table along with its exception, and the daemon moved on. In a rebuild the default is the opposite (RebuildErrors.SkipApplyErrors = false), and you can see the result in the last lines of the output: the rebuild stopped, the read model was left empty, and PendingReservation's checkpoint row is gone too. A team that starts a rebuild in the morning can be greeted by a "no pending orders" screen at noon. There are two lessons: monitor the dead letter table and clear all of it before a rebuild; and if you can handle an unexpected situation in projection code visibly instead of throwing (for example with a separate "anomaly" field), do so.
Not accounting for rebuild time
On the same machine, with PostgreSQL in Docker, I rebuilt the monthly report projection at two different sizes:
Rebuilt MonthlyStockMovement from 100,000 events in 2.6 s
Rebuilt MonthlyStockMovement from 400,000 events in 29.5 s
In the second run each of 1,000 streams carries 400 events and 14,000 documents are produced; the time is not proportional to the event count, the number of documents and the events per stream matter as well. Bookstore takes ~800 orders a day; if each order produces a few stock events, millions of events pile up in a year. Before putting a rebuild into a deployment step, measure it on production-sized data. Since Marten 9.13, MaxConcurrentRebuildsPerDatabase also caps how many rebuilds run at once; on large stores look at this setting so you do not exhaust the connection pool.
Making every read model Inline
Making everything Inline "so we do not have to deal with eventual consistency" slows commands down and moves the error above into the command. Running multi-stream projections Inline is risky for another reason as well: two commands writing to two different streams update the same month's document at the same time. Marten's documentation also recommends almost always running multi-stream projections Async.
Which One When?
| Lifecycle | Consistency | Effect on commands | Where it fits |
|---|---|---|---|
| Inline | At the same time as the event | Slows them down, its error fails the command | Single stream, simple, screens that must always be current |
| Async | Eventual (daemon lag) | None | Multi-stream reports, lists, search, heavy computation |
| Live | Current on every read | None (reads are expensive) | Write models, rarely opened single-stream screens |
A Small Experiment
★☆☆ Switch StockLevelProjection to Async and run the opening program again. What does the badge show? How would you explain it to the user (pick one of the strategies from post 03)?
★★☆ Add a ShippedAfterMinutes list to PendingReservationProjection that records how many minutes an order waited when its shipment arrives, and rebuild the projection. Do values appear for past orders too? Why?
★★★ Add the poison event on top of the opening demo data, and this time fix the dead-lettered event before the rebuild: make the projection write an "anomaly" row instead of throwing for a shipment without a reservation. Show that the rebuild finishes successfully and the pending orders come back.
In this post we produced as many read models as we wanted from events and changed their design with rebuilds. But we did not change the events themselves. In the next post we move to that harder question: when the shape of an event type has to change, how do we do it without breaking events written years ago, and how do we keep growing streams short with snapshots?