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:
- delete it
- replay events
- 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.