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
@EventType(id = "scenarios-react-book-returned")data 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;
@EventType(id = "scenarios-react-book-returned")record 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, reactor } from '@cratis/chronicle';
@eventType()class ScenariosReactBookReturned { constructor(readonly isbn: string) {}}
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. 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.
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); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.ReadModels.ScenariosReactBook do 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 endendTypeScript does not support this workflow yet.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{ public ScenariosReactStockDecreased BookReserved(ScenariosReactBookReserved @event, EventContext context) => new(@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.
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 endendTypeScript does not support this workflow yet.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.