Reducers
Reducers are specialized observers in Chronicle that automatically create and maintain read models by reacting to events. They provide a declarative way to build the current state from event streams without having to manually manage database operations.
Purpose
Reducers serve as the bridge between your event-driven domain and read models. Unlike reactors which are designed for side effects and general event handling, reducers are specifically optimized for:
- State Aggregation: Building current state from historical events
- Read Model Creation: Automatically persisting computed state to the database
- Event Replay: Rebuilding state when events are replayed
- Simplified Database Management: Chronicle handles all database operations for you
How Reducers Work
When events are appended to the event log, Chronicle automatically:
- Discovers reducer methods that handle specific event types
- Invokes the appropriate reducer method with the event and current state
- Persists the returned state to the configured database
- Manages concurrency and error handling
Creating a Reducer
Step 1: Define a Read Model
First, create a class or record that represents the state you want to maintain:
public record BookInventory(
Guid Id,
string Title,
string Author,
int Quantity,
decimal Price,
DateTime LastUpdated);
Step 2: Implement the Reducer
Create a reducer class that implements IReducerFor<TReadModel>:
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reducers;
public class BookInventoryReducer : IReducerFor<BookInventory>
{
public BookInventory OnBookAdded(BookAddedToInventory @event, BookInventory? current, EventContext context)
{
return new BookInventory(
Id: context.EventSourceId.Value,
Title: @event.Title,
Author: @event.Author,
Quantity: @event.InitialQuantity,
Price: @event.Price,
LastUpdated: context.Occurred);
}
public BookInventory OnQuantityUpdated(BookQuantityUpdated @event, BookInventory? current, EventContext context)
{
if (current is null) return null!; // Skip if no existing state
return current with
{
Quantity = @event.NewQuantity,
LastUpdated = context.Occurred
};
}
public BookInventory OnPriceChanged(BookPriceChanged @event, BookInventory? current, EventContext context)
{
if (current is null) return null!; // Skip if no existing state
return current with
{
Price = @event.NewPrice,
LastUpdated = context.Occurred
};
}
}
Method Signatures
Reducer methods are discovered by convention and support the following signatures:
Synchronous Methods
// Without context
public MyReadModel MethodName(MyEvent @event, MyReadModel? current);
// With context
public MyReadModel MethodName(MyEvent @event, MyReadModel? current, EventContext context);
Asynchronous Methods
// Without context
public Task<MyReadModel> MethodName(MyEvent @event, MyReadModel? current);
// With context
public Task<MyReadModel> MethodName(MyEvent @event, MyReadModel? current, EventContext context);
Key Concepts
Current State Parameter
The current parameter contains the existing state from the database:
- Null: No previous state exists (first time processing for this event source)
- Non-null: Current state that can be updated
Event Context
The EventContext provides additional information about the event:
EventSourceId: The unique identifier for the entity the event applies toOccurred: When the event occurredSequenceNumber: The position of the event in the sequence
Return Values
- Return new/updated state: Chronicle will persist the returned object
- Return null: Chronicle will skip persistence for this event
- Throw exception: The reducer will be marked as failed for this partition
Error Handling
If a reducer method throws an exception:
- The partition (EventSourceId) is marked as failed
- No further events for that partition are processed
- Chronicle automatically retries failed partitions on a schedule
- Once resolved, processing continues from where it left off
Managing Reducer State
Getting Reducer State
// Get the current state of a specific reducer
var state = await reducers.GetStateFor<BookInventoryReducer>();
Replaying Events
// Replay all events for a reducer (rebuilds read models)
await reducers.Replay<BookInventoryReducer>();
// Replay by reducer ID
await reducers.Replay(reducerId);
Best Practices
1. Keep Reducers Pure
Reducers should be pure functions that only depend on the event and current state:
// ✅ Good - Pure function
public BookInventory OnBookAdded(BookAddedToInventory @event, BookInventory? current, EventContext context)
{
return new BookInventory(context.EventSourceId.Value, @event.Title, @event.Author);
}
// ❌ Avoid - Side effects
public BookInventory OnBookAdded(BookAddedToInventory @event, BookInventory? current, EventContext context)
{
// Don't call external services
_emailService.SendNotification("Book added");
return new BookInventory(context.EventSourceId.Value, @event.Title, @event.Author);
}
2. Handle Null State Gracefully
Always check for null current state when events might be processed out of order:
public BookInventory OnQuantityUpdated(BookQuantityUpdated @event, BookInventory? current, EventContext context)
{
// Handle case where update event arrives before creation event
if (current is null)
{
// Could return null to skip, or create a minimal state
return null!;
}
return current with { Quantity = @event.NewQuantity };
}
3. Use Immutable Read Models
Prefer records or immutable classes for read models:
// ✅ Good - Immutable record
public record BookInventory(Guid Id, string Title, int Quantity);
// ❌ Avoid - Mutable class
public class BookInventory
{
public Guid Id { get; set; }
public string Title { get; set; }
public int Quantity { get; set; }
}
4. Single Responsibility
Each reducer should focus on a single read model or bounded context:
// ✅ Good - Focused on book inventory
public class BookInventoryReducer : IReducerFor<BookInventory> { }
// ✅ Good - Separate reducer for analytics
public class BookAnalyticsReducer : IReducerFor<BookAnalytics> { }
Discovery and Registration
Reducers are automatically discovered at startup when they:
- Implement
IReducerFor<TReadModel> - Are registered in the dependency injection container
- Have public methods that match the expected signatures
Chronicle handles all the infrastructure concerns, allowing you to focus purely on the business logic of state transformation.
Comparison with Reactors
| Aspect | Reducers | Reactors |
|---|---|---|
| Purpose | Create and maintain read models | Perform side effects and general event handling |
| Database | Automatically managed by Chronicle | Manual database operations |
| State | Maintains current state | Stateless operations |
| Return Value | Returns new state to persist | No return value (void/Task) |
| Use Cases | Read models, aggregated views | Notifications, integrations, workflows |
Choose reducers when you need to build and maintain current state from events. Choose reactors when you need to perform side effects or integrate with external systems.