Skip to content

Event Processing

Understanding how reducers process events is crucial for building effective read models. This guide covers the event processing model, method patterns, and advanced techniques.

Reducers use convention-based method discovery. Chronicle automatically finds and invokes methods that:

  • Match the event type name (e.g., OnOrderCreated for OrderCreated events)
  • Accept the event type as the first parameter
  • Accept the current read model state (nullable) as the second parameter
  • Optionally accept EventContext as the third parameter
public record OrderSummary(Guid OrderId, decimal Total, DateTimeOffset LastUpdated);
public class OrderSummaryReducer : IReducerFor<OrderSummary>
{
public OrderSummary OnOrderCreated(OrderCreated @event, OrderSummary? current, EventContext context)
{
return new OrderSummary(@event.OrderId, 0m, context.Occurred);
}
public OrderSummary OnItemAdded(ItemAdded @event, OrderSummary? current, EventContext context)
{
if (current is null) return null!; // Skip if no order exists
return current with
{
Total = current.Total + @event.Price,
LastUpdated = context.Occurred
};
}
}

Each reducer method is called for a single event source:

  • Events for the same event source (e.g., the same order) are processed sequentially
  • The current parameter contains the current state for that specific event source
  • Each event source has its own independent state

Events are guaranteed to be processed in sequence order:

  1. Events are ordered by their sequence number
  2. Each method is called once per event
  3. The return value becomes the current parameter for the next event

The simplest pattern accepts the event and current state:

public TReadModel OnEventName(EventType @event, TReadModel? current)
{
// Process event and return new state
return newState;
}

Access event metadata by adding EventContext parameter:

public TReadModel OnEventName(EventType @event, TReadModel? current, EventContext context)
{
// Access occurred time, correlation ID, etc.
return newState;
}

Both patterns support async methods:

// Async without context
public Task<TReadModel> OnEventName(EventType @event, TReadModel? current)
{
return Task.FromResult(newState);
}
// Async with context
public async Task<TReadModel> OnEventName(EventType @event, TReadModel? current, EventContext context)
{
// Perform async operations
return await ComputeStateAsync(@event, current);
}

The EventContext provides metadata about the event:

public record EventContext
{
public EventSequenceNumber SequenceNumber { get; }
public EventSourceId EventSourceId { get; }
public EventType EventType { get; }
public DateTimeOffset Occurred { get; }
public CorrelationId CorrelationId { get; }
public IEnumerable<Causation> Causation { get; }
public Identity CausedBy { get; }
// ... and more
}
public OrderSummary OnOrderPlaced(OrderPlaced @event, OrderSummary? current, EventContext context)
{
return new OrderSummary(
OrderId: @event.OrderId,
Total: @event.Amount,
PlacedAt: context.Occurred,
PlacedBy: context.CausedBy.ToString(),
CorrelationId: context.CorrelationId);
}

The current parameter represents the previously computed state for this event source.

When processing the first event for an event source:

  • current is null
  • Initialize your read model with appropriate values
  • You can return null! to skip creating state for certain events
public Analytics OnDataRecorded(DataRecorded @event, Analytics? current, EventContext context)
{
if (current is null)
{
// First event - initialize state
return new Analytics(
EventCount: 1,
FirstEventTime: context.Occurred,
LastEventTime: context.Occurred,
TotalValue: @event.Value);
}
// Update existing state
return current with
{
EventCount = current.EventCount + 1,
LastEventTime = context.Occurred,
TotalValue = current.TotalValue + @event.Value
};
}

For subsequent events:

  • current contains the state from the previous event
  • Use record’s with expression to create modified copies
  • Return the new state

Accumulate values across events:

public record Statistics(decimal Sum, int Count, decimal Average);
public class StatisticsReducer : IReducerFor<Statistics>
{
public Statistics OnMetricRecorded(MetricRecorded @event, Statistics? current)
{
var sum = (current?.Sum ?? 0) + @event.Value;
var count = (current?.Count ?? 0) + 1;
return new Statistics(sum, count, sum / count);
}
}

Track state changes through events:

