The Transaction That Outlives the Request
Split one database into five services and the transaction you deleted does not disappear - it goes feral. On sagas, compensation, process managers, and Chronicle, the small open-source .NET library this series reads line by line.
The bug that teaches you what a saga is always looks the same. An order comes in. The order service writes a row. The payment service charges the card. The inventory service throws — a timeout, a constraint, it barely matters. Now you have a paid order for stock that was never reserved, and no single ROLLBACK anywhere in the system can make that untrue.
Inside one database this problem doesn't exist. BEGIN TRANSACTION, three writes, COMMIT — atomicity is somebody else's job, and that somebody is very good at it. The moment you split the writes across services, each with its own database, you have deleted the transaction but not the need for one. The business still believes “place an order” is one operation. Your architecture now disagrees.
Why you can't just get the transaction back
The classic answer was two-phase commit: a coordinator asks every participant to prepare, and only when all vote yes does anyone commit. It works, technically, and it is almost always the wrong tool between services. Every participant holds locks from prepare until commit, so the slowest service sets the lock duration for everyone. If the coordinator dies between phases, participants sit blocked, holding locks, waiting for an answer that may take minutes to arrive. And that's before the practical objection: your HTTP APIs, your message broker, and your teammate's Mongo collection don't speak XA anyway.
Long-running business processes make it strictly worse. “Place an order” might legitimately take hours if it includes a fraud review or a warehouse confirmation. Nothing holds a database lock for hours and lives to tell about it.
Sagas: trade atomicity for a plan B
The saga pattern is older than microservices by two decades — Hector Garcia-Molina and Kenneth Salem described it in 1987, for long-lived transactions inside a single database. The idea transplants perfectly: break the big transaction into a sequence of local transactions, each of which commits immediately. If step N fails, you don't roll back — you can't, steps 1 through N−1 are already committed and visible. Instead you run compensating actions in reverse order: refund the payment, release the reservation, cancel the order.
That's the honest price of the pattern, and it's worth stating plainly: a compensation is not a rollback. Other systems may have already observed the intermediate state — an email may have gone out, a downstream report may have counted the sale. Compensations are new forward actions that semantically undo old ones, and you have to write every one of them. No library removes that obligation. What a library can do is remember which steps ran, in what order, and make sure the undo actually happens when things go sideways.
Choreography, orchestration, and the process manager
There are two shapes a saga can take.
In a choreographed saga there is no manager. The order service emits OrderPlaced; the payment service hears it and emits PaymentCompleted; inventory hears that and emits StockReserved. Failure events flow the same way in reverse. It's beautifully decoupled and, in my experience, beautifully undebuggable past about four steps — the process exists only as an emergent property of everyone's event handlers, and the question “where is order 4711 stuck?” has no single place to be answered.
In an orchestrated saga, one component owns the process. It receives events, keeps state — payment done, stock pending — and decides what happens next. Enterprise Integration Patterns calls this a process manager: a central unit of state plus routing logic for a multi-step interaction. You give up some decoupling; you gain a single place where the process is written down, can be read, and can be asked what state it's in.
Most saga libraries in .NET are process-manager implementations, and the one this series dissects is the smallest serious one I know.
Enter Chronicle
Chronicle is an open-source saga / process-manager library for .NET, built by Dariusz Pawlukiewicz and Piotr Gankiewicz of the DevMentors ecosystem and shipped on NuGet as Chronicle_. I did not write it and I don't maintain it — what I have done is use it, break it, and read every one of its source files, because its core is roughly twenty files and it is the best readable specimen of the pattern I've found. Where MassTransit gives you a saga as a full state machine welded to a transport, Chronicle gives you the pattern itself, with the seams showing.
A saga in Chronicle is a class. Handling a message is an interface. That's nearly the whole API:
public class OrderSaga : Saga<OrderSagaData>,
ISagaStartAction<OrderPlaced>,
ISagaAction<PaymentCompleted>,
ISagaAction<StockReserved>
{
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 CompleteIfDone();
}
// ... StockReserved handler, plus a CompensateAsync per message
}
You register it with one call (services.AddChronicle() — it finds saga classes by scanning your assemblies), and you drive it by handing messages to a coordinator:
await _coordinator.ProcessAsync(new OrderPlaced { OrderId = id }, context);
Notice what's missing: a message bus. Chronicle deliberately doesn't know about RabbitMQ or Azure Service Bus. ProcessAsync is a method you call — from a bus consumer, an HTTP endpoint, a background job, a test. That transport-agnosticism is either its best feature or its biggest gap depending on the day, and the series will argue both sides.
Behind that one call sits everything this series is about: how the message finds the right saga instance (correlation), how state is loaded and saved (pluggable persistence — in-memory, Redis, MongoDB), how every handled message is appended to a saga log, and how that log is replayed backwards to compensate when the saga rejects.
Where the series goes
Chronicle is small enough to understand completely, which makes it the perfect vehicle for understanding sagas completely. The plan:
- The transaction that outlives the request — this post.
- Two interfaces and a base class — the programming model:
Saga<TData>, start actions vs actions,Complete()andReject(). - How a message finds its saga — correlation:
SagaId,SagaContext, and theResolveIdoverride that routes by message content. - One message, many sagas — how sagas are discovered via assembly scanning and dispatched, including the per-saga lock.
- Four small classes run every saga — the initializer/processor/post-processor pipeline, read from source.
- Compensation is a replay in reverse — the saga log, LIFO replay, and what actually happens on an unhandled exception (the source disagrees with the README, interestingly).
- Where a saga keeps its memory — the persistence contracts and the in-memory implementations.
- Reading the Redis and Mongo providers — the two official persistence integrations, sharp edges included.
- When a saga library is overkill — the honest retrospective.
If your system has ever charged a card for stock it didn't have, you already know why the pattern matters. The rest of this series is about how it actually works — with the source code open on the other monitor.