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 same DbContext. The row therefore commits atomically with the events, and a SELECT the 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 MultiStreamProjection base. A convenience base for projections that fold several streams (or several event types) into one read model. Instead of switching on the raw EventData, you register strongly-typed On<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 ordinary IProjection, 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, and Payment streams, 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.
  • MultiStreamProjection when one event type and a single stream suffice. A plain IProjection with one case is 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 eventsapply inline projectionscommit. 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&lt;OrderPlaced&gt; / On&lt;ShipmentDispatched&gt; / On&lt;ShipmentDelivered&gt;| 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 MultiStreamProjection is an ordinary IProjection: monitor its lag, add AddRelayProjectionCoordinationPostgres() 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 SaveChanges in 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 MultiStreamProjection on the async host.
  • Keying an inline read model off EventData.Position. It is 0 at inline time (position is allocated at commit). Key off the ids in the payload.
  • Reaching for MultiStreamProjection for one event type. A plain IProjection with a single case is 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 (a switch). Exactly what MultiStreamProjection replaces; 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 forgotten case or 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 MultiStreamProjection here.
  • 008 — Projection Operations: poison-event handling and rebuilds. An inline projection can’t dead-letter (it fails the command); a host-driven MultiStreamProjection can — another reason to keep fallible work on the host.