3. Reacting to events
Our library can record what happens and show the catalog. One thing’s missing: when a popular book comes back, the next person waiting for it should hear about it. Projections build state; for doing something — sending a notification, calling another system, kicking off a process — we reach for a reactor. Let’s write one, and meet the rules that keep it well-behaved.
In event-modeling terms this is the automation pattern — a processor watches for an event and acts. It’s the last block in our model:
A reactor is just a class that watches for an event
Section titled “A reactor is just a class that watches for an event”IReactor is a marker — there’s no method to override. Instead you write a method whose first parameter is the event you care about, and Chronicle routes matching events to it. So “when a book is returned, notify the next person” reads almost exactly like that in code:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public interface INotificationService{ Task NotifyNextInLine(EventSourceId bookId); Task NotifyNextInLine(EventSourceId bookId, string bookTitle);}
public class WaitlistNotifier(INotificationService notifications) : IReactor{ public async Task BookReturned(BookReturned @event, EventContext context) { // context.EventSourceId is the BookId this happened to await notifications.NotifyNextInLine(context.EventSourceId); }}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reactor
interface NotificationService { fun notifyNextInLine(bookId: String) fun notifyNextInLine(bookId: String, bookTitle: String)}
@Reactorclass WaitlistNotifier(private val notifications: NotificationService) { fun bookReturned(event: BookReturned, context: EventContext) { // context.eventSourceId is the bookId this happened to notifications.notifyNextInLine(context.eventSourceId) }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.Reactor;
interface NotificationService { void notifyNextInLine(String bookId); void notifyNextInLine(String bookId, String bookTitle);}
@Reactorclass WaitlistNotifier { private final NotificationService notifications;
WaitlistNotifier(NotificationService notifications) { this.notifications = notifications; }
void bookReturned(BookReturned event, EventContext context) { // context.getEventSourceId() is the bookId this happened to notifications.notifyNextInLine(context.getEventSourceId()); }}defmodule MyApp.NotificationService do def notify_next_in_line(_book_id), do: :ok def notify_next_in_line(_book_id, _book_title), do: :okend
defmodule MyApp.Reactors.WaitlistNotifier do use Chronicle.Reactors.Reactor
alias MyApp.Events.BookReturned alias MyApp.NotificationService
@handles BookReturned
@impl true def handle(%BookReturned{}, %{event_source_id: book_id}) do # book_id is the id this happened to NotificationService.notify_next_in_line(book_id) :ok endendimport { EventContext, onceOnly, reactor } from '@cratis/chronicle';
async function notifyNextInLine(bookId: string): Promise<void> { console.log(`Notify next in line for book ${bookId}`);}
@reactor()class WaitlistNotifier { // Method name must be the exact camelCase of the event's class name - // Chronicle discovers handlers by name, not by parameter type. @onceOnly() async bookReturned(event: BookReturned, context: EventContext): Promise<void> { // context.eventSourceId is the bookId this happened to await notifyNextInLine(context.eventSourceId); }}Chronicle discovers this by convention — no registration, no wiring. Drop the class in, and every BookReturned now flows to it.
Why your reactor must be safe to repeat
Section titled “Why your reactor must be safe to repeat”Here’s the rule that catches everyone once: a reactor may run more than once for the same event. During a replay, a recovery, or a redeploy, Chronicle might hand it BookReturned again. If your reactor naively emails the next member every time it runs, that member gets emailed twice. So design the side effect to be idempotent — for example, record that a notification was sent and skip it if it already was. Repeatable by design.
For side effects that must not repeat when you rebuild — a physical letter, a payment — Chronicle gives you [OnceOnly]. Put it on the reactor class, or on just one handler method, and that handler is excluded from replay: redactions, revisions, and observer rewinds all skip it.
Replay is not the only way an event comes back, though. If a handler throws, its event source’s partition pauses, and recovering that partition delivers the event again as an ordinary observation. [OnceOnly] deliberately does not suppress that retry, because re-running the handler is the point of a retry. So [OnceOnly] is not exactly-once. When a side effect must also survive a retry, record its delivery identity (.NET) as a receipt, or pass it to an API that deduplicates for you.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Reactors;
public class WaitlistNotifierOnceOnly(INotificationService notifications) : IReactor{ [OnceOnly] public async Task BookReturned(BookReturned @event, EventContext context) => await notifications.NotifyNextInLine(context.EventSourceId);}import io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.OnceOnlyimport io.cratis.chronicle.observation.Reactor
interface OverdueLetterService { fun sendOverdueLetter(bookId: String)}
@Reactorclass OverdueLetterSender(private val letters: OverdueLetterService) { // A posted letter cannot be un-posted, so this handler is excluded from every replay. @OnceOnly fun bookReturned(event: BookReturned, context: EventContext) { letters.sendOverdueLetter(context.eventSourceId) }}import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.observation.OnceOnly;import io.cratis.chronicle.observation.Reactor;
interface OverdueLetterService { void sendOverdueLetter(String bookId);}
@Reactorclass OverdueLetterSender { private final OverdueLetterService letters;
OverdueLetterSender(OverdueLetterService letters) { this.letters = letters; }
// A posted letter cannot be un-posted, so this handler is excluded from every replay. @OnceOnly void bookReturned(BookReturned event, EventContext context) { letters.sendOverdueLetter(context.getEventSourceId()); }}defmodule MyApp.OnceOnlyNotificationService do def notify_next_in_line(_book_id), do: :okend
defmodule MyApp.Reactors.WaitlistNotifierOnceOnly do # `once_only: true` is never replayed: use it for side effects such as notifications. use Chronicle.Reactors.Reactor, once_only: true
alias MyApp.Events.BookReturned alias MyApp.OnceOnlyNotificationService
@handles BookReturned
@impl true def handle(%BookReturned{}, %{event_source_id: book_id}) do OnceOnlyNotificationService.notify_next_in_line(book_id) :ok endendimport { EventContext, onceOnly, reactor } from '@cratis/chronicle';
// BookReturned is the event defined earlier in the tutorial.@reactor()class WaitlistNotifierOnceOnly { @onceOnly() async bookReturned(event: BookReturned, context: EventContext): Promise<void> { await notifyNextInLine(context.eventSourceId); }}TypeScript doesn’t currently have an equivalent to [OnceOnly] for a specific handler — it is available in C#, Kotlin, Java, and Elixir (once_only: on use Chronicle.Reactors.Reactor).
Use it deliberately, though — the same exclusion means a [OnceOnly] handler also won’t run again when you replay on purpose. Idempotent-by-design stays the default; [OnceOnly] is for the effects where “again” is worse than “never”.
Use the event, not a lookup
Section titled “Use the event, not a lookup”Notice we didn’t query anything to find out which book was returned — context.EventSourceId told us. That’s deliberate. The event carries the truth of what happened; leaning on it (instead of querying back) is what makes reactors fast, order-independent, and safe to replay. And when an event genuinely doesn’t carry enough — BookReturned has no title — read the read model as shown above, and make it passive if the answer must include the event in hand.
You’ve built a library
Section titled “You’ve built a library”Step back and look at what you have. Facts go in as events. A projection folds them into a Books read model you can query. And a reactor acts when something happens. That loop — append → project → react — is the entire shape of a Chronicle application. You just built it end to end.
Where to go from here:
- Solve a focused task — use the Scenarios recipes when you need to rebuild, enforce uniqueness, react, query, or evolve an event. Test a slice shows how
EventScenarioandReadModelScenarioverify event appends and derived read model state in-process. - Go deeper on each piece — Concepts, and the guides for Projections, Reactors, and Reducers.
- Put a UI and commands on top — take the same model full-stack with Arc and Components in Build a full-stack feature.
- Model your own domain — you now know enough to leave the library behind. When you do, start by asking the only question that matters: what happened?
- Hit a snag? — Troubleshooting has the common ones.