Reducers
A reducer computes aggregate state from events. It runs synchronously on the write path — when you append an event to an aggregate with a bound reducer, the response includes the freshly computed state. Think of it as a fold: (State, Event) → New State.
This is useful when you need immediate consistency. Your API call returns the current state without a separate read.
The Contracts Package
Section titled “The Contracts Package”Reducers are plain C# classes that implement an interface from the Hapnd Contracts package. Install it in a class library project:
dotnet new classlib -n MyReducerscd MyReducersdotnet add package Hapnd.Projections.ContractsRequires .NET 10.0 or later.
Implementing a Reducer
Section titled “Implementing a Reducer”Implement IReducer<TState> where TState is your state class:
using Hapnd.Projections.Contracts;
public record OrderState{ public decimal Total { get; init; } public List<string> Items { get; init; } = []; public int ItemCount { get; init; } public string? CustomerId { get; init; }}
public class OrderReducer : IReducer<OrderState>{ public OrderState Apply(OrderState? state, Event @event) { state ??= new OrderState();
return @event.Type switch { "OrderCreated" => ApplyOrderCreated(state, @event), "ItemAdded" => ApplyItemAdded(state, @event), _ => state }; }
private static OrderState ApplyOrderCreated(OrderState state, Event @event) { var data = @event.GetData<OrderCreatedData>(); return state with { CustomerId = data.CustomerId }; }
private static OrderState ApplyItemAdded(OrderState state, Event @event) { var data = @event.GetData<ItemAddedData>(); return state with { Total = state.Total + data.Price, Items = [..state.Items, data.Item], ItemCount = state.ItemCount + 1 }; }}
public record OrderCreatedData(string? CustomerId);public record ItemAddedData(string Item, decimal Price);Key points:
- The
stateparameter isnullfor the first event on a new aggregate — always handle this - Use pattern matching on
@event.Typeto dispatch to the correct handler - Always return the current state unchanged for unrecognised event types (the
_ => statearm) - Use C# records with
initproperties and thewithexpression for immutable state updates
The Event Record
Section titled “The Event Record”The Event record passed to your Apply method contains:
| Property | Type | Description |
|---|---|---|
Id | string | Unique event identifier |
TenantId | string | Tenant identifier |
AggregateId | string | Aggregate this event belongs to |
Type | string | Event type discriminator (e.g., "ItemAdded") |
Data | object | Event payload (raw JSON) |
Timestamp | long | Unix milliseconds |
Metadata | Dictionary<string, object>? | Optional metadata |
Deserialising Event Data
Section titled “Deserialising Event Data”Use GetData<T>() for strongly-typed access to the event payload:
var data = @event.GetData<ItemAddedData>();Console.WriteLine($"Added {data.Item} for {data.Price:C}");Use TryGetData<T>() when the payload might not match the expected type. It returns default instead of throwing:
var data = @event.TryGetData<ItemAddedData>();if (data is not null){ // Process the event}Reducer Rules
Section titled “Reducer Rules”Your reducer must be a pure function. Hapnd enforces this at compile-time via Roslyn static analysis.
Required
Section titled “Required”- Pure — no side effects, no I/O, no network calls, no file access
- Immutable — return a new state object, don’t modify the input
- Idempotent — applying the same event twice produces the same result
- Total — handle all event types, returning current state unchanged for unrecognised types
Blocked at Compile-Time
Section titled “Blocked at Compile-Time”Hapnd uses a semantic allowlist — only explicitly permitted APIs can be used. The following are blocked:
System.IO— file accessSystem.Net— network callsSystem.Reflection— runtime reflectionSystem.Diagnostics— process launchingSystem.Threading— thread manipulationSystem.Environment— environment variable access
This is an inverted security model: everything is blocked unless specifically permitted. The allowlist includes safe BCL types like Collections, LINQ, Text, Math, and Convert.
Allowed
Section titled “Allowed”- Standard C# language features (pattern matching, records, LINQ, etc.)
System.CollectionsandSystem.Collections.GenericSystem.LinqSystem.TextSystem.MathandSystem.Convert- The
Hapnd.Projections.Contractsnamespace
Multi-Reducer DLLs
Section titled “Multi-Reducer DLLs”Put multiple reducers in a single project. Hapnd discovers all IReducer<T> implementations automatically when you deploy:
public class OrderReducer : IReducer<OrderState> { ... }public class CustomerReducer : IReducer<CustomerState> { ... }public class InventoryReducer : IReducer<InventoryState> { ... }Aggregate Type Resolution
Section titled “Aggregate Type Resolution”The aggregate type for each reducer is inferred from the class name by stripping the Reducer suffix and lowercasing:
| Class Name | Inferred Type |
|---|---|
OrderReducer | order |
CustomerReducer | customer |
InventoryReducer | inventory |
Override with the [AggregateType] attribute:
[AggregateType("purchase_order")]public class OrderReducer : IReducer<OrderState> { ... }Auto-Binding
Section titled “Auto-Binding”When you deploy a multi-reducer DLL, Hapnd automatically binds each discovered reducer to its aggregate type. No manual bind command needed for the initial deployment.
Deploying
Section titled “Deploying”Use the Hapnd CLI:
# Authenticatenpx @hapnd/cli login sk_live_your_key
# Deploy from the project directory (auto-detects .csproj)npx @hapnd/cli deploy
# Check compilation statusnpx @hapnd/cli status red_abc123The CLI auto-detects your project type, zips the source files, uploads them, and Hapnd compiles server-side using Roslyn. If compilation succeeds, the reducers are bound to their aggregate types automatically.
To manually bind or rebind a reducer to a different aggregate type:
npx @hapnd/cli bind order red_abc123How It Works Internally
Section titled “How It Works Internally”When an event is appended to an aggregate with a bound reducer:
- The event is stored in the Durable Object’s SQLite database
- The current snapshot is loaded (or
nullfor a new aggregate) - The compiled reducer DLL is fetched from R2 (cached after first load)
- The .NET container executes
Apply(currentState, newEvent) - The new state is saved as a snapshot
- The response includes both the event metadata and the computed state
Snapshots mean the reducer only processes new events — it doesn’t replay from the beginning every time. For a 10,000-event aggregate, only the latest event is processed on each append.