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, so 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:

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

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 Name Inferred Type
OrderReducer order
CustomerReducer customer
InventoryReducer inventory

Override with the [AggregateType] attribute:

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

If two reducers in the same DLL resolve to the same aggregate type, compilation fails with a descriptive error:

Duplicate aggregate types detected: 'order' is claimed by: MyReducers.OrderReducer, Domain.OrderReducer.
Each aggregate type must map to exactly one reducer. Use [AggregateType] to disambiguate.

This is caught at compile time; you’ll see the error in hapnd status output. Rename one class or add [AggregateType("different-name")] to resolve it.

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_api_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.