Articles · Projections & Read Models ·026
Inline & Multi-Stream Projections
Runnable sample on GitHub
Sample:
samples/026-inline-and-multistream-projections— an order/shipment read side built two ways: an inline read-your-writes summary, and a multi-stream fulfillment view.dotnet test(needs Docker).Prerequisites: 007 — Projections, 005 — Event Sourcing Basics.
Overview
Article 007 built the standard read side: a projection runs in a background host, catches up from a checkpoint after each command commits, and is eventually consistent. That is the right default — it keeps the write path fast and lets the read model fail, lag, and rebuild independently. But it has one property you sometimes cannot live with: immediately after a command, a query against the read model may not yet see the change.
This article covers two refinements that sit alongside the async host, not replacing it:
- Inline (synchronous, in-commit) projections (
IInlineEventProjection). The projection runs inside the command’s transaction, right after the aggregate’s events are appended and before commit, staging its read-model write on the sameDbContext. The row therefore commits atomically with the events, and aSELECTthe moment the command returns sees it — read-your-writes, zero lag, no host. The cost is that the projection now runs on the write path. - The typed
MultiStreamProjectionbase. A convenience base for projections that fold several streams (or several event types) into one read model. Instead of switching on the rawEventData, you register strongly-typedOn<TEvent>handlers; the base maps each handled type to its stable persisted name, deserializes the payload, and routes it to the matching handler. It is an ordinaryIProjection, so the async host of article 007 drives it.
The contrast is the whole point. The async host (007) trades read-after-write consistency for an isolated, resilient, rebuildable read side. An inline projection trades a little write-path latency (and coupling the read model’s correctness to the command) for read-your-writes. Most read models should stay on the host; a select few — the ones a user reads back instantly — earn the inline treatment.
Why this exists
Eventual consistency is usually fine, until the UX makes it not fine. The classic case: a user submits a
form, the command succeeds, the UI navigates to a list — and the thing they just created isn’t there yet,
because the projection host hasn’t caught up the 30ms it needs. Teams paper over this with optimistic UI,
spinners, or by reading the write model for “your own” change. All valid (007 recommends them). But
sometimes the simplest correct answer is: make this one read model update in the same transaction as the
write, so there is nothing to wait for. That is what IInlineEventProjection is.
Separately, hand-written projections that care about more than one or two event types accrete a switch
over serializer.DeserializeEvent(@event) with a case per type — and worse, projections that fold
several aggregate types repeat that boilerplate while the interesting logic (the fold) gets buried.
MultiStreamProjection extracts the dispatch so the projection is just its typed handlers.
When to use this
Inline projection:
- Read-your-writes on a specific view. A user creates/edits something and is immediately shown a list/summary built from that view — and “processing…” is not acceptable. The inline row is there the instant the command returns.
- A small, cheap, single-purpose read model whose update is a trivial upsert. The write-path cost is negligible and the consistency win is real.
- An invariant-style read model you want to fail the command if it can’t be maintained (because it runs in the same transaction, a throwing inline projection rolls the whole command back).
Multi-stream projection:
- A denormalised view that joins several aggregates’ events — an order summary fed by
Order,Shipment, andPaymentstreams, folded into one row the UI renders without N+1 lookups. - Any projection handling more than a couple of event types, where the typed-handler form is simply
clearer than a hand-rolled
switch.
When not to use this
- Inline for most read models. Inline projections run on every command’s write path. A dashboard counter, a search index, an activity feed — none of these need read-your-writes, and putting them inline taxes every write and couples the command’s success to the read model. Keep them on the async host (007).
- Inline for anything slow or fallible. The projection’s latency is added to every matching command, and a failure fails the command. Inline work must be fast and all-but-infallible. Network calls, large recomputations, or anything that can throw on bad-but-valid data belong on the host (which can dead-letter — article 008).
- Inline for cross-aggregate folds that need the global order. An inline projection sees only this command’s just-appended events, in this transaction; it cannot see a sibling stream written by a different command. Folds across streams are the async host’s job (it reads the global log). The multi-stream demo here runs on the host path for exactly this reason.
MultiStreamProjectionwhen one event type and a single stream suffice. A plainIProjectionwith onecaseis fine; the base earns its keep once there are several handlers.
Concepts
Inline projection (IInlineEventProjection). Two members: bool Handles(string eventType) (defaults
to true) to filter event types, and Task ApplyAsync(EventData @event, CancellationToken) to apply one
just-appended event. You stage writes on the shared scoped DbContext (via IRelayDbContextAccessor) and
never call SaveChanges — the unit of work commits the read-model write together with the events.
Register one or more as IInlineEventProjection in DI; when none are registered the command path is
byte-for-byte unchanged.
Where it runs. Inside TransactionExecutor: begin transaction → run handler → dispatch domain events
→ stage snapshots → append events → apply inline projections → commit. The inline apply
happens after the append and before the commit, so its writes land in the same transaction. Because
the global position is allocated at commit, the EventData.Position an inline projection sees is still
0 — inline read models key off domain data (ids in the payload), never position.
Idempotency still applies. A command can be retried, re-presenting the same event in a new transaction. So an inline projection’s apply must be safe to repeat: a guarded insert, an upsert, an idempotent status flip. Same discipline as the async host, same reason.
MultiStreamProjection. An abstract IProjection. You override Name and Configure(), and in
Configure() call On<TEvent>((evt, data, ct) => …) for each event type you handle. Under the hood the
base uses the IEventTypeRegistry to map typeof(TEvent) to its stable persisted name (so Handles
reports those names) and the IEventSerializer to deserialize each delivered event before routing it to
your handler. Events with no registered handler are ignored. Handlers stage read-model writes on the
shared DbContext, exactly like a hand-written projection — no storage shape is imposed, so single-stream
and multi-stream models both work.
“Stream” here means aggregate stream. Each aggregate instance is one stream. A multi-stream projection
folds events from different aggregates (often different aggregate types) into one read model. In the
sample, ShipmentDispatched carries the OrderId it fulfils, so the fulfillment row — keyed by order —
can be advanced by an event from the shipment stream.
Architecture
flowchart TD
subgraph "Command transaction (inline = read-your-writes)"
C[Command] --> EX[TransactionExecutor]
EX -->|append events| ES[(relay_events)]
EX -->|ApplyAsync, same DbContext| IP[OrderSummaryInlineProjection]
IP -->|stage row| RM1[(order_summaries)]
EX -->|COMMIT events + row together| TX{{atomic}}
end
Q1[SELECT right after the command] -->|already there, no lag| RM1
subgraph "Async host (multi-stream = eventual)"
H[ProjectionHostedService] -->|read global log| ES
H -->|ProjectAsync, typed dispatch| MP[FulfillmentProjection : MultiStreamProjection]
MP -->|On<OrderPlaced> / On<ShipmentDispatched> / On<ShipmentDelivered>| RM2[(fulfillments)]
end
Q2[SELECT eventually] --> RM2
style RM1 fill:#e8f5e9
style RM2 fill:#e3f2fd
The inline read model (green) commits with the events; the multi-stream read model (blue) is folded from several streams by the async host and is eventually consistent.
Building it step by step
The sample is
samples/026-inline-and-multistream-projections.
1. An inline projection — stage on the shared DbContext, never SaveChanges
public sealed class OrderSummaryInlineProjection(IRelayDbContextAccessor accessor, IEventSerializer serializer)
: IInlineEventProjection
{
private readonly DbContext _context = (DbContext)accessor.DbContext;
public bool Handles(string eventType)
=> eventType == typeof(OrderPlaced).FullName || eventType == typeof(OrderCancelled).FullName;
public async Task ApplyAsync(EventData @event, CancellationToken ct = default)
{
var orders = _context.Set<OrderSummaryReadModel>();
switch (serializer.DeserializeEvent(@event))
{
case OrderPlaced e: // idempotent guarded insert
if (await orders.FindAsync([e.OrderId], ct) is null)
orders.Add(new() { OrderId = e.OrderId, Customer = e.Customer, Amount = e.Amount, Status = "Placed" });
break;
case OrderCancelled e:
{ var r = await orders.FindAsync([e.OrderId], ct); if (r is not null) r.Status = "Cancelled"; }
break;
}
}
}
2. Register it — that’s the entire wiring
// DI: registering as IInlineEventProjection is all it takes. The TransactionExecutor resolves them with
// GetServices<IInlineEventProjection>() and applies the command's just-appended events inside the commit.
services.AddScoped<IInlineEventProjection, OrderSummaryInlineProjection>();
No host, no checkpoint table. With none registered, the command path is unchanged.
3. A multi-stream projection — typed handlers across two streams
public sealed class FulfillmentProjection(
IRelayDbContextAccessor accessor, IEventSerializer serializer, IEventTypeRegistry registry)
: MultiStreamProjection(serializer, registry)
{
private readonly DbContext _context = (DbContext)accessor.DbContext;
public override string Name => "fulfillment";
protected override void Configure()
{
On<OrderPlaced>(async (e, _, ct) => // ORDER stream → seed the row
{
var rows = _context.Set<FulfillmentReadModel>();
if (await rows.FindAsync([e.OrderId], ct) is null)
rows.Add(new() { OrderId = e.OrderId, Customer = e.Customer, Amount = e.Amount, FulfillmentStatus = "Ordered" });
});
On<ShipmentDispatched>(async (e, _, ct) => // SHIPMENT stream → advance the SAME order row
{
var r = await _context.Set<FulfillmentReadModel>().FindAsync([e.OrderId], ct);
if (r is not null) { r.Carrier = e.Carrier; r.FulfillmentStatus = "Dispatched"; }
});
On<ShipmentDelivered>(async (e, _, ct) =>
{
var r = await _context.Set<FulfillmentReadModel>().FindAsync([e.OrderId], ct);
if (r is not null) r.FulfillmentStatus = "Delivered";
});
}
}
In production, register it with services.AddProjection<FulfillmentProjection>() and
services.AddRelayProjections() so the async host (article 007) drives it. The sample drives it directly
from the test so it needs no checkpoint store.
4. Map the read-model tables
modelBuilder.ApplyRelayEventStore();
modelBuilder.ApplyRelaySnapshots();
modelBuilder.Entity<OrderSummaryReadModel>(e => { e.ToTable("order_summaries"); e.HasKey(r => r.OrderId); });
modelBuilder.Entity<FulfillmentReadModel>(e => { e.ToTable("fulfillments"); e.HasKey(r => r.OrderId); });
The inline read-model table lives in the host’s DbContext, which is what makes its write atomic with the
events. (The inline path needs no ApplyRelayProjectionCheckpoints() — there is no checkpoint; it runs in
the commit.)
Complete source code
| File | Shows |
|---|---|
Domain/Order.cs |
The Order aggregate + OrderPlaced/OrderCancelled |
Domain/Shipment.cs |
A second aggregate/stream + its events (carry OrderId) |
Domain/Commands.cs |
place/cancel order, dispatch/deliver shipment |
ReadModels/OrderSummaryInlineProjection.cs |
IInlineEventProjection + its read model |
ReadModels/FulfillmentProjection.cs |
MultiStreamProjection folding two streams |
ShopDbContext.cs |
event store + snapshots + two read-model tables |
ShopFixture.cs |
Testcontainers Postgres + DI (registers the inline projection) |
InlineProjectionTests.cs |
read-your-writes: assert immediately, no host/WaitUntil |
MultiStreamProjectionTests.cs |
typed dispatch folds two streams into one row |
Running the example
dotnet test samples/026-inline-and-multistream-projections/Projections.Inline.Tests # needs Docker
Testing
The two tests dramatize the contrast.
The inline test asserts with no host, no WaitUntil, and no polling — the read model is already
committed when the command returns:
await Bus.Execute<PlaceOrderCommand, Order>(new PlaceOrderCommand(id, "Ada", 250m), default);
using var scope = _fixture.Services.CreateScope(); // a fresh scope — the row is durable
var row = await scope.ServiceProvider.GetRequiredService<ShopDbContext>()
.OrderSummaries.AsNoTracking().SingleOrDefaultAsync(r => r.OrderId == id);
row!.Status.Should().Be("Placed"); // no lag: it committed with the events
That immediate assertion is the entire guarantee. Compare it with article 007’s WaitUntilAsync poll —
the async host requires the wait; the inline projection forbids the need for one.
The multi-stream test appends events to two aggregates (an order, then its shipment), reads the global
log, routes each event through ProjectAsync (typed dispatch), and asserts the single folded row —
standing in for the host so the sample needs no checkpoint store:
await bus.Execute<PlaceOrderCommand, Order>(new PlaceOrderCommand(orderId, "Lin", 480m), default);
await bus.Execute<DispatchShipmentCommand, Shipment>(new DispatchShipmentCommand(shipmentId, orderId, "DHL"), default);
await bus.Execute<DeliverShipmentCommand, Shipment>(new DeliverShipmentCommand(shipmentId), default);
var events = await store.GetAllEventsAsync(fromPosition: 0, maxCount: null, default);
foreach (var e in events.Where(e => projection.Handles(e.EventType)))
await projection.ProjectAsync(e, default);
await ctx.SaveChangesAsync(default);
var row = await ctx.Fulfillments.AsNoTracking().SingleAsync(r => r.OrderId == orderId);
row.Carrier.Should().Be("DHL"); // from the shipment stream
row.FulfillmentStatus.Should().Be("Delivered"); // also from the shipment stream
row.Amount.Should().Be(480m); // from the order stream
Both are integration tests against real PostgreSQL because the guarantees — atomic event+row commit, and folding the global log across streams — are database behaviors.
Production considerations
- Budget the write-path cost of inline projections. Every matching command now does the projection’s work before it can commit. Keep it to a fast upsert; profile it as part of command latency, not as a background task. If it grows, move it to the host.
- A throwing inline projection fails the command. That is sometimes what you want (an invariant), and sometimes a foot-gun (a transient read-model glitch rejecting a perfectly good write). Decide deliberately, and make inline applies all-but-infallible.
- Inline projections must be idempotent. Command retries re-present events in a new transaction. Guard inserts, make flips idempotent — same rule as the async host.
- Inline sees only this command’s events, position 0. It cannot fold sibling streams (that needs the
global log) and must not key off
Position(allocated at commit). Key off payload ids. - Run multi-stream projections on the host with the coordinator. A
MultiStreamProjectionis an ordinaryIProjection: monitor its lag, addAddRelayProjectionCoordinationPostgres()when running multiple hosts, and rebuild it from history when its shape changes (article 008) — all of 007’s operational guidance applies unchanged. - Mix freely. A service can run the async host for most read models and register a couple of inline projections for the few views that need read-your-writes. They are independent.
Common mistakes
- Calling
SaveChangesin an inline projection. It breaks the atomicity — the read-model write must commit with the events, in the unit of work’s single commit. Stage only. - Putting a slow or fallible projection inline. You have just added its latency to every write and made the command fail when the projection does. Inline is for fast, near-infallible upserts; everything else goes on the host.
- Expecting an inline projection to fold sibling streams. It only sees the current command’s appended
events. Cross-stream folds need the global log — use a
MultiStreamProjectionon the async host. - Keying an inline read model off
EventData.Position. It is0at inline time (position is allocated at commit). Key off the ids in the payload. - Reaching for
MultiStreamProjectionfor one event type. A plainIProjectionwith a singlecaseis simpler; the base pays off with several handlers. - Forgetting idempotency in either projection — retries (inline) and at-least-once delivery + rebuilds (host) both re-present events.
Tradeoffs
Inline projection. Benefit: read-your-writes with zero lag and no host for the views that need it; the read model commits atomically with the events. Cost: runs on every matching command’s write path (latency), couples the command’s success to the read model (a throw rolls the command back), and can’t fold sibling streams. Reach for it for a small number of instantly-read views; leave everything else on the host.
MultiStreamProjection. Benefit: typed On<TEvent> handlers replace a hand-rolled switch, and
folding several aggregate types into one read model is first-class. Cost: essentially none beyond a base
class — it is still an IProjection with the same staging/idempotency contract. Default to it once a
projection handles more than a couple of event types.
Alternatives
- Async host + read-your-writes worked around in the UX (007). Optimistic UI, a “processing…” state, or reading the write model for your own just-made change. Often the right call — it keeps the read side fully decoupled. Inline is for when none of those are acceptable for a specific view.
- Read the write model (the aggregate) for “by id” reads. For the just-changed aggregate, loading and replaying it is already read-your-writes — no projection needed. Inline projections shine for views that aren’t “by id” (a list/summary you read back instantly).
- A database trigger / materialised view. Maintains a derived view of tables synchronously. Works when your source of truth is relational tables, not a JSON event stream with domain logic; an inline projection is the event-sourced equivalent, expressed in C# with your domain types.
- Hand-written multi-event
IProjection(aswitch). Exactly whatMultiStreamProjectionreplaces; fine for one or two cases, boilerplate beyond that.
Lessons from production systems
- The read-after-write bug is a UX bug first. Teams that decide per view how much staleness is acceptable ship cleanly; the ones that assume read-after-write everywhere ship “I saved it but it’s not there” tickets. Inline projections are the surgical fix for the one or two views where the answer is “none.”
- Inline is a scalpel, not a hammer. The systems that regret it made everything inline and watched write latency climb and commands start failing on read-model hiccups. Keep the default async; promote individual views to inline with intent.
- Typed dispatch removes a whole bug class. Hand-rolled
switch (DeserializeEvent(@event))blocks grow a forgottencaseor a wrong cast;On<TEvent>makes the handler’s type the contract and ignores the rest by construction.
Should you use this?
| Situation | Recommendation |
|---|---|
| A specific view a user reads back instantly, no “processing…” allowed | Inline — read-your-writes, atomic with the events |
| Most read models (dashboards, feeds, search, counters) | Async host (007) — keep them off the write path |
| A view that joins several aggregates’/streams’ events | MultiStreamProjection on the host |
| A projection handling several event types | MultiStreamProjection — clearer than a switch |
| A slow or fallible projection | Async host — never inline (it taxes/fails the write) |
| One event type, one stream | Plain IProjection — neither feature needed |
Next steps
- 007 — Projections: the async projection host these techniques sit alongside —
checkpoints, gap-safe catch-up, lag monitoring. Run your
MultiStreamProjectionhere. - 008 — Projection Operations: poison-event handling and rebuilds.
An inline projection can’t dead-letter (it fails the command); a host-driven
MultiStreamProjectioncan — another reason to keep fallible work on the host.