Skip to content
Kumar Chandrachooda
.NET

When a Saga Library Is Overkill

Eight parts of source-reading later, the honest retrospective. What Chronicle gets right, the timeout-shaped hole at its centre, how it stacks up against MassTransit and NServiceBus and durable execution - and the cases where a status column beats every saga framework ever written.

By Kumar Chandrachooda 07 Apr 2026 7 min read
Weighing the machinery against the process it manages

This series read Chronicle — the open-source .NET saga library by Dariusz Pawlukiewicz and Piotr Gankiewicz — essentially cover to cover: the two-interface programming model, correlation and ResolveId, the Scrutor scan and the fan-out coordinator, the four-class pipeline, the reverse replay of the saga log, the persistence contracts, and both shipped providers with their sharp edges. Time to zoom out and give the verdict a practitioner owes you: what this library is genuinely good at, where it will hurt you, what I'd pick instead in each situation — and when the honest answer is none of the above.

What Chronicle gets right

It's completely knowable. The core is about twenty files, and this series quoted most of the load-bearing ones. There is enormous, underrated engineering value in a dependency your whole team can actually read: when something behaves oddly, the answer is thirty minutes away in the source, not in a GitHub discussion from 2021. Every surprise documented in this series — the Reject() throw, the Empty context, the ForEach — was findable precisely because the library is small enough to have an “essentially cover to cover”.

Transport-agnosticism ages well. ProcessAsync is just a method. We drove Chronicle from RabbitMQ consumers, from HTTP endpoints during a migration when half the events still arrived as webhooks, and — the killer feature nobody advertises — from xUnit, where a full saga including compensation runs in-memory in milliseconds with zero test infrastructure. Bus-integrated frameworks make the happy path smoother; they make the “our messages come from three different places” path much rougher.

The model fits join-style processes. Completion-condition-over-accumulated-state (part 2) is a genuinely better fit than a state machine when the process is “wait for these N facts in any order”. Multiple start actions (either message may arrive first) is a small feature that dissolves a whole class of race conditions.

The pattern is implemented honestly. State keyed by (id, type), an append-only log as the compensation source, LIFO replay, persist-in-finally so the outcome is durable even on failure — the architecture is the saga literature, faithfully. As an executable explanation of the process-manager pattern, Chronicle has no equal I know of in .NET.

Where it will hurt you

Consolidating the series' findings in one place, roughly in the order they'll bite:

  • No timeouts. The defining gap. Real sagas are mostly deadlines: “if no PaymentCompleted within 30 minutes, cancel and release the stock”. Chronicle has no scheduler, no reminders, no way for a saga to receive the passage of time — a saga waiting for a message that never comes waits forever, silently Pending. You end up building the missing half yourself: a background sweeper querying for stale Pending states and injecting synthetic TimeoutExpired messages. It works, but it's the hardest part of the saga problem, and it's the part the library leaves entirely to you.
  • Rejection is a tombstone (parts 5–6). No retry, no resurrection; and via the unhandled-exception path (path B), a saga can end up rejected without compensation having run. Discipline — convert exceptions to explicit Reject() calls — is the only mitigation.
  • Compensation is fire-and-forget (part 6). Started in reverse order, never awaited, exceptions unobservable. Idempotent, self-logging compensations are a hard requirement, not a virtue.
  • Single-node assumptions (parts 4, 7, 8). The per-id lock is process-local; persistence has no optimistic concurrency; the dual state-plus-log write has no transaction. Scale out without partitioning messages by saga id and you've silently reintroduced every race the library appears to prevent.
  • Provider sharp edges (part 8). The Redis key built from Type.GetHashCode() orphans all saga state on every restart; Mongo's delete-then-insert tears under concurrency. For production, write your own provider — it's an afternoon, and part 7 is the checklist.
  • Dormant maintenance. The library targets netcoreapp3.1, and activity on the repository has been quiet for years. It runs fine on modern .NET, but adopting it today means adopting it as source you own — which, given that its whole value is readability, is less damning than it would be for a framework you can't hold in your head. Fork it, fix the Redis key, await the compensations, and you have a maintained library; the series has effectively been the code review for that fork.

The alternatives, honestly

MassTransit gives you sagas as Automatonymous state machines with transport-native correlation, scheduled events (timeouts — the thing Chronicle lacks most), and persistence with optimistic concurrency baked in. The cost is conceptual surface: the state-machine DSL, transport configuration, and a framework you will not be reading cover to cover. If your processes are long-lived, deadline-driven, and running on multiple nodes against a real broker, MassTransit is the boring correct answer in modern .NET — it's also free, whereas NServiceBus, the most mature saga implementation in the ecosystem (timeouts, ConfigureHowToFindSaga, decades of production hardening), is commercial. Between those two it's mostly a licensing and ecosystem decision.

Durable execution — Temporal, Azure Durable Functions — solves the same business problem with a different paradigm: instead of a state bag advanced by messages, you write the whole process as one method and the runtime checkpoints it through crashes, waits, and retries. Compensation becomes a try/catch around code that looks sequential. For processes with heavy time logic (wait 30 days, remind three times, escalate), durable execution is dramatically less code than any message-driven saga. Its costs: an infrastructure dependency (a Temporal cluster, or buying into Functions), determinism constraints on your workflow code, and versioning in-flight workflows — the same “old data meets new code” problem, wearing a different hat.

Choreography — no manager, services reacting to each other's events — remains right for short chains (two, three steps) between teams that don't want coupling to a shared orchestrator. Past that, the absence of a place where the process is written down costs more in debugging than the decoupling saves. If you're choreographing five steps with compensations, you've built a distributed saga library without noticing; get an explicit one.

And when it's all overkill

Here's the retrospective's actual thesis: most processes that get a saga library don't need one. The saga pattern earns its machinery when three things are simultaneously true — multiple independent services own steps, steps have side effects that need undoing, and the process outlives a request. Knock out any leg and simpler tools win:

  • Steps all in one service, one database? That's a status column and a transaction. Order.Status advancing through an enum, mutations guarded by WHERE Status = 'AwaitingPayment', has been the correct implementation of “workflow” since before we called it that. An optimistic-concurrency token gives you more safety than Chronicle's persistence layer does.
  • Side effects, multiple services, but the flow is linear and fast? A queue per step with retries and a dead-letter gets you there without any orchestrator state at all. Compensation for a two-step process is one catch block, not a framework.
  • Long-lived, but the “process state” is really just waiting for one thing? An outbox plus a scheduled job (“nightly: cancel unpaid orders older than 24h”) is embarrassingly effective and every operator on your team already understands it.

The failure mode I've watched repeatedly isn't choosing the wrong saga library — it's reaching for any orchestration framework at the first whiff of multi-step, then feeding it processes a status column could have carried. Every saga you add is a distributed state machine your on-call rotation now owns: correlation bugs, stuck-Pending monitoring, compensation testing, poison-message policy. That overhead is worth it — sometimes. The design review question is simply: what, exactly, can't a status column do here? Write the answer down. If it mentions independent services, compensations, and time, you have a saga; pick your tooling with this series' scorecards in hand. If it doesn't, close the NuGet tab.

Chronicle's deepest lesson, though, wasn't about sagas at all. It's that a library small enough to read is a library you can disagree with in specifics — this key scheme, that unawaited loop — instead of superstition. Whatever you orchestrate with next, insist on that. Read the part of it that will page you at 2 a.m. If you can't, that's information too.