Four Small Classes Run Every Saga
Seeker, initializer, processor, post-processor - Chronicle's whole runtime is four internal classes you can read in ten minutes. A source walk through the pipeline, including the gate that drops messages, the finally block that always persists, and the state that isn't as final as it looks.
Most frameworks make you choose between using them and understanding them. Chronicle — the open-source .NET saga library this series is reading end to end — has a runtime small enough to refuse the choice: four internal classes in a Managers folder, each with one method that matters. This part walks a message through all four, because three of my hardest-won Chronicle debugging sessions would have been ten-minute fixes if I'd read them earlier.
The cast, in dispatch order:
SagaSeeker— which sagas handle this message type? (Covered in part 4.)SagaInitializer— may this saga process it, and with what state?SagaProcessor— run the handler, persist the outcome.SagaPostProcessor— react to the resulting state.
The coordinator runs stations 2–4 inside the per-id lock. Let's take them in turn.
The initializer: three gates and a hydration
SagaInitializer.TryInitializeAsync decides whether processing happens at all. Its logic compresses to three gates — here's the load-bearing middle of the method, from the source:
var state = await _repository.ReadAsync(id, sagaType).ConfigureAwait(false);
if (state is null)
{
if (!(action is ISagaStartAction<TMessage>))
{
return (false, default);
}
state = CreateSagaState(id, sagaType, dataType);
}
else if (state.State is SagaStates.Rejected)
{
return (false, default);
}
Gate one: existence. State is read from the repository keyed by (id, sagaType). If nothing is there, this message is trying to talk to a saga that hasn't started — and only an ISagaStartAction<TMessage> is allowed to fix that. Anything else is silently dropped: (false, default) bubbles up and the coordinator just returns. No log, no error, no dead-letter. Operationally this is the sharpest edge in the library: a PaymentCompleted that arrives before its OrderPlaced (brokers reorder; it happens) simply evaporates. Chronicle has no message deferral or buffering, so out-of-order delivery is your problem — either make more message types start actions (they're allowed to be handled in any order once you stop assuming sequence), or have the caller retry when a saga it expected to exist doesn't.
Gate two: creation. For a permitted start, CreateSagaState builds a Pending state and instantiates your TData with Activator.CreateInstance(dataType) — the reason for the new() constraint on Saga<TData>. Note what this implies: saga data is born at first message, not at registration; there's no “declare upfront” step.
Gate three: the tombstone. If state exists but is Rejected, processing refuses — permanently. A rejected saga is a tombstone: every future message for that id and type is dropped forever. There is no retry API, no un-reject. If your business process can fail recoverably, don't model the failure as rejection; model it as data (Data.Attempts++, stay Pending) and reserve Reject() for the genuinely dead.
And the fourth thing that isn't a gate but should be noticed: Completed passes straight through. A completed saga still processes messages — handlers run, onCompleted can fire again, state gets rewritten. If a straggler duplicate after completion would double-ship an order, the guard has to be in your handler. I'd have preferred completed-means-done, but the source is unambiguous, and it does enable “reopenable” processes if you want them.
Finally the hydration: saga.Initialize(id, state.State) for plain sagas, and for Saga<TData> a reflection call (InvokeGeneric) to the generic Initialize(id, state, data) overload — the freshly resolved transient instance gets its memory installed. Reflection here is invisible in the API but it's why your saga's Initialize must remain the standard signature if you ever override it.
The processor: try, catch, always persist
SagaProcessor.ProcessAsync is where your code runs, and its structure is a textbook try/catch/finally with everything in the placement:
try
{
await action.HandleAsync(message, context);
}
catch (Exception ex)
{
context.SagaContextError = new SagaContextError(ex);
if (!(saga.State is SagaStates.Rejected))
{
saga.Reject(ex);
}
}
finally
{
await UpdateSagaAsync(message, saga, state);
}
Three observations, in ascending order of importance.
First, the catch stashes the exception into context.SagaContextError — that's the channel through which your onRejected hook later learns what went wrong. If you've ever wondered why the context has a mutable error property, this is its one writer.
Second, the if not already Rejected check distinguishes two failure flavours: a handler that called Reject() itself (state already Rejected — remember from part 2 that Reject() throws, so we are in this catch block) versus a handler that threw an ordinary exception (state still Pending, so the processor calls saga.Reject(ex) on its behalf). These two paths look symmetrical here. They are not — Saga.Reject throws a fresh ChronicleException, and this time we're already inside the catch block, so it propagates. The full, surprising consequence of that is the centrepiece of part 6; for now, plant the flag: self-reject and crash are handled by the same lines but do not end the same way.
Third — and this is the design decision I most respect in the library — persistence lives in finally. The outcome is saved no matter what happened, success or failure. UpdateSagaAsync does two writes:
state.Update(saga.State, updatedSagaData);
var logData = SagaLogData.Create(saga.Id, sagaType, message);
var persistenceTasks = new []
{
_repository.WriteAsync(state),
_log.WriteAsync(logData)
};
await Task.WhenAll(persistenceTasks).ConfigureAwait(false);
The state repository gets the new state and data (the Data property is read back off your saga via reflection — the base class has no setter hook, so the processor just reaches in). The saga log gets an append-only record: this message, this saga, this timestamp. That log is not telemetry — it's the compensation source. When a saga rejects, the log is what gets replayed backwards. Notice it's written on the failure path too, which means the message whose handler failed is itself in the log before compensation starts — a detail with consequences next part.
Notice also what the Task.WhenAll is not: a transaction. State write and log write go to two stores (or two keys) with independent failure modes. If the process dies between them, you get a saga whose state and history disagree — state says two messages handled, log shows one, and a later compensation quietly skips an action. With in-memory persistence this window is academic; with Redis or Mongo across a network it's real. It's the standard dual-write problem, and Chronicle chooses simplicity over a unit-of-work abstraction. Fair choice for the library's size — but if your compensations move money, put idempotency on their side rather than trusting the log to be perfect.
The post-processor: the reaction shot
After the processor, the saga is in one of three states, and SagaPostProcessor is a switch over them:
switch (saga.State)
{
case SagaStates.Rejected:
await onRejected(message, context);
await CompensateAsync(saga, sagaType, context);
break;
case SagaStates.Completed:
await onCompleted(message, context);
break;
}
Pending — the common case, saga still mid-flight — does nothing at all; the pipeline simply ends until the next message. Completed awaits your onCompleted hook. Rejected awaits onRejected and then launches the compensation replay, which deserves — and gets — its own article.
The hooks are worth a design note: they receive the message and context, not the saga, so they're intended for integration (publish the OrderFulfilled event, ack differently) rather than for poking at saga internals. And they're per-dispatch parameters on ProcessAsync rather than declarations on the saga class — the caller decides what completion means this time. I've grown to like that inversion: the saga owns the process, the call site owns the ceremony.
The pipeline on one page
Message arrives → seeker finds every saga class handling its type → per saga: resolve id, take the per-id lock → initializer reads (id, type) state, applies the start/tombstone gates, hydrates a fresh transient instance → processor runs your handler, converts crashes to rejection, and persists state + log in a finally → post-processor fires your hook and, on rejection, replays the log backwards.
Four small classes, one honest pipeline. Next part follows the rejection branch all the way down — where the source and the README part company, and where a List.ForEach hides the library's most consequential quirk.