Skip to content

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:

UI/A: LibraryStream: Library
BookReturned
WaitlistNotifier

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);
}
}

Chronicle discovers this by convention — no registration, no wiring. Drop the class in, and every BookReturned now flows to it.

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, 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”.

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.

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: