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

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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

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.

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 EventScenario and ReadModelScenario verify 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.