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.

Projections are deployed by uploading a compiled C# project. Hapnd compiles your code, identifies it, runs historical events through it, and activates it, without you stopping traffic, managing checkpoints, or coordinating cutovers.

Terminal window
npx @hapnd/cli deploy
npx @hapnd/cli status a1b2c3d4-e5f6-7890-abcd-ef1234567890

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

The upload response contains two IDs that serve different purposes:

  • uploadId - a plain UUID identifying this specific deploy. Use it to poll compilation status.
  • projectionId - the stable identity for this logical projection, always prefixed with proj_. Returned as null at upload time and populated once compilation completes. This is the ID you use everywhere else: state queries, webhook routes, SDK subscriptions.

The two IDs are visually distinct: upload IDs are plain UUIDs (e.g. a1b2c3d4-e5f6-...), while projection IDs carry the proj_ prefix (e.g. proj_ae3e7510...).

Poll GET /projections/{uploadId}/status until the response includes a non-null projectionId. Once you have it, it never changes for this projection; you can hard-code it in your downstream code.

Every projection has a stable identity that’s resolved at compile time. By default, the identity is the fully qualified class name. Renaming a class therefore creates a new projection; Hapnd treats the renamed class as something it has never seen before.

Identity is scoped per tenant. Two tenants can each have a MailboxProjection and they’re entirely independent.

The first time Hapnd sees a given identity, it generates a new projectionId and stores it. Every subsequent upload of the same identity reuses that ID. There’s no separate “create projection” step; uploading the code is the registration.

Class names change. A projection’s identity shouldn’t change with them.

Apply [ProjectionName] to pin an explicit, stable identity that survives class renames:

using Hapnd.Projections.Contracts;
[ProjectionName("orders-mailbox")]
public class MailboxProjection : IProjection<MailboxView>
{
public string? ResolveKey(Event @event) { /* ... */ }
public MailboxView Apply(MailboxView? state, Event @event) { /* ... */ }
}

Once [ProjectionName] is set, you can rename the class freely and the projection keeps its existing projectionId, its existing state, and its existing webhook configuration.

If you’ve been running a projection without the attribute and want to add it later, set the attribute value to your current class name on the next deploy. Identity stays the same, future renames are now safe.

When you deploy a change to an existing projection, Hapnd doesn’t replace the running projection in place. It runs two versions side by side, briefly, until the new one is ready to take over.

On every redeploy, Hapnd runs the new code through the full event history before it touches live traffic:

  1. The new upload becomes the pending version. The old upload remains the active version.
  2. The pending version replays every historical event from global position zero in a separate state space. Old and new state coexist; they don’t interfere.
  3. The active version continues serving state queries, processing real-time events, and firing notifications, exactly as it did before you deployed. Consumers see no change.
  4. Once the pending version finishes catchup, Hapnd performs an atomic swap: pending becomes active, the previous active version is retired, and its read state is cleaned up in the background.

This is what “hot-swap” means in Hapnd; there is no moment where the projection is offline, no moment where state queries return partial data, and no moment where real-time events are dropped. The old version handles everything until the new version is fully ready.

If a second redeploy of the same projection arrives while a pending version is still catching up, the latest upload wins. The in-flight pending version is abandoned, the new upload becomes pending, and catchup restarts from position zero. This is intentional; it prevents stale intermediate versions from ever activating.

Because the active version keeps serving the entire time, GET /projections/{id}/state/{key} returns 200 with the previous version’s state throughout the redeploy. No 202 catching_up response, no gap, no surprise nulls.

You will only see a 202 in one situation: the very first deploy of a projection identity, when there is no active version yet. From the second deploy onward, reads stay on the active version until the swap completes.

This is by design. It means you can deploy projections during business hours without coordinating with downstream consumers.

Hot-swap relies on the same auto-activation guarantee that single-version projections did: the new code processes every historical event, in the original global order, before it begins serving any traffic.

When a pending version is catching up, Hapnd:

  1. Compiles your code with full security analysis (semantic whitelist, ECDSA-signed DLL, isolated container execution).
  2. Replays every event in global position order across all aggregates, the same order they were originally written.
  3. Holds the swap until catchup is complete and one final round has caught any events that arrived during the swap window.
  4. Promotes the pending version to active in a single atomic database update.

A cross-aggregate projection deployed today therefore produces the same state it would have produced if it had been running from the beginning. You don’t manage checkpoints, offsets, or replay logic; the platform owns all of it, and the design has been deliberately built to make data loss during a deploy avoidable.

Projections produce state that you consume in two ways:

Query on demand - GET /projections/{id}/state/{key} returns the current state for a given state key. Use this when you need the latest state for a specific key (e.g. a customer ID, a mailbox ID). Returns 202 only on the very first deploy of a new projection identity, before any active version exists.

Subscribe to changes - receive state updates via 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 notification mechanism.