React to an event
Goal: when something happens — a book is returned, an order ships — you need to do something: notify a member, call an external API, or record a follow-up fact. That’s a reactor.
Write the reactor
Section titled “Write the reactor”A reactor is a class implementing the marker interface IReactor. You don’t implement a method from it — you write a method whose first parameter is the event type you want to handle, and Chronicle routes matching events to it:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventType]public record ScenariosReactBookReturned(string Isbn);
public interface IScenariosReactNotificationService{ Task NotifyNextInLine(EventSourceId bookId); Task NotifyNextInLine(EventSourceId bookId, string bookTitle);}
public class ScenariosReactWaitlistNotifier(IScenariosReactNotificationService notifications) : IReactor{ public async Task BookReturned(ScenariosReactBookReturned @event, EventContext context) { // context.EventSourceId is the source the event happened to (the book) await notifications.NotifyNextInLine(context.EventSourceId); }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class ScenariosReactBookReturned(val isbn: String)
interface ScenariosReactNotificationService { fun notifyNextInLine(bookId: String) fun notifyNextInLine(bookId: String, bookTitle: String)}
@Reactorclass ScenariosReactWaitlistNotifier(private val notifications: ScenariosReactNotificationService) { fun bookReturned(event: ScenariosReactBookReturned, context: EventContext) { // context.eventSourceId is the source the event happened to (the book) notifications.notifyNextInLine(context.eventSourceId) }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
@EventTyperecord ScenariosReactBookReturned(String isbn) {}
interface ScenariosReactNotificationService { void notifyNextInLine(String bookId);
void notifyNextInLine(String bookId, String bookTitle);}
@Reactorclass ScenariosReactWaitlistNotifier { private final ScenariosReactNotificationService notifications;
ScenariosReactWaitlistNotifier(ScenariosReactNotificationService notifications) { this.notifications = notifications; }
void bookReturned(ScenariosReactBookReturned event, EventContext context) { // context.getEventSourceId() is the source the event happened to (the book) notifications.notifyNextInLine(context.getEventSourceId()); }}defmodule MyApp.Events.ScenariosReactBookReturned do use Chronicle.Events.EventType, id: "scenarios-react-book-returned"
defstruct [:isbn]end
defmodule MyApp.ScenariosReactNotificationService do def notify_next_in_line(_book_id), do: :ok def notify_next_in_line(_book_id, _book_title), do: :okend
defmodule MyApp.Reactors.ScenariosReactWaitlistNotifier do use Chronicle.Reactors.Reactor
alias MyApp.Events.ScenariosReactBookReturned alias MyApp.ScenariosReactNotificationService
@handles ScenariosReactBookReturned
@impl true def handle(%ScenariosReactBookReturned{}, context) do # context.event_source_id is the source the event happened to (the book) ScenariosReactNotificationService.notify_next_in_line(Map.get(context, :event_source_id)) :ok endendimport { EventContext, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ScenariosReactBookReturned { @field(String) readonly isbn: string;
constructor(isbn: string) { this.isbn = isbn; }}
interface ScenariosReactNotificationService { notifyNextInLine(bookId: string): Promise<void>; notifyNextInLine(bookId: string, bookTitle: string): Promise<void>;}
@reactor()class ScenariosReactWaitlistNotifier { constructor(private readonly notifications: ScenariosReactNotificationService) {}
// Method name must be the exact camelCase of the event's class name - // Chronicle discovers handlers by name, not by parameter type. @onceOnly() async scenariosReactBookReturned(event: ScenariosReactBookReturned, context: EventContext): Promise<void> { // context.eventSourceId is the source the event happened to (the book) await this.notifications.notifyNextInLine(context.eventSourceId); }}Chronicle discovers it by convention — no registration.
Be safe to repeat
Section titled “Be safe to repeat”A reactor may run more than once for the same event (replay, recovery, redeploy). Make the side effect idempotent — record that it happened and skip if it already did. For a side effect that must never run again during a replay — a welcome email, a payment call — mark the handler (or the whole class) with [OnceOnly] and Chronicle excludes it from replays. See OnceOnly.
In C#, events a handler returns are already protected on redelivery: Chronicle keys them from the delivery, so recovery and catch-up do not append them twice. External side effects (an email, a payment call) are not protected that way and still need a receipt under the delivery identity. If you change what a handler returns and want replays to append the new shape, raise the reactor’s effect epoch.
Need state? Pick the right consistency
Section titled “Need state? Pick the right consistency”Reach for the event first — it carries the truth of what happened, and context.EventSourceId tells you what it happened to. But some reactions genuinely need more state. Say the notification should include the book’s title — that lives in the read model, not the event.
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public record ScenariosReactBook(string Title);
public class ScenariosReactWaitlistNotifierWithTitle(IEventStore eventStore, IScenariosReactNotificationService notifications) : IReactor{ public async Task BookReturned(ScenariosReactBookReturned @event, EventContext context) { // Strongly consistent — rebuilt from the event log, includes this event var book = await eventStore.ReadModels.GetInstanceById<ScenariosReactBook>(context.EventSourceId); await notifications.NotifyNextInLine(context.EventSourceId, book.Title); }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reactorimport io.cratis.chronicle.readModels.ReadModel
@ReadModeldata class ScenariosReactBook(val title: String = "")
@Reactorclass ScenariosReactWaitlistNotifierWithTitle(private val notifications: ScenariosReactNotificationService) { // Looked up by the triggering event's event source id; a materialized read model can lag this event or be null. fun bookReturned(event: ScenariosReactBookReturned, context: EventContext, book: ScenariosReactBook?) { notifications.notifyNextInLine(context.eventSourceId, book?.title ?: "") }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.Reactor;import io.cratis.chronicle.readModels.ReadModel;
@ReadModelrecord ScenariosReactBook(String title) { ScenariosReactBook() { this(""); }}
@Reactorclass ScenariosReactWaitlistNotifierWithTitle { private final ScenariosReactNotificationService notifications;
ScenariosReactWaitlistNotifierWithTitle(ScenariosReactNotificationService notifications) { this.notifications = notifications; }
// Looked up by the triggering event's event source id; a materialized read model can lag this event or be null. void bookReturned(ScenariosReactBookReturned event, EventContext context, ScenariosReactBook book) { notifications.notifyNextInLine(context.getEventSourceId(), book != null ? book.title() : ""); }}defmodule MyApp.ReadModels.ScenariosReactBook do use Chronicle.ReadModels.ReadModel
defstruct title: ""end
defmodule MyApp.Reactors.ScenariosReactWaitlistNotifierWithTitle do use Chronicle.Reactors.Reactor
alias MyApp.Events.ScenariosReactBookReturned alias MyApp.ReadModels.ScenariosReactBook alias MyApp.ScenariosReactNotificationService
@handles ScenariosReactBookReturned
@impl true def handle(%ScenariosReactBookReturned{}, %{event_source_id: book_id}) do # Strongly consistent — rebuilt from the event log, includes this event {:ok, book} = Chronicle.read_model(ScenariosReactBook, book_id) ScenariosReactNotificationService.notify_next_in_line(book_id, book.title) :ok endendimport { EventContext, ReactorServices, readModel, reactor } from '@cratis/chronicle';
@readModel()class ScenariosReactBook { title = '';}
@reactor()class ScenariosReactWaitlistNotifierWithTitle { // Constructor injection requires an artifactActivator in ChronicleOptions. constructor(private readonly notifications: ScenariosReactNotificationService) {}
async scenariosReactBookReturned(event: ScenariosReactBookReturned, context: EventContext, services: ReactorServices): Promise<void> { // Eventually consistent: this read may not yet include the triggering event. const book = await services.readModels.findInstanceById(ScenariosReactBook, context.eventSourceId); if (book === null) throw new Error(`Book ${context.eventSourceId} is not available yet`); await this.notifications.notifyNextInLine(context.eventSourceId, book.title); }}The full menu of read APIs — single, all, paged, observed — is in Get read models.
Appending an event
Section titled “Appending an event”The common “translation” pattern — react to one slice’s event by recording a new fact. The simplest way is to return the event: Chronicle appends it to the event log for you, against the triggering event’s EventSourceId:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
[EventType]public record ScenariosReactBookReserved(string Isbn);
[EventType]public record ScenariosReactStockDecreased(string Isbn, int Quantity);
public class ScenariosReactStockKeeping : IReactor{ // Without [OnceOnly], a replay of this reactor appends StockDecreased again. [OnceOnly] public ScenariosReactStockDecreased BookReserved(ScenariosReactBookReserved @event, EventContext context) => new(@event.Isbn, 1);}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reactor
@EventTypedata class ScenariosReactBookReserved(val isbn: String)
@EventTypedata class ScenariosReactStockDecreased(val isbn: String, val quantity: Int)
// Returning an event from a handler appends it as a side effect - against the event source that// triggered the reactor - with no event log dependency in sight.@Reactorclass ScenariosReactStockKeeping { fun bookReserved(event: ScenariosReactBookReserved, context: EventContext): ScenariosReactStockDecreased = ScenariosReactStockDecreased(event.isbn, 1)}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.Reactor;
@EventTyperecord ScenariosReactBookReserved(String isbn) {}
@EventTyperecord ScenariosReactStockDecreased(String isbn, int quantity) {}
// Returning an event from a handler appends it as a side effect - against the event source that// triggered the reactor - with no event log dependency in sight.@Reactorclass ScenariosReactStockKeeping { ScenariosReactStockDecreased bookReserved(ScenariosReactBookReserved event, EventContext context) { return new ScenariosReactStockDecreased(event.isbn(), 1); }}defmodule MyApp.Events.ScenariosReactReturnBookReserved do use Chronicle.Events.EventType, id: "scenarios-react-return-book-reserved"
defstruct [:isbn]end
defmodule MyApp.Events.ScenariosReactReturnStockDecreased do use Chronicle.Events.EventType, id: "scenarios-react-return-stock-decreased"
defstruct [:isbn, :quantity]end
defmodule MyApp.Reactors.ScenariosReactReturnStockKeeping do # Without `once_only`, a replay of this reactor appends StockDecreased again. use Chronicle.Reactors.Reactor, once_only: [MyApp.Events.ScenariosReactReturnBookReserved]
alias MyApp.Events.{ScenariosReactReturnBookReserved, ScenariosReactReturnStockDecreased}
@handles ScenariosReactReturnBookReserved
# The returned event is appended to the same event source as a side effect. @impl true def handle(%ScenariosReactReturnBookReserved{isbn: isbn}, _context) do {:ok, %ScenariosReactReturnStockDecreased{isbn: isbn, quantity: 1}} endendimport { EventContext, eventType, onceOnly, reactor } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ScenariosReactBookReserved { @field(String) readonly isbn: string;
constructor(isbn: string = '') { this.isbn = isbn; }}
@eventType()class ScenariosReactStockDecreased { @field(String) readonly isbn: string; @field(Number) readonly quantity: number;
constructor(isbn: string = '', quantity: number = 0) { this.isbn = isbn; this.quantity = quantity; }}
@reactor()class ScenariosReactStockKeeping { // Without @onceOnly(), a replay of this reactor appends StockDecreased again. // Returning an event appends it for you, targeting the triggering event's own // event source id, stream, and subject - the simplest way to record a follow-up fact. @onceOnly() async scenariosReactBookReserved(event: ScenariosReactBookReserved, context: EventContext): Promise<ScenariosReactStockDecreased> { return new ScenariosReactStockDecreased(event.isbn, 1); }}Task<StockDecreased> and collections of events work too, and you can control the target event source — see Returning side effects. Auto-appending a returned event is C#-only — every other client appends explicitly instead, shown next. In C# the returned events are appended with idempotency keys, see Duplicate protection; a ReactorScenario records returned events by default, and with the real side-effect handlers the in-process testing package appends them with keys too.
When you want to inspect the outcome yourself, append explicitly and handle the AppendResult — and throw if it failed, so the partition pauses instead of the fact being silently lost:
using Cratis.Chronicle;using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class ScenariosReactStockCouldNotBeDecreased(string isbn) : Exception($"Stock could not be decreased for ISBN {isbn}");
public class ScenariosReactStockKeepingExplicit(IEventStore eventStore) : IReactor{ public async Task BookReserved(ScenariosReactBookReserved @event, EventContext context) { var result = await eventStore.EventLog.Append( context.EventSourceId, new ScenariosReactStockDecreased(@event.Isbn, 1)); if (!result.IsSuccess) { throw new ScenariosReactStockCouldNotBeDecreased(@event.Isbn); } }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ScenariosReactBookReserved do use Chronicle.Events.EventType, id: "scenarios-react-book-reserved"
defstruct [:isbn]end
defmodule MyApp.Events.ScenariosReactStockDecreased do use Chronicle.Events.EventType, id: "scenarios-react-stock-decreased"
defstruct [:isbn, :quantity]end
defmodule MyApp.ScenariosReactStockCouldNotBeDecreased do defexception [:message]
def exception(isbn) do %__MODULE__{message: "Stock could not be decreased for ISBN #{isbn}"} endend
defmodule MyApp.Reactors.ScenariosReactStockKeepingExplicit do use Chronicle.Reactors.Reactor
alias MyApp.Events.{ScenariosReactBookReserved, ScenariosReactStockDecreased} alias MyApp.ScenariosReactStockCouldNotBeDecreased
@handles ScenariosReactBookReserved
@impl true def handle(%ScenariosReactBookReserved{isbn: isbn}, %{event_source_id: event_source_id}) do case Chronicle.append(event_source_id, %ScenariosReactStockDecreased{isbn: isbn, quantity: 1}) do :ok -> :ok {:error, _reason} -> raise ScenariosReactStockCouldNotBeDecreased, isbn end endendimport { EventContext, ReactorServices, onceOnly, reactor } from '@cratis/chronicle';
@reactor()class ScenariosReactStockKeepingExplicit { @onceOnly() async scenariosReactBookReserved(event: ScenariosReactBookReserved, context: EventContext, services: ReactorServices): Promise<void> { const result = await services.eventStore.eventLog.append( context.eventSourceId, new ScenariosReactStockDecreased(event.isbn, 1)); if (!result.isSuccess) { throw new Error(`Stock could not be decreased for ISBN ${event.isbn}`); } }}See also
Section titled “See also”- Reactors — the full reactor model, filtering, and once-only handling.
- Returning side effects — every supported return shape and metadata control.
- Projections, reducers, and reactors — when a reactor is the right tool vs. building state.