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, 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. 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 genuinely must never repeat — 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 entirely: redactions, revisions, and observer rewinds all skip it, so it runs only once per event.
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);}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.Kotlin, Java, Elixir, and TypeScript don’t currently have an equivalent to [OnceOnly] — replay/redaction exclusion for a specific handler is C#-only for now.
Use it deliberately, though — the same guarantee 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 — reach for the strongly consistent read shown above, not the eventually consistent collection.
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:
- 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.