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 callsSaveChangesonce at the end, soAccountOpened,MoneyDepositedandMoneyWithdrawnfor one account are all staged on the sameDbContextbefore anything is committed.FindAsyncresolves against EF Core’s change tracker first, so it sees the row inserted byAccountOpenedearlier in the same batch. AFirstOrDefaultAsync/Wherequery goes to the database, which doesn’t have that row yet — so the deposit and withdrawal would find nothing and the balance would silently stay0. 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
AddonAccountOpened(no guard) double-inserts on re-delivery; a non-atomic increment double-counts. Make every apply safe to repeat. - Calling
SaveChangesinside 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
ProjectAsyncmeans 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.