Articles · Projections & Read Models ·007

Projections

Runnable sample on GitHub

Sample: samples/007-projections — a balance read model built from the ledger’s events. dotnet test (needs Docker).

Prerequisite: 005 — Event Sourcing Basics.

Overview

Event sourcing stores what happened, which is perfect for the write side and useless for the read side. “Show me all accounts over $1000,” “render the dashboard,” “the activity feed” — none of these can be answered by replaying one aggregate’s events, and the event store has no index for them. A projection solves this: it subscribes to the event stream and maintains a read model — a flat, query-optimised table shaped for exactly the queries you need. The events remain the source of truth; the read model is a derived, disposable view you can rebuild at will.

This is the “Q” of CQRS made concrete. The write side appends events; the read side projects them into whatever shapes the queries demand. This article builds a balance read model from the ledger and runs the projection host that keeps it current.

Why this exists

The event store answers exactly one kind of question well: “give me the event stream for this aggregate id.” That is all the write side needs (load by id, replay, decide). But applications are mostly reads, and reads are mostly across aggregates: lists, filters, aggregations, joins, search. None of those map onto “replay one stream.” If you try to serve them from the event store you will either (a) replay every aggregate on every query — catastrophically slow — or (b) bolt indexes onto the event log, turning your append-only source of truth into a contended query database and losing the properties that made event sourcing worth it.

Projections decouple the two. The read model is a normal table (or document, or cache) shaped for its queries — a balance column you can index, a denormalised activity row you can paginate, a pre-aggregated dashboard counter. Because it is derived from the events, you can have as many read models as you have query shapes, evolve each independently, and rebuild any of them from full history when its shape needs to change — without touching the write side or migrating data. The read model is a cache of a question; the events are the answer key.

When to use this

Use a projection whenever you have an event-sourced (or event-driven) write side and need to query it in any shape other than “by aggregate id”:

  • Dashboards & lists. “All open orders,” “accounts by balance,” “today’s payments” — a read model indexed for the query, kept current by a projection.
  • Denormalised views. A read model that joins data from several aggregates’ events into one row the UI can render without N+1 lookups (an order summary with customer name and line totals).
  • Activity feeds / audit views. A chronological, paginated projection of events into a human-readable feed — trivial when events are your data model.
  • Aggregations & counters. Pre-computed totals (revenue today, items in stock) maintained incrementally as events arrive, so the dashboard query is a single indexed read.
  • Search indexes / caches. A projection can just as well write to Elasticsearch or Redis as to a SQL table — anywhere a query-optimised copy of the data belongs.

The signal: any time you find yourself wanting to query event-sourced data and “by id” isn’t enough, a projection is the answer. There is no other correct way to query the read side.

When not to use this

  • You aren’t event-sourcing (or event-driving) the write side. Projections consume an event stream. If your write model is a CRUD table, just query the table — projections would be machinery with no events to project. (This is why articles 001–004 had no projections.)
  • A single model genuinely serves reads and writes. For simple domains, “CQRS-lite” — one model used for both — is legitimate and simpler. Don’t build a projection layer to immediately query it back into the same shape you wrote. Add projections when read and write shapes diverge.
  • The query is “by aggregate id.” That is the repository’s job (load and replay). A projection for single-aggregate-by-id reads is redundant.

The costs are real and worth stating: projections introduce eventual consistency (the read model lags the write — usually milliseconds, but non-zero, and your UX must tolerate it); they are operational infrastructure (a host to run, checkpoints to track, lag to monitor, rebuilds to perform); and they demand idempotent, carefully-designed handlers. For a domain that doesn’t need divergent read shapes, that is overhead.

Concepts

Read model. A table (or other store) shaped for queries — AccountBalanceReadModel { Id, Owner, Balance }, indexed however the queries want. It is derived: nothing writes to it except its projection, and it can be dropped and rebuilt from events.

Projection. A class implementing IProjection: a stable Name (its checkpoint key), Handles(string eventType) to filter which events it cares about, and ProjectAsync(EventData @event) to apply one event to the read model. Writes are staged on the shared DbContext; the projection never calls SaveChanges — the host commits.

