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.
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 |
| State in response | Yes — returned with every append | No — query separately or subscribe |
| Interface | IReducer<TState> | IProjection<TState> |
| Use case | Immediate consistency for a single entity | Read models, analytics, cross-aggregate views |
Implementing a Projection
Section titled “Implementing a Projection”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.
The Same Rules Apply
Section titled “The Same Rules Apply”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
Deploying
Section titled “Deploying”npx @hapnd/cli deploynpx @hapnd/cli status proj_abc123The CLI detects whether your code implements IReducer<T> or IProjection<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)
- Discovers all aggregates that match the projection’s scope
- Replays historical events through your projection to build initial state
- 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.
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.