Event Processing
This guide explains how reactors discover methods, which signatures are supported, and how event context and errors are handled.
Event Method Discovery
Section titled “Event Method Discovery”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].
When more than one method matches
Section titled “When more than one method matches”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:
- 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.
- 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.
- 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.
Supported Signatures
Section titled “Supported Signatures”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);}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class EventProcessingSignaturesOrderPlaced(val orderId: String)
@EventTypedata class EventProcessingSignaturesOrderShipped(val orderId: String)
@EventTypedata class EventProcessingSignaturesOrderCancelled(val orderId: String)
@EventTypedata class EventProcessingSignaturesRefundIssued(val orderId: String, val amount: Double)
@EventTypedata class EventProcessingSignaturesOrderArchived(val orderId: String)
@Reactorclass EventProcessingSignaturesReactor { // (event) - no metadata needed, no side effect fun placed(event: EventProcessingSignaturesOrderPlaced) { }
// (event, context) - suspend, no side effect suspend fun shipped(event: EventProcessingSignaturesOrderShipped, context: EventContext) { }
// (event) - returns a single side-effect event fun cancelled(event: EventProcessingSignaturesOrderCancelled) = EventProcessingSignaturesOrderArchived(event.orderId)
// (event, context) - suspend, returns a list of side-effect events suspend fun refundIssued( event: EventProcessingSignaturesRefundIssued, context: EventContext ): List<Any> = listOf(EventProcessingSignaturesOrderArchived(event.orderId))}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
import java.util.List;
@EventTyperecord EventProcessingSignaturesOrderPlaced(String orderId) {}
@EventTyperecord EventProcessingSignaturesOrderShipped(String orderId) {}
@EventTyperecord EventProcessingSignaturesOrderCancelled(String orderId) {}
@EventTyperecord EventProcessingSignaturesRefundIssued(String orderId, double amount) {}
@EventTyperecord EventProcessingSignaturesOrderArchived(String orderId) {}
@Reactorclass EventProcessingSignaturesReactor { // (event) - no metadata needed, no side effect void placed(EventProcessingSignaturesOrderPlaced event) { }
// (event, context) - no side effect void shipped(EventProcessingSignaturesOrderShipped event, EventContext context) { }
// (event) - returns a single side-effect event EventProcessingSignaturesOrderArchived cancelled(EventProcessingSignaturesOrderCancelled event) { return new EventProcessingSignaturesOrderArchived(event.orderId()); }
// (event, context) - returns a list of side-effect events List<Object> refundIssued(EventProcessingSignaturesRefundIssued event, EventContext context) { return List.of(new EventProcessingSignaturesOrderArchived(event.orderId())); }}defmodule ReactorSupportedSignaturesOrderPlaced do use Chronicle.Events.EventType, id: "reactor-supported-signatures-order-placed"
defstruct [:order_id]end
defmodule ReactorSupportedSignaturesReactor do use Chronicle.Reactors.Reactor
alias ReactorSupportedSignaturesOrderPlaced
@handles ReactorSupportedSignaturesOrderPlaced
# Every reactor handler has exactly this one shape: the event, then the # context map. There is no family of overloads to choose between — # dispatch always matches on the event's struct type via @handles. @impl true def handle(%ReactorSupportedSignaturesOrderPlaced{} = event, context) do process(event.order_id, Map.get(context, :occurred))
:ok end
defp process(_order_id, _occurred), do: :okendimport { EventContext } from '@cratis/chronicle';
interface ReactorHandlerSignatures<TEvent, TResult> { methodName(event: TEvent): void; methodName(event: TEvent, context: EventContext): void;
methodNameAsync(event: TEvent): Promise<void>; methodNameAsync(event: TEvent, context: EventContext): Promise<void>;
methodNameReturningAsync(event: TEvent): Promise<TResult>; methodNameReturningAsync(event: TEvent, context: EventContext): Promise<TResult>;
methodNameReturning(event: TEvent): TResult; methodNameReturning(event: TEvent, context: EventContext): TResult;}TResult can be an event type or IEnumerable<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).
Event Context
Section titled “Event Context”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) { }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class ReactorAccountClosed(val accountId: String)
@Reactorclass AuditReactor { fun accountClosed(event: ReactorAccountClosed, context: EventContext) { writeAudit(event.accountId, context.occurred, context.eventSourceId) }
private fun writeAudit(accountId: String, occurred: java.time.Instant, eventSourceId: String) {}}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;import java.time.Instant;
@EventTyperecord ReactorAccountClosed(String accountId) {}
@Reactorclass AuditReactor { void accountClosed(ReactorAccountClosed event, EventContext context) { writeAudit(event.accountId(), context.getOccurred(), context.getEventSourceId()); }
private void writeAudit(String accountId, Instant occurred, String eventSourceId) {}}defmodule MyApp.Events.ReactorAccountClosed do use Chronicle.Events.EventType, id: "reactor-account-closed"
defstruct [:account_id]end
defmodule MyApp.Reactors.AuditReactor do use Chronicle.Reactors.Reactor
alias MyApp.Events.ReactorAccountClosed
@handles ReactorAccountClosed
@impl true def handle(%ReactorAccountClosed{} = event, context) do write_audit(event.account_id, Map.get(context, :occurred), Map.get(context, :event_source_id))
:ok end
defp write_audit(_account_id, _occurred, _event_source_id), do: :okendimport { EventContext, eventType, reactor } from '@cratis/chronicle';
@eventType()class ReactorAccountClosed { constructor(readonly accountId: string) {}}
@reactor()class AuditReactor { // Method name must be the exact camelCase of the event's class name - // Chronicle discovers handlers by name, not by parameter type. async reactorAccountClosed(event: ReactorAccountClosed, context: EventContext): Promise<void> { this.writeAudit(event.accountId, context.occurred, context.eventSourceId); }
private writeAudit(accountId: string, occurred: Date, eventSourceId: string): void {}}Taking Dependencies
Section titled “Taking Dependencies”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.Reactors;
public interface IReactorShippingService{ Task Schedule(EventProcessingOrder order, decimal price);}
public interface IReactorPricingService{ decimal PriceFor(EventProcessingOrder order);}
[EventType]public record EventProcessingOrderPlaced;
public record EventProcessingOrder(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)); }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactorimport io.cratis.chronicle.readModels.ReadModel
@EventTypedata class EventProcessingOrderPlaced(val orderId: String)
@ReadModeldata class EventProcessingOrder(val id: String = "", val total: Double = 0.0)
interface EventProcessingShippingService { suspend fun schedule(order: EventProcessingOrder)}
@Reactorclass EventProcessingOrderProcessor(private val shipping: EventProcessingShippingService) { // `order` is resolved by Chronicle itself: any parameter whose type carries @ReadModel is // materialized on demand, keyed by the triggering event's EventSourceId - strongly consistent // as of this handler call. It is null until something has been projected for that key. suspend fun orderPlaced(event: EventProcessingOrderPlaced, order: EventProcessingOrder?, context: EventContext) { if (order != null) shipping.schedule(order) }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;import io.cratis.chronicle.readModels.ReadModel;
@EventTyperecord EventProcessingOrderPlaced(String orderId) {}
@ReadModelrecord EventProcessingOrder(String id, double total) { EventProcessingOrder() { this("", 0.0); }}
interface EventProcessingShippingService { void schedule(EventProcessingOrder order);}
@Reactorclass EventProcessingOrderProcessor { private final EventProcessingShippingService shipping;
EventProcessingOrderProcessor(EventProcessingShippingService shipping) { this.shipping = shipping; }
// `order` is resolved by Chronicle itself: any parameter whose type carries @ReadModel is // materialized on demand, keyed by the triggering event's EventSourceId - strongly consistent // as of this handler call. It is null until something has been projected for that key. void orderPlaced(EventProcessingOrderPlaced event, EventProcessingOrder order, EventContext context) { if (order != null) { shipping.schedule(order); } }}Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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 strongly-consistent read models) into the handler call.
Each parameter after the event resolves as follows:
- An
EventContextparameter receives the event context; its position does not matter. - A read model — a type with a reducer or projection — is materialized on demand from that reducer or projection, making it strongly consistent when the reactor runs. Read models are resolved directly by Chronicle, never through the service provider.
- Any other type is resolved from the service provider.
Resolving the read model key
Section titled “Resolving the read model key”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.Reactors;using Cratis.Chronicle.ReadModels;
[EventType]public record ReactorOrderLineAdded(string OrderId);
public record ReactorOrderLine(string Id, string ProductId, int Quantity);
public class OrderLineProcessingReactor : IReactor, ICanResolveReadModelKey{ public ReadModelKey Resolve(object @event, EventContext context) => ((ReactorOrderLineAdded)@event).OrderId;
public Task OrderLineAdded(ReactorOrderLineAdded @event, ReactorOrderLine order) => Task.CompletedTask;}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
Event Source Isolation
Section titled “Event Source Isolation”Events are delivered per event source and in sequence order. Each reactor method is called for the specific event source that produced the event.
Error Handling
Section titled “Error Handling”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.