Getting Started with Reducers
Reducers provide a powerful way to build read models by reducing a sequence of events into aggregated state. This guide will walk you through creating your first reducer.
Prerequisites
Section titled “Prerequisites”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
Creating a Reducer
Section titled “Creating a Reducer”1. Define Your Read Model
Section titled “1. Define Your Read Model”First, create a record representing the state you want to compute:
public record ReducersGettingStartedOrderSummary( Guid OrderId, decimal TotalAmount, int ItemCount, DateTimeOffset LastUpdated);import io.cratis.chronicle.readModels.ReadModelimport java.time.Instant
@ReadModeldata class ReducersGettingStartedOrderSummary( val orderId: String = "", val totalAmount: Double = 0.0, val itemCount: Int = 0, val lastUpdated: Instant = Instant.EPOCH)import io.cratis.chronicle.readModels.ReadModel;import java.time.Instant;
@ReadModelrecord ReducersGettingStartedOrderSummary( String orderId, double totalAmount, int itemCount, Instant lastUpdated) {
ReducersGettingStartedOrderSummary() { this("", 0.0, 0, Instant.EPOCH); }}defmodule MyApp.ReadModels.ReducersGettingStartedOrderSummary do defstruct order_id: "", total_amount: 0, item_count: 0, last_updated: nilendclass ReducersGettingStartedOrderSummary { orderId = ''; totalAmount = 0; itemCount = 0; lastUpdated: Date = new Date(0);}2. Implement the Reducer
Section titled “2. Implement the Reducer”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 }; }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport java.time.Instant
@EventType(id = "reducers-getting-started-order-created")data class ReducersGettingStartedOrderCreated(val orderId: String)
@EventType(id = "reducers-getting-started-item-added-to-order")data class ReducersGettingStartedItemAddedToOrder(val price: Double, val quantity: Int)
@EventType(id = "reducers-getting-started-item-removed-from-order")data class ReducersGettingStartedItemRemovedFromOrder(val price: Double, val quantity: Int)
@Reducerclass ReducersGettingStartedOrderSummaryReducer { fun created(event: ReducersGettingStartedOrderCreated): ReducersGettingStartedOrderSummary = ReducersGettingStartedOrderSummary( orderId = event.orderId, totalAmount = 0.0, itemCount = 0, lastUpdated = Instant.now() )
fun itemAdded( event: ReducersGettingStartedItemAddedToOrder, current: ReducersGettingStartedOrderSummary? ): ReducersGettingStartedOrderSummary? { if (current == null) return null // Skip if order not created yet
return current.copy( totalAmount = current.totalAmount + (event.price * event.quantity), itemCount = current.itemCount + event.quantity, lastUpdated = Instant.now() ) }
fun itemRemoved( event: ReducersGettingStartedItemRemovedFromOrder, current: ReducersGettingStartedOrderSummary? ): ReducersGettingStartedOrderSummary? { if (current == null) return null // Skip if order not created yet
return current.copy( totalAmount = current.totalAmount - (event.price * event.quantity), itemCount = current.itemCount - event.quantity, lastUpdated = Instant.now() ) }}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reducer;import java.time.Instant;
@EventType(id = "reducers-getting-started-order-created")record ReducersGettingStartedOrderCreated(String orderId) {}
@EventType(id = "reducers-getting-started-item-added-to-order")record ReducersGettingStartedItemAddedToOrder(double price, int quantity) {}
@EventType(id = "reducers-getting-started-item-removed-from-order")record ReducersGettingStartedItemRemovedFromOrder(double price, int quantity) {}
@Reducerclass ReducersGettingStartedOrderSummaryReducer { ReducersGettingStartedOrderSummary created(ReducersGettingStartedOrderCreated event) { return new ReducersGettingStartedOrderSummary(event.orderId(), 0.0, 0, Instant.now()); }
ReducersGettingStartedOrderSummary itemAdded( ReducersGettingStartedItemAddedToOrder event, ReducersGettingStartedOrderSummary current) { if (current == null) return null; // Skip if order not created yet
return new ReducersGettingStartedOrderSummary( current.orderId(), current.totalAmount() + (event.price() * event.quantity()), current.itemCount() + event.quantity(), Instant.now()); }
ReducersGettingStartedOrderSummary itemRemoved( ReducersGettingStartedItemRemovedFromOrder event, ReducersGettingStartedOrderSummary current) { if (current == null) return null; // Skip if order not created yet
return new ReducersGettingStartedOrderSummary( current.orderId(), current.totalAmount() - (event.price() * event.quantity()), current.itemCount() - event.quantity(), Instant.now()); }}defmodule MyApp.Events.ReducersGettingStartedOrderCreated do use Chronicle.Events.EventType, id: "reducers-getting-started-order-created"
defstruct [:order_id]end
defmodule MyApp.Events.ReducersGettingStartedItemAddedToOrder do use Chronicle.Events.EventType, id: "reducers-getting-started-item-added-to-order"
defstruct [:price, :quantity]end
defmodule MyApp.Events.ReducersGettingStartedItemRemovedFromOrder do use Chronicle.Events.EventType, id: "reducers-getting-started-item-removed-from-order"
defstruct [:price, :quantity]end
defmodule MyApp.Reducers.ReducersGettingStartedOrderSummaryReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.ReducersGettingStartedOrderSummary
alias MyApp.Events.{ ReducersGettingStartedItemAddedToOrder, ReducersGettingStartedItemRemovedFromOrder, ReducersGettingStartedOrderCreated }
@handles ReducersGettingStartedOrderCreated @handles ReducersGettingStartedItemAddedToOrder @handles ReducersGettingStartedItemRemovedFromOrder
@impl true def reduce(%ReducersGettingStartedOrderCreated{} = event, _model, context) do %MyApp.ReadModels.ReducersGettingStartedOrderSummary{ order_id: event.order_id, total_amount: 0, item_count: 0, last_updated: Map.get(context, :occurred) } end
# Skip if order not created yet def reduce(%ReducersGettingStartedItemAddedToOrder{}, nil, _context), do: nil
def reduce(%ReducersGettingStartedItemAddedToOrder{} = event, model, context) do %{ model | total_amount: model.total_amount + event.price * event.quantity, item_count: model.item_count + event.quantity, last_updated: Map.get(context, :occurred) } end
# Skip if order not created yet def reduce(%ReducersGettingStartedItemRemovedFromOrder{}, nil, _context), do: nil
def reduce(%ReducersGettingStartedItemRemovedFromOrder{} = event, model, context) do %{ model | total_amount: model.total_amount - event.price * event.quantity, item_count: model.item_count - event.quantity, last_updated: Map.get(context, :occurred) } endendimport { eventType, reducer } from '@cratis/chronicle';
@eventType()class ReducersGettingStartedOrderCreated { orderId = '';}
@eventType()class ReducersGettingStartedItemAddedToOrder { price = 0; quantity = 0;}
@eventType()class ReducersGettingStartedItemRemovedFromOrder { price = 0; quantity = 0;}
// Method names must be the exact camelCase of the event's class name -// Chronicle discovers handlers by name, not by parameter type.@reducer('', undefined, ReducersGettingStartedOrderSummary)class ReducersGettingStartedOrderSummaryReducer { reducersGettingStartedOrderCreated( event: ReducersGettingStartedOrderCreated, _current: ReducersGettingStartedOrderSummary | undefined ): ReducersGettingStartedOrderSummary { return { orderId: event.orderId, totalAmount: 0, itemCount: 0, lastUpdated: new Date() }; }
reducersGettingStartedItemAddedToOrder( event: ReducersGettingStartedItemAddedToOrder, current: ReducersGettingStartedOrderSummary | undefined ): ReducersGettingStartedOrderSummary | undefined { if (!current) return undefined; // Skip if order not created yet
return { ...current, totalAmount: current.totalAmount + (event.price * event.quantity), itemCount: current.itemCount + event.quantity, lastUpdated: new Date() }; }
reducersGettingStartedItemRemovedFromOrder( event: ReducersGettingStartedItemRemovedFromOrder, current: ReducersGettingStartedOrderSummary | undefined ): ReducersGettingStartedOrderSummary | undefined { if (!current) return undefined; // Skip if order not created yet
return { ...current, totalAmount: current.totalAmount - (event.price * event.quantity), itemCount: current.itemCount - event.quantity, lastUpdated: new Date() }; }}Kotlin, Java, and TypeScript handler methods only receive the event and the current state — unlike C# and Elixir, they don’t get an EventContext parameter, so they can’t use an event’s metadata (like Occurred) when computing the next state.
Method Signatures
Section titled “Method Signatures”Reducer methods are discovered by convention and support the following signatures:
Synchronous Methods
Section titled “Synchronous Methods”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);}Asynchronous Methods
Section titled “Asynchronous Methods”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);}These overload tables are specific to C#‘s method-overload-based dispatch. Kotlin, Java, and TypeScript each dispatch reducer handlers by matching the first parameter’s event type against a fixed 2-parameter (event, current) shape — no context parameter. TypeScript’s dispatch does await the handler, so an async handler works there; Kotlin and Java invoke the handler directly and cannot await it, so a suspend/async handler isn’t supported in those two. Elixir dispatches through a single reduce/3 callback per event, pattern-matched by struct type, which does receive a context map.
Key Points:
- Method names can be anything, but typically start with
Onfollowed by the event type name - The
@eventparameter is the specific event being processed - The
currentparameter contains the existing state (null if no previous state exists) - The
EventContextparameter provides metadata like event source ID, occurred timestamp, and sequence number - Both synchronous and asynchronous methods are supported
3. Using the Reducer Attribute (Optional)
Section titled “3. Using the Reducer Attribute (Optional)”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>;import io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ReducersGettingStartedAttributeOrderSummary(val orderId: String = "")
@Reducer(id = "order-summary")class ReducersGettingStartedAttributeOrderSummaryReducerimport io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.readModels.ReadModel;
@ReadModelrecord ReducersGettingStartedAttributeOrderSummary(String orderId) { ReducersGettingStartedAttributeOrderSummary() { this(""); }}
@Reducer(id = "order-summary")class ReducersGettingStartedAttributeOrderSummaryReducer {}defmodule MyApp.ReadModels.ReducersGettingStartedAttributeOrderSummary do defstruct order_id: ""end
defmodule MyApp.Reducers.ReducersGettingStartedAttributeOrderSummaryReducer do use Chronicle.Reducers.Reducer, model: MyApp.ReadModels.ReducersGettingStartedAttributeOrderSummary, id: "order-summary"
@impl true def reduce(_event, model, _context), do: modelendimport { reducer } from '@cratis/chronicle';
class ReducersGettingStartedAttributeOrderSummary { orderId = '';}
@reducer('order-summary', 'order-events', ReducersGettingStartedAttributeOrderSummary)class ReducersGettingStartedAttributeOrderSummaryReducer {}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 totrue)
Kotlin, Java, and Elixir only support setting a custom id — none of them currently support targeting a non-default event sequence or explicitly toggling active/passive from this attribute. TypeScript supports both id and the event sequence via the reducer() decorator’s parameters, shown above.
Retrieving Reduced State
Section titled “Retrieving Reduced State”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);}import io.cratis.chronicle.IEventStore
class ReducersGettingStartedOrderService(private val store: IEventStore) { suspend fun getOrderSummary(orderId: String): ReducersGettingStartedOrderSummary? = store.readModels.getInstanceByKey(ReducersGettingStartedOrderSummary::class, orderId)}import io.cratis.chronicle.IEventStore;import kotlin.jvm.JvmClassMappingKt;import kotlinx.coroutines.BuildersKt;import kotlin.coroutines.EmptyCoroutineContext;import kotlin.coroutines.Continuation;
class ReducersGettingStartedOrderService { private final IEventStore store;
ReducersGettingStartedOrderService(IEventStore store) { this.store = store; }
ReducersGettingStartedOrderSummary getOrderSummary(String orderId) throws InterruptedException { return (ReducersGettingStartedOrderSummary) BuildersKt.runBlocking( EmptyCoroutineContext.INSTANCE, (scope, continuation) -> { @SuppressWarnings("unchecked") var readContinuation = (Continuation<? super ReducersGettingStartedOrderSummary>) continuation; return store.getReadModels().getInstanceByKey( JvmClassMappingKt.getKotlinClass(ReducersGettingStartedOrderSummary.class), orderId, readContinuation); }); }}defmodule MyApp.ReducersGettingStartedOrderService do alias MyApp.ReadModels.ReducersGettingStartedOrderSummary
def get_order_summary(order_id) do Chronicle.read_model(ReducersGettingStartedOrderSummary, order_id) endendimport { IEventStore } from '@cratis/chronicle';
class ReducersGettingStartedOrderService { constructor(private readonly store: IEventStore) {}
async getOrderSummary(orderId: string): Promise<ReducersGettingStartedOrderSummary> { return this.store.readModels.getInstanceById(ReducersGettingStartedOrderSummary, orderId); }}Event Processing
Section titled “Event Processing”Reducer methods are called for each event matching the event type:
- Event Type Matching - Chronicle calls the method that handles the specific event type
- Event Source ID - Each method receives events for a single event source
- Sequential Processing - Events are processed in the order they were appended
The current parameter in each method:
- Is
nullwhen no previous state exists for this event source - Contains the current persisted state for this event source
Best Practices
Section titled “Best Practices”- Keep reducers pure - Avoid side effects; only compute state from events
- Handle null current state - Always check if
currentis null for the initial state - Use immutable state - Create new instances rather than mutating the current state
- Process events in order - The events are provided in sequence order; respect that order
- Consider performance - For large event streams, optimize your reduction logic
Next Steps
Section titled “Next Steps”- Learn about Passive Reducers to control when reducers observe
- Explore Event Processing for advanced patterns