Projections
Projections build read models asynchronously from events. The defining concept: each projection controls its own state key via ResolveKey. This means a projection can collect state across multiple aggregates — grouping emails into a mailbox view, aggregating orders across customers into a dashboard, or building any read model that doesn’t map one-to-one with a single aggregate.
How Projections Differ from Reducers
Section titled “How Projections Differ from Reducers”| Reducers | Projections | |
|---|---|---|
| Execution | Synchronous, on the write path | Asynchronous, via queue |
| Scope | Single aggregate | All aggregates of a type |
| State key | Aggregate ID (automatic) | ResolveKey return value (you define) |
| State in response | Yes — returned with every append | No — query separately or subscribe |
| Interface | IReducer<TState> | IProjection<TState> |
| Use case | Immediate consistency, single entity | Read models, analytics, cross-aggregate views |
Installing the Contracts Package
Section titled “Installing the Contracts Package”dotnet add package Hapnd.Projections.ContractsRequires .NET 10.
ResolveKey
Section titled “ResolveKey”Every event passes through ResolveKey before Apply is called. The string you return becomes the key under which state is stored and retrieved.
- Return a string → state is loaded for that key,
Applyis called, updated state is saved - Return
null→ the event is skipped entirely — no state loaded, noApplycalled, nothing saved
This is what makes projections powerful. A reducer always keys state by @event.AggregateId. A projection can key state by anything — a mailbox ID extracted from event data, a customer segment, a date bucket. You define the shape of the read model by defining the key.
Example: Per-aggregate projection
Section titled “Example: Per-aggregate projection”The simplest case: ResolveKey returns @event.AggregateId, so each aggregate gets its own state. This behaves like a reducer, but runs asynchronously.
using Hapnd.Projections.Contracts;
public record SalesStats{ public decimal TotalRevenue { get; init; } public int OrderCount { get; init; } public decimal AverageOrderValue { get; init; }}
public class SalesProjection : IProjection<SalesStats>{ public string? ResolveKey(Event @event) => @event.AggregateId;
public SalesStats Apply(SalesStats? state, Event @event) { state ??= new SalesStats();
return @event.Type switch { "OrderCompleted" => ApplyOrderCompleted(state, @event), _ => state }; }
private static SalesStats ApplyOrderCompleted(SalesStats state, Event @event) { var data = @event.GetData<OrderCompletedData>(); var newCount = state.OrderCount + 1; var newRevenue = state.TotalRevenue + data.Total;
return state with { TotalRevenue = newRevenue, OrderCount = newCount, AverageOrderValue = newRevenue / newCount }; }}
public record OrderCompletedData(decimal Total);No IProjection.Apply boilerplate — the contracts package handles the bridge to the non-generic base interface via default interface methods.
Example: Cross-aggregate projection
Section titled “Example: Cross-aggregate projection”This is where projections diverge from reducers. Events arrive from individual email aggregates (email-001, email-002, etc.), but ResolveKey routes them to a shared mailbox state. One read model, many source aggregates.
using Hapnd.Projections.Contracts;
public record MailboxView{ public List<EmailSummary> Emails { get; init; } = []; public int UnreadCount { get; init; }}
public record EmailSummary(string EmailId, string Subject, string From, bool IsRead);
public class MailboxProjection : IProjection<MailboxView>{ public string? ResolveKey(Event @event) { // Events arrive from individual email aggregates (email-001, email-002, etc.) // We key state by mailbox — grouping all emails into one view return @event.Type switch { "EmailReceived" => @event.GetData<EmailReceivedData>().MailboxId, "EmailRead" => @event.GetData<EmailReadData>().MailboxId, _ => null // Skip events we don't care about }; }
public MailboxView Apply(MailboxView? state, Event @event) { state ??= new MailboxView();
return @event.Type switch { "EmailReceived" => ApplyEmailReceived(state, @event), "EmailRead" => ApplyEmailRead(state, @event), _ => state }; }
private static MailboxView ApplyEmailReceived(MailboxView state, Event @event) { var data = @event.GetData<EmailReceivedData>(); var summary = new EmailSummary(data.EmailId, data.Subject, data.From, false);
return state with { Emails = [..state.Emails, summary], UnreadCount = state.UnreadCount + 1 }; }
private static MailboxView ApplyEmailRead(MailboxView state, Event @event) { var data = @event.GetData<EmailReadData>();
return state with { Emails = state.Emails .Select(e => e.EmailId == data.EmailId ? e with { IsRead = true } : e) .ToList(), UnreadCount = state.UnreadCount - 1 }; }}
public record EmailReceivedData(string EmailId, string MailboxId, string Subject, string From);public record EmailReadData(string EmailId, string MailboxId);Events flow from many email aggregates, but ResolveKey routes them all to the same mailbox state. This is how you build read models that span aggregates — no joins, no manual fan-out.
Skipping events
Section titled “Skipping events”When ResolveKey returns null, the event is skipped entirely. No state is loaded, Apply is not called, and nothing is saved. Use this to filter out events your projection doesn’t care about. The MailboxProjection above demonstrates this with the _ => null fallback.
The same rules apply
Section titled “The same rules apply”Projections follow the same constraints as reducers. Keep Apply a pure function — no side effects, no I/O, no network calls. Return new state objects rather than mutating existing ones. Handle unknown event types by returning state unchanged. Your code is compiled server-side with the same Roslyn security analysis as reducers.
Deploying
Section titled “Deploying”npx @hapnd/cli deploynpx @hapnd/cli status proj_abc123The CLI auto-detects whether your code implements IProjection<T> or IReducer<T> and routes the upload accordingly.
Auto-activation and historical catchup
Section titled “Auto-activation and historical catchup”When you upload a projection, Hapnd automatically:
- Compiles your code using Roslyn (with full security analysis)
- Replays all historical events in global position order through your projection
- Activates the projection once catchup is complete
- Begins processing new events as they arrive in real time
Historical replay processes events in the same global order they were originally written, across all aggregates. This means a cross-aggregate projection deployed today produces the same state as if it had been running from the beginning.
You don’t need to manage checkpoints, offsets, or replay logic. Hapnd handles all of it. The catchup process is designed to prevent race conditions — events appended during catchup are not lost or double-processed.
Consuming projection state
Section titled “Consuming projection state”Projections produce state that you consume through notifications:
- Webhooks — HTTP POST to your endpoint when state changes
- WebSocket streaming — real-time push via the SDK’s subscription API
- REST polling — cursor-based polling endpoint
See the Notifications page for details on each mechanism.