Two Interfaces and a Base Class
Chronicle's entire programming model is Saga<TData>, ISagaStartAction, and ISagaAction. Building a full order-fulfilment saga to learn what Complete() promises, what Reject() actually does, and why a saga can have more than one beginning.
Part 1 made the case for orchestrated sagas and introduced Chronicle, the small open-source .NET saga library from the DevMentors stable that this series reads line by line. This part is about the surface you actually program against — which, in a library whose core is about twenty files, is refreshingly small: one base class, two interfaces, and three states.
The shape of a saga
A Chronicle saga is a plain class that derives from Saga or Saga<TData> and declares, via interfaces, which messages it cares about:
public class OrderSagaData
{
public Guid OrderId { get; set; }
public bool Paid { get; set; }
public bool StockReserved { get; set; }
}
public class OrderSaga : Saga<OrderSagaData>,
ISagaStartAction<OrderPlaced>,
ISagaAction<PaymentCompleted>,
ISagaAction<PaymentFailed>,
ISagaAction<StockReserved>
{
// one HandleAsync + one CompensateAsync per message type
}
Each interface is two methods:
public interface ISagaAction<in TMessage>
{
Task HandleAsync(TMessage message, ISagaContext context);
Task CompensateAsync(TMessage message, ISagaContext context);
}
That pairing is the saga pattern encoded as an API. For every message you handle, you owe the library a description of how to undo what handling it did. ISagaStartAction<TMessage> adds nothing — it's an empty marker interface extending ISagaAction<TMessage> — but it changes routing: only a start action may bring a saga into existence. If a PaymentCompleted arrives for a saga id nobody has started, Chronicle silently drops it for that saga; if an OrderPlaced arrives, a fresh saga state is created. We'll see the exact gate in the initializer's source in part 5.
TData is your saga's memory between messages. The constraint is class, new() — Chronicle instantiates it with Activator.CreateInstance when the saga first starts, so it needs a parameterless constructor, and it gets serialized by whatever persistence provider you've plugged in, so keep it a dumb bag of properties. There's also a non-generic Saga base for processes that only need to know which messages arrived, not remember anything about them — though in practice I've almost never met one; the moment your completion condition is “both messages seen”, you need two booleans, and that's TData.
Handling: mutate Data, then decide
Here's the heart of the order saga:
public Task HandleAsync(OrderPlaced message, ISagaContext context)
{
Data.OrderId = message.OrderId;
return Task.CompletedTask;
}
public Task HandleAsync(PaymentCompleted message, ISagaContext context)
{
Data.Paid = true;
return CompleteIfFulfilled();
}
public Task HandleAsync(StockReserved message, ISagaContext context)
{
Data.StockReserved = true;
return CompleteIfFulfilled();
}
public Task HandleAsync(PaymentFailed message, ISagaContext context)
{
Reject(); // never returns - more on that below
return Task.CompletedTask;
}
private Task CompleteIfFulfilled()
{
if (Data.Paid && Data.StockReserved)
{
Complete();
}
return Task.CompletedTask;
}
The CompleteIfFulfilled shape is idiomatic Chronicle, straight out of the project's own README sample: because PaymentCompleted and StockReserved may arrive in either order, both handlers check whether the process is done. The saga doesn't encode a step sequence the way a state machine would — it encodes a completion condition over accumulated state. That's less rigid than MassTransit's Automatonymous, and for join-style flows (“wait until all three of these have happened”) it's genuinely nicer. For strictly ordered flows you end up hand-rolling guards ("ignore StockReserved unless Paid"), which a state machine would have given you for free. Know which flow you have.
The three states, and what Reject() really does
A saga is always in exactly one of three states — the enum in the source is exactly this small:
public enum SagaStates : byte
{
Pending = 0,
Completed = 1,
Rejected = 2,
}
Complete() is as innocent as it looks — it flips State to Completed. The state is persisted after the handler returns, and the coordinator will fire your onCompleted hook.
Reject() is not innocent, and this is the first place where reading Chronicle's source pays off. From Saga.cs, verbatim:
public virtual void Reject(Exception innerException = null)
{
State = SagaStates.Rejected;
throw new ChronicleException("Saga rejection called by method", innerException);
}
It sets the state and then throws. That's a deliberate control-flow device: the throw aborts the rest of your handler, the processing pipeline catches the exception, sees the saga already marked Rejected, persists that, and kicks off compensation. The practical consequence is that Reject() behaves like a return you can't accidentally fall past — any code after the call never runs. It also means you shouldn't wrap Reject() in your own try/catch (Exception) or you'll swallow the mechanism. Part 6 traces the two rejection paths through the pipeline, and there's a genuine surprise waiting in the unhandled-exception path.
Two more facts about states that the source teaches and the docs don't emphasise. First, rejection is terminal: once a saga's persisted state is Rejected, the initializer refuses to process any further message for that saga id, forever. There's no retry, no resurrection. Second, completion is not terminal: a message arriving after Complete() is still handled — the saga is hydrated in its Completed state and your handler runs again. If a duplicate StockReserved after completion would be harmful, your handler has to check.
Wiring and driving it
Registration is one line:
services.AddChronicle();
No per-saga registration, no marker assemblies — Chronicle scans the loaded assemblies for anything implementing ISaga and registers it against every ISagaAction<T> interface it exposes (part 4 dissects exactly how, because there are consequences). By default you get in-memory persistence; the builder callback swaps in Redis, Mongo, or your own.
Driving the saga is the coordinator, and this is where Chronicle differs most from bus-integrated saga frameworks — you call it:
var context = SagaContext.Create()
.WithSagaId(order.Id.ToString())
.WithOriginator("Checkout")
.Build();
await _coordinator.ProcessAsync(new OrderPlaced { OrderId = order.Id }, context);
The context carries the correlation id that ties the two calls to the same saga instance — get it wrong (or omit it) and your messages quietly start separate sagas, which is the single most common Chronicle beginner bug and the entire subject of part 3.
The coordinator also accepts completion hooks, which are handy for the “publish a follow-up event when the saga finishes” pattern:
await _coordinator.ProcessAsync(message,
onCompleted: (m, ctx) => _publisher.PublishAsync(new OrderFulfilled(Data.OrderId)),
onRejected: (m, ctx) => _publisher.PublishAsync(new OrderCancelled(Data.OrderId)),
context: context);
More than one beginning
One under-documented niche feature: a saga may implement several ISagaStartAction<T> interfaces. Whichever start message arrives first creates the saga; any later start message finds existing state and behaves like an ordinary action. That's exactly what you want for rendezvous flows where two systems race — say, a webhook from the payment provider and an internal OrderPlaced event, either of which may legitimately arrive first. Declare both as start actions and the race stops mattering.
Where we are
That's the entire programming model: derive, implement an interface per message, mutate Data, call Complete() or Reject(), and let a coordinator push messages in. What we've deliberately skipped is everything the library does around those calls — finding the right saga instance, locking it, loading state, logging messages, compensating. Next up is correlation: how, exactly, a PaymentCompleted finds the one saga among thousands that has been waiting for it.