Skip to content

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.

ReducersProjections
ExecutionSynchronous, on the write pathAsynchronous, via queue
ScopeSingle aggregateAll aggregates of a type
State keyAggregate ID (automatic)ResolveKey return value (you define)
State in responseYes — returned with every appendNo — query separately or subscribe
InterfaceIReducer<TState>IProjection<TState>
Use caseImmediate consistency, single entityRead models, analytics, cross-aggregate views
Terminal window
dotnet add package Hapnd.Projections.Contracts

Requires .NET 10.

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, Apply is called, updated state is saved
  • Return null → the event is skipped entirely — no state loaded, no Apply called, 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.

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.

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.

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.

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.

Terminal window
npx @hapnd/cli deploy
npx @hapnd/cli status proj_abc123

The CLI auto-detects whether your code implements IProjection<T> or IReducer<T> and routes the upload accordingly.

When you upload a projection, Hapnd automatically:

  1. Compiles your code using Roslyn (with full security analysis)
  2. Replays all historical events in global position order through your projection
  3. Activates the projection once catchup is complete
  4. 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.

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.