Skip to content

Build a separate reducer

Use a separate IReducerFor<T> when the fold needs injected services, instance behavior during replay, or must live apart from its model. For application code, start with model-bound attributes, then fluent mappings, then reducer code; a self-reducing model is the default for new reducers. Choose how to build a read model explains the choice; this guide shows the separate-class form.

Before you begin, ensure you have:

  • A Chronicle-enabled application
  • Basic understanding of events and event sourcing
  • A read model class to reduce events into

First, create a record representing the state you want to compute:

public record ReducersGettingStartedOrderSummary(
Guid OrderId,
decimal TotalAmount,
int ItemCount,
DateTimeOffset LastUpdated);

View C# snippet source on GitHub

Create a reducer by implementing IReducerFor<TReadModel> with methods for each event type:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
[EventType]
public record ReducersGettingStartedOrderCreated(Guid OrderId);
[EventType]
public record ReducersGettingStartedItemAddedToOrder(decimal Price, int Quantity);
[EventType]
public record ReducersGettingStartedItemRemovedFromOrder(decimal Price, int Quantity);
public class ReducersGettingStartedOrderSummaryReducer : IReducerFor<ReducersGettingStartedOrderSummary>
{
public ReducersGettingStartedOrderSummary Created(ReducersGettingStartedOrderCreated @event, ReducersGettingStartedOrderSummary? current, EventContext context) =>
new(
OrderId: @event.OrderId,
TotalAmount: 0m,
ItemCount: 0,
LastUpdated: context.Occurred);
public ReducersGettingStartedOrderSummary? ItemAdded(ReducersGettingStartedItemAddedToOrder @event, ReducersGettingStartedOrderSummary? current, EventContext context)
{
if (current is null) return null; // Skip if order not created yet
return current with
{
TotalAmount = current.TotalAmount + (@event.Price * @event.Quantity),
ItemCount = current.ItemCount + @event.Quantity,
LastUpdated = context.Occurred
};
}
public ReducersGettingStartedOrderSummary? ItemRemoved(ReducersGettingStartedItemRemovedFromOrder @event, ReducersGettingStartedOrderSummary? current, EventContext context)
{
if (current is null) return null; // Skip if order not created yet
return current with
{
TotalAmount = current.TotalAmount - (@event.Price * @event.Quantity),
ItemCount = current.ItemCount - @event.Quantity,
LastUpdated = context.Occurred
};
}
}

View C# snippet source on GitHub

The event context supplies metadata such as occurrence time and event source ID. TypeScript handlers can take it as a third argument after the event and current state.

Reducer methods are discovered by convention and support the following signatures:

using Cratis.Chronicle.Events;
public interface IReducersGettingStartedSyncSignatures<TReadModel, TEvent>
where TReadModel : class
{
// Without context
TReadModel? WithoutContext(TEvent @event, TReadModel? current);
// With context
TReadModel? WithContext(TEvent @event, TReadModel? current, EventContext context);
}

View C# snippet source on GitHub

using Cratis.Chronicle.Events;
public interface IReducersGettingStartedAsyncSignatures<TReadModel, TEvent>
where TReadModel : class
{
// Without context
Task<TReadModel?> WithoutContext(TEvent @event, TReadModel? current);
// With context
Task<TReadModel?> WithContext(TEvent @event, TReadModel? current, EventContext context);
}

View C# snippet source on GitHub

These overload tables describe .NET instance-method dispatch. Kotlin and Java match the first parameter’s event type. TypeScript selects a method by the camelCase event-class name and passes (event, current, context); it awaits the result, so an async handler works. Kotlin awaits suspend handlers. Elixir uses a reduce/3 callback pattern-matched by event struct.

Key Points:

  • In .NET, method names are unrestricted; the examples use On followed by the event type name
  • The @event parameter is the specific event being processed
  • The current parameter contains the existing state (null if no previous state exists)
  • The EventContext parameter provides metadata like event source ID, occurred timestamp, and sequence number
  • Both synchronous and asynchronous methods are supported

In .NET, the event must be the first parameter and the nullable current read model the second. An optional third parameter must be EventContext. Return the model or Task<TReadModel>, including a nullable result. Returning null deletes or leaves the instance absent. Classic plain Task is also accepted, but yields null state; a mutation-only Task handler does not retain current state. A method taking the current model first and object second is not dispatched.

The .NET analyzers report unsupported signatures as CHR0006 and event parameters without [EventType] as CHR0007, including object event parameters. If none of a reducer’s methods can be dispatched, the client skips its kernel registration and logs a warning naming the reducer and explaining why its public reducer-like methods were skipped. The application still starts; correct the signatures so the reducer can observe events.

You can customize the reducer using the [Reducer] attribute:

using Cratis.Chronicle.Reducers;
public record ReducersGettingStartedAttributeOrderSummary(Guid OrderId);
[Reducer(id: "order-summary", eventSequence: "order-events")]
public class ReducersGettingStartedAttributeOrderSummaryReducer : IReducerFor<ReducersGettingStartedAttributeOrderSummary>;

View C# snippet source on GitHub

Attribute parameters:

  • id - Custom identifier for the reducer (defaults to the fully qualified type name)
  • eventSequence - The event sequence to observe (defaults to the event log)
  • isActive - Whether the reducer actively observes events (defaults to true)

Use the client tab’s configuration shape. TypeScript expresses identity and sequence through the reducer() decorator; Kotlin and Java use @Reducer.

Once your reducer is set up, you can retrieve the computed state:

using Cratis.Chronicle;
using Cratis.Chronicle.ReadModels;
public class ReducersGettingStartedOrderService(IEventStore eventStore)
{
public async Task<ReducersGettingStartedOrderSummary?> GetOrderSummary(Guid orderId) =>
await eventStore.ReadModels.GetInstanceById<ReducersGettingStartedOrderSummary>(orderId);
}

View C# snippet source on GitHub

Reducer methods are called for each event matching the event type:

  1. Event Type Matching - Chronicle calls the method that handles the specific event type
  2. Event Source ID - Each method receives events for a single event source
  3. Sequential Processing - Events are processed in the order they were appended

The current parameter in each method:

  • Is null when no previous state exists for this event source
  • Contains the current persisted state for this event source
  1. Keep reducers deterministic - Compute state from recorded facts. An injected clock or live rate service can return different values during replay; record time and rates in events when they determine the result.
  2. Handle null current state - Always check if current is null for the initial state
  3. Use immutable state - Create new instances rather than mutating the current state
  4. Process events in order - The events are provided in sequence order; respect that order
  5. Consider performance - For large event streams, optimize your reduction logic