Skip to main content

Projections + CQRS (read models that you can rebuild)

Event streams are great for writes, but they're not how you want to query.

Typical pattern:

  • Write model: event-sourced aggregate + stream
  • Read model: projections built from events (often stored in a DB optimized for queries)

This separation is commonly called CQRS (Command Query Responsibility Segregation).

Projection rule 1: projections are disposable

If your read model gets corrupted, you should be able to:

  1. delete it
  2. replay events
  3. rebuild it

That's the whole point.

Projection rule 2: projections must be idempotent

Your projector will see duplicates (retries, restarts, at-least-once delivery). It must be safe to process the same event twice.

Practical approach:

  • store a "last processed stream version" (per stream) in the read model
  • ignore older versions

A simple read model: current balance per account

public sealed record AccountSummary(
Guid AccountId,
string OwnerName,
decimal Balance,
bool IsClosed,
long LastProcessedVersion
);

Projector (in-memory, but correct semantics)

public sealed class AccountSummaryProjector
{
private readonly Dictionary<Guid, AccountSummary> _readModel = new();

public AccountSummary? Get(Guid accountId) =>
_readModel.TryGetValue(accountId, out var s) ? s : null;

public void Apply(Guid accountId, long streamVersion, IDomainEvent @event)
{
if (_readModel.TryGetValue(accountId, out var current))
{
// idempotency: ignore if we already processed this version or beyond
if (streamVersion <= current.LastProcessedVersion) return;
}

var next = current ?? new AccountSummary(
AccountId: accountId,
OwnerName: "",
Balance: 0,
IsClosed: false,
LastProcessedVersion: -1
);

next = @event switch
{
AccountOpened e => next with
{
OwnerName = e.OwnerName,
LastProcessedVersion = streamVersion
},
MoneyDeposited e => next with
{
Balance = next.Balance + e.Amount,
LastProcessedVersion = streamVersion
},
MoneyWithdrawn e => next with
{
Balance = next.Balance - e.Amount,
LastProcessedVersion = streamVersion
},
AccountClosed => next with
{
IsClosed = true,
LastProcessedVersion = streamVersion
},
_ => throw new NotSupportedException($"Unknown event: {@event.GetType().Name}")
};

_readModel[accountId] = next;
}
}

"Where does streamVersion come from?"

In real event stores, each event has:

  • a stream revision / event number (monotonic per stream) or you compute it as "index in stream".

That version is what makes projections idempotent.

Pull vs push projection building

Two common approaches:

  • Pull: projector periodically reads new events from the event store and applies them.
  • Push: event store pushes events to subscribers, projector applies them.

Either way, you still need:

  • ordering per stream
  • idempotency
  • checkpointing (last processed position)

Next

Snapshots speed up aggregate rebuilds. Use them strategically.

Next: Snapshots

Sources