Skip to content

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.

Reducers are plain C# classes that implement an interface from the Hapnd Contracts package. Install it in a class library project:

Terminal window
dotnet new classlib -n MyReducers
cd MyReducers
dotnet add package Hapnd.Projections.Contracts

Requires .NET 10.0 or later.

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 state parameter is null for the first event on a new aggregate — always handle this
  • Use pattern matching on @event.Type to dispatch to the correct handler
  • Always return the current state unchanged for unrecognised event types (the _ => state arm)
  • Use C# records with init properties and the with expression for immutable state updates

The Event record passed to your Apply method contains:

PropertyTypeDescription
IdstringUnique event identifier
TenantIdstringTenant identifier
AggregateIdstringAggregate this event belongs to
TypestringEvent type discriminator (e.g., "ItemAdded")
DataobjectEvent payload (raw JSON)
TimestamplongUnix milliseconds
MetadataDictionary<string, object>?Optional metadata

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
}

Your reducer must be a pure function. Hapnd enforces this at compile-time via Roslyn static analysis.

  • 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

Hapnd uses a semantic allowlist — only explicitly permitted APIs can be used. The following are blocked:

  • System.IO — file access
  • System.Net — network calls
  • System.Reflection — runtime reflection
  • System.Diagnostics — process launching
  • System.Threading — thread manipulation
  • System.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.

  • Standard C# language features (pattern matching, records, LINQ, etc.)
  • System.Collections and System.Collections.Generic
  • System.Linq
  • System.Text
  • System.Math and System.Convert
  • The Hapnd.Projections.Contracts namespace

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> { ... }

The aggregate type for each reducer is inferred from the class name by stripping the Reducer suffix and lowercasing:

Class NameInferred Type
OrderReducerorder
CustomerReducercustomer
InventoryReducerinventory

Override with the [AggregateType] attribute:

[AggregateType("purchase_order")]
public class OrderReducer : IReducer<OrderState> { ... }

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.

Use the Hapnd CLI:

Terminal window
# Authenticate
npx @hapnd/cli login sk_live_your_key
# Deploy from the project directory (auto-detects .csproj)
npx @hapnd/cli deploy
# Check compilation status
npx @hapnd/cli status red_abc123

The 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:

Terminal window
npx @hapnd/cli bind order red_abc123

When an event is appended to an aggregate with a bound reducer:

  1. The event is stored in the Durable Object’s SQLite database
  2. The current snapshot is loaded (or null for a new aggregate)
  3. The compiled reducer DLL is fetched from R2 (cached after first load)
  4. The .NET container executes Apply(currentState, newEvent)
  5. The new state is saved as a snapshot
  6. 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.