Checkpoint. Each projection records how far through the log it has processed, as a gap-safe (TxId, Position) cursor. The host reads forward from the checkpoint, applies a batch, and commits the read-model writes and the checkpoint advance in one transaction — so the checkpoint can never get ahead of the data. A crash leaves the checkpoint behind, never ahead, so the worst case is re-applying a batch (hence idempotency).

The projection host. ProjectionHostedService is a background service that drives every registered projection forward: read forward from each checkpoint, apply, commit, repeat — waking immediately on new events (via LISTEN/NOTIFY) and polling lazily otherwise. Each projection catches up independently, in its own scope and transaction.

Gap-safe catch-up. Because a late-committing transaction can insert an event with a lower global position than an already-committed one, checkpointing on position alone could skip events. Relay orders the log by (tx_id, position) against a visibility horizon so catch-up never skips. You don’t configure this; it is why projections are reliable under concurrent writes.

Architecture

graph LR
    subgraph "Write side"
        C[Commands] --> ES[(relay_events)]
    end
    subgraph "Projection host (background)"
        H[ProjectionHostedService] -->|read forward from checkpoint| ES
        H -->|apply batch| P[AccountBalanceProjection]
        P -->|stage writes| RM[(account_balances)]
        H -->|advance checkpoint| CP[(relay_projection_checkpoints)]
    end
    Q[Queries / dashboards] -->|SELECT| RM
    style RM fill:#e8f5e9
    style CP fill:#fff3e0

The read-model write and the checkpoint advance commit together (green + orange in one transaction). Queries hit the read model, never the event store.

Building it step by step

The sample is samples/007-projections.

1. Define the read model

public class AccountBalanceReadModel
{
    public Guid Id { get; set; }
    public string Owner { get; set; } = string.Empty;
    public decimal Balance { get; set; }
}

2. Write the projection — idempotent, no SaveChanges

public sealed class AccountBalanceProjection(IRelayDbContextAccessor accessor, IEventSerializer serializer)
    : IProjection
{
    private readonly DbContext _context = (DbContext)accessor.DbContext;
    public string Name => "account-balance";
    public bool Handles(string eventType)
        => eventType == typeof(AccountOpened).FullName
        || eventType == typeof(MoneyDeposited).FullName
        || eventType == typeof(MoneyWithdrawn).FullName;

    public async Task ProjectAsync(EventData @event, CancellationToken ct = default)
    {
        var accounts = _context.Set<AccountBalanceReadModel>();
        switch (serializer.DeserializeEvent(@event))
        {
            case AccountOpened e:
                if (await accounts.FindAsync([e.AccountId], ct) is null)
                    accounts.Add(new() { Id = e.AccountId, Owner = e.Owner });
                break;
            case MoneyDeposited e:
                { var r = await accounts.FindAsync([e.AccountId], ct); if (r is not null) r.Balance += e.Amount; } break;
            case MoneyWithdrawn e:
                { var r = await accounts.FindAsync([e.AccountId], ct); if (r is not null) r.Balance -= e.Amount; } break;
        }
    }
}

The insert is guarded so re-presenting AccountOpened is harmless. The increments rely on the host’s exactly-once-per-commit guarantee: a failed batch rolls the increment back and the checkpoint, so an event is never double-applied (article 008 covers what happens when an event poisons the projection). Stage writes only — the host owns the commit.

Use FindAsync, not a LINQ query, for read-modify-write inside a projection. The host applies a whole batch of events and calls SaveChanges once at the end, so AccountOpened, MoneyDeposited and MoneyWithdrawn for one account are all staged on the same DbContext before anything is committed. FindAsync resolves against EF Core’s change tracker first, so it sees the row inserted by AccountOpened earlier in the same batch. A FirstOrDefaultAsync/Where query goes to the database, which doesn’t have that row yet — so the deposit and withdrawal would find nothing and the balance would silently stay 0. This is a real bug this sample’s tests caught.