public record OrderStatus(string State, DateTimeOffset LastUpdated);
public class OrderStatusReducer : IReducerFor<OrderStatus>
{
public OrderStatus OnOrderCreated(OrderCreated @event, OrderStatus? current, EventContext context)
=> new OrderStatus("Created", context.Occurred);
public OrderStatus OnOrderPaid(OrderPaid @event, OrderStatus? current, EventContext context)
=> new OrderStatus("Paid", context.Occurred);
public OrderStatus OnOrderShipped(OrderShipped @event, OrderStatus? current, EventContext context)
=> new OrderStatus("Shipped", context.Occurred);
public OrderStatus OnOrderDelivered(OrderDelivered @event, OrderStatus? current, EventContext context)
=> new OrderStatus("Delivered", context.Occurred);
public OrderStatus OnOrderCancelled(OrderCancelled @event, OrderStatus? current, EventContext context)
=> new OrderStatus("Cancelled", context.Occurred);
}

Build collections from events:

public record Activity(string Type, DateTimeOffset Timestamp, string Description);
public record CustomerActivityLog(List<Activity> Activities);
public class CustomerActivityLogReducer : IReducerFor<CustomerActivityLog>
{
public CustomerActivityLog OnCustomerAction(CustomerAction @event, CustomerActivityLog? current, EventContext context)
{
var activities = current?.Activities ?? new List<Activity>();
activities.Add(new Activity(
@event.Type,
context.Occurred,
@event.Description));
return new CustomerActivityLog(activities);
}
}

Aggregate events within time windows:

public record HourlyMetrics(Dictionary<int, decimal> MetricsByHour);
public class HourlyMetricsReducer : IReducerFor<HourlyMetrics>
{
public HourlyMetrics OnMetricRecorded(MetricRecorded @event, HourlyMetrics? current, EventContext context)
{
var metricsByHour = current?.MetricsByHour ?? new Dictionary<int, decimal>();
var hour = context.Occurred.Hour;
if (!metricsByHour.ContainsKey(hour))
metricsByHour[hour] = 0;
metricsByHour[hour] += @event.Value;
return new HourlyMetrics(metricsByHour);
}
}

Skip processing based on conditions:

public record Account(Guid AccountId, decimal Balance, bool IsActive);
public class AccountReducer : IReducerFor<Account>
{
public Account OnAccountOpened(AccountOpened @event, Account? current, EventContext context)
{
return new Account(@event.AccountId, 0m, true);
}
public Account OnDepositMade(DepositMade @event, Account? current, EventContext context)
{
// Skip if account doesn't exist or is not active
if (current is null || !current.IsActive) return null!;
return current with { Balance = current.Balance + @event.Amount };
}
public Account OnAccountClosed(AccountClosed @event, Account? current, EventContext context)
{
if (current is null) return null!;
return current with { IsActive = false };
}
}

Return null! to skip creating/updating state:

public OrderSummary OnItemAdded(ItemAdded @event, OrderSummary? current, EventContext context)
{
// Can't add items if order doesn't exist
if (current is null) return null!;
return current with
{
Total = current.Total + @event.Price
};
}

Include error information in your read model:

public record ValidationResult(bool IsValid, List<string> Errors);
public class ValidationResultReducer : IReducerFor<ValidationResult>
{
public ValidationResult OnInvalidDataDetected(InvalidDataDetected @event, ValidationResult? current)
{
var errors = current?.Errors ?? new List<string>();
errors.Add(@event.Reason);
return new ValidationResult(false, errors);
}
}

Leverage record types with with expressions:

// Efficient - only creates new object when needed
public Stats OnMetricRecorded(MetricRecorded @event, Stats? current)
{
if (current is null)
return new Stats(Count: 1, Sum: @event.Value);
return current with
{
Count = current.Count + 1,
Sum = current.Sum + @event.Value
};
}

Be mindful of collection modifications:

public record ItemList(List<Item> Items);
public ItemList OnItemAdded(ItemAdded @event, ItemList? current)
{
// Reuse existing list
var items = current?.Items ?? new List<Item>();
items.Add(new Item(@event.ItemId, @event.Name));
return new ItemList(items);
}
  1. Use record types - Prefer immutable record types for read models
  2. Keep logic pure - Avoid side effects; only compute state from events
  3. Handle null safely - Always check current for null on first event
  4. Use with expressions - Leverage record’s with for clean state updates
  5. Return null! to skip - Use null! to skip creating/updating state
  6. Access context when needed - Use EventContext for metadata like timestamps
  7. Name methods clearly - Use descriptive method names that match event types
  8. Test thoroughly - Unit test with various event sequences and edge cases