One Message, Many Sagas
services.AddChronicle() registers sagas you never mentioned, and one ProcessAsync call can run five of them in parallel. Inside Chronicle's Scrutor scan, the seeker's dedupe trick, and the ref-counted lock that serializes each saga id.
There's a moment with every convention-based library where you stop and ask: how does it know? You wrote services.AddChronicle(), mentioned no saga classes at all, and yet ProcessAsync(new OrderPlaced(...)) finds your OrderSaga, your LoyaltyPointsSaga, and the FraudCheckSaga a teammate added last sprint — and runs all three. This part reads the discovery and dispatch machinery of Chronicle, the open-source .NET saga library this series dissects, because every convenience in that sentence has a mechanism, and two of the mechanisms have consequences.
What AddChronicle() actually registers
The extension method does three things: registers the pipeline, configures persistence, and scans for your sagas. The pipeline is five small services — coordinator, seeker, initializer, processor, post-processor — all transient. Persistence defaults to the in-memory implementations as singletons (they'd be useless otherwise — a transient in-memory store forgets everything between resolutions), unless you use the builder callback to plug in Redis, Mongo, or your own.
Then comes the interesting part. Chronicle uses Scrutor to find sagas, and the scan in Extensions.cs is worth quoting because each clause has a consequence:
private static void RegisterSagas(this IServiceCollection services)
=> services.Scan(scan =>
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
scan
.FromAssemblies(assemblies)
.AddClasses(classes => classes.AssignableTo(typeof(ISaga)))
.As(t => t
.GetTypeInfo()
.GetInterfaces(includeInherited: false))
.WithTransientLifetime();
});
Clause by clause:
AppDomain.CurrentDomain.GetAssemblies() scans every currently loaded assembly. Emphasis on loaded: .NET loads assemblies lazily, so if your sagas live in a class library that nothing has touched by the time AddChronicle() runs, they won't be found. This is the classic “works locally, half the sagas missing in the trimmed-down worker service” bug. The blunt fix is to force a load first — typeof(OrderSaga).Assembly mentioned anywhere before AddChronicle() does it. I'd have preferred an overload that takes explicit assemblies; since there isn't one, make the forced load a visible convention in Program.cs rather than an accident of ordering.
GetInterfaces(includeInherited: false) is a custom extension in the same file that subtracts the base type's interfaces — so your saga is registered as its ISagaAction<T>/ISagaStartAction<T> interfaces (the ones you declared) but not as the ISaga plumbing interfaces the base class brings along. Precise, quiet, and the reason the container can answer “who handles OrderPlaced?” directly.
WithTransientLifetime() is the big one: a saga instance lives for exactly one message dispatch. Every ProcessAsync resolves a fresh instance, hydrates it from the state repository, runs one handler, persists, and discards it. Fields on your saga class are therefore amnesia in waiting — anything that must survive between messages goes in Data, full stop. The upside is equally important: a saga class can take constructor dependencies like any transient service. Inject your HttpClient wrappers and repositories freely; you're always working with a live, scoped-appropriately object graph.
The seeker's little dedupe dance
At dispatch time, the coordinator asks SagaSeeker for everything that handles the message type. The implementation is four lines of LINQ with a subtle job:
public IEnumerable<ISagaAction<TMessage>> Seek<TMessage>()
=> _serviceProvider.GetService<IEnumerable<ISagaAction<TMessage>>>()
.Union(_serviceProvider.GetService<IEnumerable<ISagaStartAction<TMessage>>>())
.GroupBy(s => s.GetType())
.Select(g => g.First())
.Distinct();
Why the gymnastics? Because ISagaStartAction<T> extends ISagaAction<T>, a saga declaring a start action got registered under both interfaces by the scan. Resolve both collections and the same saga class comes back twice — and since the registrations are transient, it comes back as two different instances, which defeats Union's reference-equality dedupe. Hence the GroupBy(GetType()): collapse per concrete type, keep the first instance.
There's a design decision hiding in that GroupBy: one instance per saga class per dispatch — you cannot register the same saga class twice and get two of them. And it explains a behaviour that puzzles newcomers: whether a message arrives at the “start” or “continue” phase of a saga's life isn't decided here. The seeker returns the saga if it handles the type at all; whether a missing state is allowed to be created is decided later, by the initializer checking action is ISagaStartAction<TMessage> — part 5's territory.
Fan-out: your message is a broadcast
Now the coordinator itself. The public ProcessAsync fans out to every saga the seeker found:
var actions = _seeker.Seek<TMessage>().ToList();
var sagaTasks = actions
.Select(action => ProcessAsync(message, action, onCompleted, onRejected, context))
.ToList();
await Task.WhenAll(sagaTasks);
This is worth internalising: ProcessAsync is not “deliver to the saga”, it's “deliver to all sagas concerned, concurrently”. A single CustomerBanned message can advance an AccountClosureSaga, reject a PendingOrderSaga, and start a ComplianceReviewSaga in one call — each resolving its own saga id via its own ResolveId, each with independent state. It's a miniature in-process pub/sub, and it's the feature that makes Chronicle feel like an orchestration layer rather than a single-workflow tool.
Two practical corollaries. First, Task.WhenAll means one saga's failure doesn't stop the others from running — but the aggregate await will surface the failure to your calling code, so a bus consumer wrapping ProcessAsync should decide deliberately what a partial failure means for message acknowledgement. Second, your completion hooks are shared across the fan-out: onCompleted fires for each saga that reaches Completed on this message, which is occasionally three emails instead of one if you weren't expecting it.
The lock: one saga id at a time
Concurrent dispatches would be carnage without coordination — two ProcessAsync calls for the same order id would both read state, both mutate, and last-writer-wins. Chronicle's answer wraps the whole per-saga pipeline:
var id = saga.ResolveId(message, context);
using (await Locker.LockAsync(id))
{
var (isInitialized, state) = await _initializer.TryInitializeAsync(saga, id, message);
if (!isInitialized) return;
await _processor.ProcessAsync(saga, message, state, context);
await _postProcessor.ProcessAsync(saga, message, context, onCompleted, onRejected);
}
Locker is a static KeyedLocker — a dictionary of ref-counted SemaphoreSlims, one per key, created on first use and removed when the last waiter releases. It's a tidy little pattern (the ref-counting is what stops the dictionary growing forever) and it gives you exactly one guarantee: within this process, messages for the same saga id execute strictly one at a time. Load, handle, persist — atomic with respect to other dispatches for that id.
Read the guarantee's fine print, though. The locker is static, keyed by the id alone — so different saga types correlating on the same id serialize behind one semaphore (a throughput quirk, not a bug). And “within this process” is doing heavy lifting: run two instances of your service against shared Redis persistence and the lock protects nothing between them. Two nodes can process messages for the same saga concurrently, and the state repository has no optimistic concurrency to catch the collision — last write wins, silently. If you scale out a Chronicle service, either partition messages so a given saga id always lands on the same node (consumer group by key), or accept that you've reintroduced the race the locker was hiding. We'll come back to this in the persistence parts, because it's the single most important operational fact about the library.
The mechanism, assembled
So the full answer to “how does it know”: Scrutor indexes every loaded saga class by its message interfaces at startup; the seeker asks the container and dedupes the start-action double registration; the coordinator fans out one task per saga class, resolves each saga's id, and takes a per-id semaphore before touching state. Next part steps inside that using block, where three small classes — initializer, processor, post-processor — run the actual life of a saga.