3. Map the checkpoint tables and register the read side

// DbContext
modelBuilder.ApplyRelayEventStore();
modelBuilder.ApplyRelaySnapshots();
modelBuilder.ApplyRelayProjectionCheckpoints();          // checkpoints + dead-letters + failures
modelBuilder.Entity<AccountBalanceReadModel>(e => { e.ToTable("account_balances"); e.HasKey(r => r.Id); });

// DI (production)
services.AddRelayProjectionCheckpointsEfCore<LedgerDbContext>(); // durable checkpoint/failure stores
services.AddRelayProjections();                                   // the host + failure cache + manager
services.AddProjection<AccountBalanceProjection>();               // register each projection

In production AddRelayProjections() registers the host as a hosted service that runs for the app’s lifetime. The sample constructs the host by hand so its tests can start and stop it deterministically — the only difference is who presses “go.”

Complete source code

File Contents
Projections/AccountBalanceProjection.cs The read model + the IProjection
LedgerDbContext.cs ApplyRelayProjectionCheckpoints() + the read-model table
LedgerFixture.cs Read-side DI wiring
ProjectionTests.cs Runs commands, starts the host, waits for catch-up

Running the example

dotnet test samples/007-projections/Ledger.Projections.Tests   # needs Docker

Testing

Projections are eventually consistent, so the test waits for the read model to catch up rather than asserting immediately — exactly how a real consumer must think about it:

await bus.Execute<OpenAccountCommand, Account>(new OpenAccountCommand(id, "Ada"), default);
await bus.Execute<DepositCommand, Account>(new DepositCommand(id, 100m), default);
await bus.Execute<WithdrawCommand, Account>(new WithdrawCommand(id, 30m), default);

var host = CreateHost();
await host.StartAsync(default);
var projected = await LedgerFixture.WaitUntilAsync(async () =>
{
    using var scope = _fixture.Services.CreateScope();
    var row = await scope.ServiceProvider.GetRequiredService<LedgerDbContext>()
        .AccountBalances.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
    return row is { Balance: 70m };
}, TimeSpan.FromSeconds(60));
projected.Should().BeTrue();

This is an integration test against real PostgreSQL because the guarantees being verified — gap-safe catch-up, atomic checkpoint+write — are database behaviors. The WaitUntilAsync poll is not a test smell; it is the honest acknowledgement that the read side is asynchronous.

Production considerations

  • Monitor projection lag. The read model trails the write; usually by milliseconds, but a slow projection or a backlog can stretch that. Relay exposes a lag gauge and a health check (ProjectionLagMonitor, ProjectionLagHealthCheck); alert on it, because a silently-lagging read model shows users stale data.
  • One read model per query shape — denormalise freely. Don’t force one read model to serve every query. A dashboard counter, an account-list row, and a search document are three projections off the same events. Storage is cheap; query latency is not.
  • Idempotency is non-negotiable. Design every projection so re-applying an event is harmless (upsert, guarded insert, or — for increments — rely on the atomic checkpoint and the poison handling in article 008). At-least-once is the contract.
  • Scale out with the Postgres coordinator. Running multiple instances of the host? Add AddRelayProjectionCoordinationPostgres() so an advisory lock gives each projection a single active worker (active/active with per-projection mutual exclusion). Without it, multiple hosts would race the same checkpoint.
  • Rebuilds are a first-class operation. When a read model’s shape must change, you reproject from history rather than migrating data. Article 008 covers in-place and zero-downtime (shadow) rebuilds — design read models knowing they are disposable and rebuildable.
  • Keep projections free of side effects. A projection writes a read model. It must not call other services, send emails, or do I/O that can’t be safely repeated on a rebuild — a rebuild replays all history, so a projection that sent an email per event would spam on every rebuild. Side effects belong in integration-event handlers (articles 011–014), not projections.

