Skip to content

Event Processing

This guide explains how reactors discover methods, which signatures are supported, and how event context and errors are handled.

Reactors use convention-based method discovery. Chronicle finds and invokes public methods that:

  • Accept the event type as the first parameter
  • Optionally accept further dependency parameters (the EventContext, read models, or services)
  • Return void, Task, Task<T>, or a synchronous side-effect type (see below)

Event parameter types must be marked with [EventType].

Discovery matches on the first parameter type, so a helper you extract out of a handler for readability — say a Task<string> Enrich(OrderPlaced @event) that the handler awaits — matches the same event type the handler does. When that happens, Chronicle picks one method per event type, in this order:

  1. A public method beats a non-public one. Handlers are public by convention; anything else is an implementation detail, so a private helper never displaces the handler that calls it.
  2. The richest signature wins. Between two methods of the same accessibility, the one taking the most parameters asked for the most context, so it is the more specific handler.
  3. Method name, ordinal, so a genuine tie resolves the same way on every run rather than being left to reflection order.

A private method is still a valid handler when nothing else handles that event type — the precedence only decides between candidates, it does not disqualify any of them. If a helper is not meant to take part in dispatch at all, give it a first parameter that is not an event type.

using Cratis.Chronicle.Events;
public interface ReactorHandlerSignatures<TEvent, TResult>
{
void MethodName(TEvent @event);
void MethodName(TEvent @event, EventContext context);
Task MethodNameAsync(TEvent @event);
Task MethodNameAsync(TEvent @event, EventContext context);
Task<TResult> MethodNameReturningAsync(TEvent @event);
Task<TResult> MethodNameReturningAsync(TEvent @event, EventContext context);
TResult MethodNameReturning(TEvent @event);
TResult MethodNameReturning(TEvent @event, EventContext context);
}

View C# snippet source on GitHub

TResult can be an event type or a sequence of known events: IEnumerable<TEvent>, TEvent[], List<TEvent>, or IReadOnlyList<TEvent>. See Returning Side Effects for the full list of supported return types and metadata resolution.

This overload table is specific to C#‘s method-overload-based dispatch. Kotlin, Elixir, and TypeScript each use one canonical handler shape instead of a family of overloads — see Getting started with reactors for what that shape looks like in each client. None of them auto-append a handler’s return value the way C# does (see Returning side effects).

The optional EventContext parameter provides metadata about the event, including the event source ID, sequence number, timestamps, and correlation identifiers.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
[EventType]
public record ReactorAccountClosed(Guid AccountId);
public class AuditReactor : IReactor
{
public void AccountClosed(ReactorAccountClosed @event, EventContext context)
{
WriteAudit(@event.AccountId, context.Occurred, context.EventSourceId);
}
void WriteAudit(Guid accountId, DateTimeOffset occurred, EventSourceId eventSourceId) { }
}

View C# snippet source on GitHub

Beyond the event and EventContext, a handler method can take additional parameters that Chronicle resolves when the method runs. Only the first parameter is fixed; it is the event that drives dispatch.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.Reactors;
public interface IReactorShippingService
{
Task Schedule(EventProcessingOrder order, decimal price);
}
public interface IReactorPricingService
{
decimal PriceFor(EventProcessingOrder order);
}
[EventType]
public record EventProcessingOrderPlaced(decimal Total);
// A read model is resolved by Chronicle only when a projection or reducer builds it.
[FromEvent<EventProcessingOrderPlaced>]
public record EventProcessingOrder([Key] string Id, decimal Total);
public class EventProcessingOrderProcessor(IReactorShippingService shipping) : IReactor
{
public async Task OrderPlaced(
EventProcessingOrderPlaced @event,
EventContext context,
EventProcessingOrder order,
IReactorPricingService pricing)
{
await shipping.Schedule(order, pricing.PriceFor(order));
}
}

View C# snippet source on GitHub

This is currently a C#-only capability. Kotlin, Elixir, and TypeScript reactor handlers accept only the event and, optionally, the event context — there’s no mechanism to have Chronicle resolve and inject further parameters (services or read models) into the handler call.

Each parameter after the event resolves as follows. A type counts as a read model only when a projection or reducer builds it; any other type, including a plain record, is requested from the service provider:

  • An EventContext parameter receives the event context; its position does not matter.
  • A read model — a type with a reducer or projection — is resolved directly by Chronicle, never through the service provider. A materialized read model (the default) is read from its sink, so it is eventually consistent and may not yet include the event being handled. Mark it [Passive] when the handler needs it strongly consistent: Chronicle then computes it from the events when the reactor runs. That suits a read model keyed by its event source id; a passive read of a projection that joins other event sources or uses a custom key misses their events.
  • Any other type is resolved from the service provider.

By default the read model is materialized using the EventSourceId from the event context. When the key differs, implement ICanResolveReadModelKey on the reactor to return the ReadModelKey to use:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
using Cratis.Chronicle.Reactors;
using Cratis.Chronicle.ReadModels;
[EventType]
public record ReactorOrderLineOrderPlaced(string CustomerId);
[EventType]
public record ReactorOrderLineAdded(string OrderId);
// The order read model is keyed by the order's event source id.
[FromEvent<ReactorOrderLineOrderPlaced>]
public record ReactorOrder([Key] string Id, string CustomerId);
public class OrderLineProcessingReactor : IReactor, ICanResolveReadModelKey
{
public ReadModelKey Resolve(object @event, EventContext context) =>
((ReactorOrderLineAdded)@event).OrderId;
// The line's own event source is not the order, so the key comes from the event.
public Task OrderLineAdded(ReactorOrderLineAdded @event, ReactorOrder order) =>
Task.CompletedTask;
}

View C# snippet source on GitHub

The resolved key applies to every read model parameter across all of the reactor’s handler methods. This is also currently C#-only, since it exists to customize the read-model-parameter resolution described above.

Events are delivered per event source and in sequence order. Each reactor method is called for the specific event source that produced the event.

Concurrency Across Partitions (.NET client)

Section titled “Concurrency Across Partitions (.NET client)”

Within one partition the .NET client runs batches strictly in arrival order. Different partitions of the same reactor can now be handled at the same time, up to a bound. Handlers for different event sources can therefore overlap, so shared state that is not thread-safe (for example a singleton service) must be safe for concurrent use. There is no ordering across partitions. Replay begin and end notifications wait for work in flight, and partition replay notifications run in order with that partition’s events. Results are still reported to the Kernel one at a time.

The bound is Reactors.MaxConcurrentPartitions in ChronicleOptions and defaults to the processor count, at most 8. Set it to 1 to restore the previous behavior of handling one batch at a time.

If a reactor method throws an exception, the failing event source partition is marked as failed and processing for that partition is paused. Once the underlying issue is resolved, processing resumes from the last successful event.

  • CHR0004 - Reactor method signatures
  • CHR0005 - Event parameters require [EventType]