Skip to content

Projections

Projections build read models asynchronously from events. Unlike reducers (which run synchronously on the write path for a single aggregate), projections run in the background across all aggregates of a given type. They’re ideal for building views, analytics, or triggering workflows based on patterns across your event streams.

ReducersProjections
ExecutionSynchronous, on the write pathAsynchronous, via queue
ScopeSingle aggregateAll aggregates
State in responseYes — returned with every appendNo — query separately or subscribe
InterfaceIReducer<TState>IProjection<TState>
Use caseImmediate consistency for a single entityRead models, analytics, cross-aggregate views

Projections use the same Apply method as reducers but implement IProjection<TState>:

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 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
};
}
object? IProjection.Apply(object? state, Event @event)
=> Apply((SalesStats?)state, @event);
}
public record OrderCompletedData(decimal Total);

Note the explicit IProjection.Apply implementation — this is required because projections use a non-generic base interface for runtime loading. Reducers don’t need this because IReducer<T> handles it automatically.

Projections follow the same rules as reducers:

  • Pure functions — no side effects, no I/O, no network calls
  • Immutable state — return new objects, don’t mutate
  • Handle unknown events — return current state unchanged
  • Compiled server-side with the same Roslyn security analysis
Terminal window
npx @hapnd/cli deploy
npx @hapnd/cli status proj_abc123

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

When you upload a projection, Hapnd automatically:

  1. Compiles your code using Roslyn (with full security analysis)
  2. Discovers all aggregates that match the projection’s scope
  3. Replays historical events through your projection to build initial state
  4. Begins processing new events as they arrive

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.