Common mistakes

  • Querying the event store for reads. The cardinal error. The event store serves “by id”; everything else is a projection. Indexing the event log to query it turns your source of truth into a contended query DB.
  • Non-idempotent projections. A blind Add on AccountOpened (no guard) double-inserts on re-delivery; a non-atomic increment double-counts. Make every apply safe to repeat.
  • Calling SaveChanges inside a projection. The host commits the batch and the checkpoint together; a projection that saves on its own breaks that atomicity and can advance state without the checkpoint (or vice versa). Stage only.
  • Coupling the read model to the write model. Reusing the aggregate as the read model defeats the purpose — you re-introduce the “one model for everything” problem CQRS exists to solve. The read model is shaped for queries, independently.
  • Side effects in projections. Sending an email or calling an API from ProjectAsync means a rebuild re-sends/re-calls for every historical event. Projections build read models; nothing else.
  • Assuming read-after-write consistency. Writing a command and immediately querying the read model may see stale data. Design the UX for eventual consistency (optimistic UI, “processing…”, or read the write model for the just-changed aggregate).

Tradeoffs

Benefits. Query the event-sourced write side in any shape, fast; as many independent read models as you have query shapes; rebuild any of them from history when their shape changes, with no data migration; and a clean separation that lets read and write evolve and scale independently.

Costs. Eventual consistency (and the UX work to handle it); operational infrastructure (a host, checkpoints, lag monitoring, rebuilds); and the discipline of idempotent, side-effect-free projections.

Alternatives

  • Query the write model directly (CRUD / CQRS-lite). If you aren’t event-sourcing, or read and write shapes don’t diverge, just query your tables. Simplest; loses nothing you needed. The right choice until shapes diverge.
  • Database materialised views. The database maintains a derived view of tables. Useful when your source of truth is relational tables (not events) and the view logic is expressible in SQL. It can’t project a JSON event stream with domain logic, and refresh strategies are coarse — but for table-based sources it is a legitimate, lower-code option.
  • Read replicas. Scale read throughput of the same schema. Orthogonal to projections: replicas give you more copies of one shape; projections give you different shapes. Often used together.
  • On-the-fly aggregation at query time. Compute the answer from events per request. Fine for rare, small queries; collapses under load or large streams. Projections trade write-time work for fast reads — the right trade when reads dominate, which they almost always do.

Objectively: projections are the way to read an event-sourced system. The alternatives apply when your source of truth isn’t events, or when read and write shapes are the same.

Lessons from production systems

  • Read-side lag is a product decision, not just an ops metric. The teams that succeed decide, per view, how much staleness is acceptable and design the UX for it (optimistic updates, spinners, reading the write model for your own just-made change). The ones that struggle assume read-after-write consistency and ship confusing “I saved it but it’s not there” bugs.
  • Projections multiply, and that’s healthy. Mature event-sourced systems have many small, single-purpose read models, not one giant one. Resist the urge to make one read model do everything; add a focused projection per query and let rebuilds keep them honest.
  • The rebuild capability is what makes the read side fearless. Because any read model can be rebuilt from events, teams change read shapes freely — something impossible when the read model is the source of truth. Invest in making rebuilds routine (article 008); it pays back every time requirements change.
  • Side effects in projections cause the worst rebuild incidents. The classic disaster is a projection that sent notifications; someone rebuilds it and re-sends two years of emails. Keep projections pure; put side effects on the integration-event path where delivery is once, not per-replay.

Should you use this?

Situation Recommendation
Event-sourced write side, queries beyond “by id” Strong fit — the only correct read mechanism
Dashboards, lists, feeds, aggregations over events Strong fit
Multiple divergent read shapes over the same data Strong fit — one projection each
CRUD write model Avoid — query the table; no events to project
Read and write shapes are identical Lean against — CQRS-lite is simpler
Single-aggregate “by id” reads Avoid — that’s the repository

Next steps

The projection host quietly does the right thing while events are well-behaved. Production events are not always well-behaved: one will be poison (a bug, a bad payload) and throw forever, and one day a read model’s shape will need to change. The next article covers projection operations — skipping poison events to dead-letters so one bad event can’t wedge the stream, and rebuilding read models (in-place and zero-downtime) from full history.

➡️ 008 — Projection Operations