OrionSaga
In-process saga orchestration for .NET. Declare an ordered sequence of steps over a shared context; if any step fails, OrionSaga automatically compensates the already-completed steps in reverse order, so a multi-step operation either fully completes or cleanly unwinds.
Part of the Orion family. Usable entirely on its own.
Why
Some operations touch several systems in a row: reserve stock, charge a card, book a courier. If the courier booking fails, you must release the charge and the stock, in the right order, or you leave money and inventory stranded. Writing that rollback by hand is error-prone. OrionSaga lets you declare each step next to its compensation and runs the unwind for you when something breaks.
The whole library is one generic executor over your own context type, with no dependency beyond the
DI abstractions and the family's shared Orion.Abstractions contracts spine. There is no message
broker, no database, and no background process to operate.
How it works
A saga is an ordered list of steps. Each step has a forward action and an optional compensating
action that undoes it. RunAsync walks the steps in order over a single shared context. The first
step to throw, be cancelled, or overrun its optional per-step timeout stops forward progress; every
step that already completed is then compensated in reverse order, and a SagaResult reports exactly
what happened, including whether the run succeeded, failed, or was cancelled.
Key invariant: the step that failed is not compensated (its forward action never completed). Only the steps that fully completed are unwound.
Install
dotnet add package OrionSaga
Targets net8.0, net9.0, and net10.0. The runtime dependencies are
Microsoft.Extensions.DependencyInjection.Abstractions and Orion.Abstractions (the family's
shared contracts spine, which supplies the OrionInstrumentation telemetry base).
Quick start
Build a saga of steps, each pairing a forward action with its compensation, then run it over a context instance:
using Moongazing.OrionSaga.Orchestration;
var saga = new SagaBuilder<OrderContext>()
.AddStep("reserve-stock",
execute: (ctx, ct) => inventory.ReserveAsync(ctx.OrderId, ct),
compensate: (ctx, ct) => inventory.ReleaseAsync(ctx.OrderId, ct))
.AddStep("charge-card",
execute: (ctx, ct) => payments.ChargeAsync(ctx.OrderId, ctx.Amount, ct),
compensate: (ctx, ct) => payments.RefundAsync(ctx.OrderId, ct))
.AddStep("book-courier",
execute: (ctx, ct) => courier.BookAsync(ctx.OrderId, ct),
compensate: (ctx, ct) => courier.CancelAsync(ctx.OrderId, ct))
.Build();
var result = await saga.RunAsync(context, ct);
if (!result.Succeeded)
{
logger.LogWarning("Order saga failed at {Step}: {Error}",
result.FailedStep, result.Failure!.Message);
}
If charge-card throws, OrionSaga compensates reserve-stock (the only completed step) and returns
a failure result. charge-card itself is not compensated, and book-courier never runs.
The shared context is yours; OrionSaga only threads it through. A minimal one:
public sealed class OrderContext
{
public required string OrderId { get; init; }
public decimal Amount { get; init; }
}
Usage
Compensation on failure
When a step throws, rollback runs automatically and the result records the outcome. Inspect
RolledBackCleanly to know whether the unwind itself was clean:
var result = await saga.RunAsync(context, ct);
if (!result.Succeeded)
{
if (result.RolledBackCleanly)
{
// Failed, but every completed step compensated successfully. State is consistent.
logger.LogWarning("Saga rolled back cleanly after {Step} failed", result.FailedStep);
}
else
{
// One or more compensations themselves threw: these effects may not be undone.
foreach (var failure in result.CompensationFailures)
{
logger.LogError(failure.Exception,
"Compensation for {Step} failed and needs manual attention", failure.StepName);
}
}
}
A compensation that throws is recorded in CompensationFailures and rollback continues for the
remaining steps, so one bad compensation does not strand the others.
Steps without a compensation
The compensate argument is optional. A step with no real undo (for example a pure read or a step
that already commits atomically) is added with a forward action only:
var saga = new SagaBuilder<OrderContext>()
.AddStep("load-order", (ctx, ct) => orders.LoadAsync(ctx.OrderId, ct)) // no compensation
.AddStep("charge-card",
(ctx, ct) => payments.ChargeAsync(ctx.OrderId, ctx.Amount, ct),
(ctx, ct) => payments.RefundAsync(ctx.OrderId, ct))
.Build();
A missing compensation is treated as a no-op during rollback.
Typed step results
A step's forward action can return a value, with an apply callback that writes it into the context
so the next step reads it directly instead of the step mutating shared state by hand:
var saga = new SagaBuilder<OrderContext>()
.AddResultStep<string>("reserve-stock",
execute: (ctx, ct) => inventory.ReserveAsync(ctx.OrderId, ct), // returns a reservation id
apply: (ctx, reservationId) => ctx.ReservationId = reservationId,
compensate: (ctx, ct) => inventory.ReleaseAsync(ctx.ReservationId!, ct))
.AddStep("charge-card",
(ctx, ct) => payments.ChargeAsync(ctx.ReservationId!, ctx.Amount, ct)) // reads what reserve produced
.Build();
apply runs on the forward path immediately after the action; a fault it raises fails the step like
any other forward fault and triggers rollback. AddResultStep is an ergonomic layer over the untyped
step: ordering, compensation, per-step timeouts, and the SagaResult all behave identically, and
typed and untyped steps mix freely in one saga. It is deliberately a distinct method rather than an
AddStep overload, so an existing AddStep(name, forward, compensate) call whose forward happens to
return a Task<T> can never rebind to it and lose its compensation.
Cancellation
RunAsync takes a CancellationToken that cancels forward progress. A cancellation is reported as
SagaOutcome.Cancelled, distinct from a business Failed, so an operator or timeout cancellation is
not mistaken for a step that genuinely faulted. Rollback always runs with a non-cancelled token, so a
saga cancelled mid-flight still unwinds the work it already did:
var result = await saga.RunAsync(context, cancellationToken);
if (result.Cancelled)
{
// The caller's token (or a per-step timeout) cancelled the run. Completed steps were rolled back.
logger.LogWarning("Order saga cancelled at {Step}", result.FailedStep);
}
Cancelled and Failed are mutually exclusive: a cancellation never sets Failed, and a forward
fault never sets Cancelled. The completed steps are compensated in both cases.
Per-step timeouts
A step can declare a maximum forward-action duration with the timeout argument on AddStep. When
the forward action overruns that budget it is cancelled, the saga rolls back the completed steps, and
the result is reported as Cancelled with TimedOut set to true:
var saga = new SagaBuilder<OrderContext>()
.AddStep("reserve-stock",
execute: (ctx, ct) => inventory.ReserveAsync(ctx.OrderId, ct),
compensate: (ctx, ct) => inventory.ReleaseAsync(ctx.OrderId, ct))
.AddStep("book-courier",
execute: (ctx, ct) => courier.BookAsync(ctx.OrderId, ct),
compensate: (ctx, ct) => courier.CancelAsync(ctx.OrderId, ct),
timeout: TimeSpan.FromSeconds(2)) // forward action must finish within 2s
.Build();
var result = await saga.RunAsync(context, ct);
if (result.TimedOut)
{
logger.LogWarning("Order saga timed out at {Step}", result.FailedStep);
}
The per-step deadline is honoured alongside the caller's CancellationToken through a linked token,
so external cancellation still works. The budget must be strictly positive; a null timeout (the
default) means no budget. TimedOut is true only when the per-step deadline genuinely fired: a step
that throws OperationCanceledException for an unrelated reason (its own HttpClient timeout, a child
token it cancels itself) is reported as Cancelled but not as TimedOut. TimedOut is always false
unless Cancelled is true.
Observers
Attach an ISagaObserver to react to step completion, failure, and compensation, for logging or
alerting. The observer is observability only: any exception it throws is swallowed so it can never
disrupt orchestration or rollback.
using Moongazing.OrionSaga.Observers;
public sealed class LoggingSagaObserver(ILogger<LoggingSagaObserver> logger) : ISagaObserver
{
public void OnStepCompleted(string stepName) =>
logger.LogInformation("Step {Step} completed", stepName);
public void OnStepFailed(string stepName, Exception exception) =>
logger.LogError(exception, "Step {Step} failed", stepName);
public void OnCompensated(string stepName) =>
logger.LogInformation("Step {Step} compensated", stepName);
public void OnCompensationFailed(string stepName, Exception exception) =>
logger.LogError(exception, "Compensation for {Step} failed", stepName);
}
var saga = new SagaBuilder<OrderContext>()
.WithObserver(new LoggingSagaObserver(logger))
.AddStep(/* ... */)
.Build();
Each notification also has a richer overload that carries the step's one-based ordinal and measured duration, so an observer can build a timing breakdown without its own bookkeeping:
public void OnStepCompleted(string stepName, int ordinal, TimeSpan duration) =>
logger.LogInformation("Step {Ordinal} {Step} completed in {Elapsed}", ordinal, stepName, duration);
The richer overloads are default interface methods that forward to the name-only methods, so an existing observer keeps working unchanged; override an overload to receive the ordinal and duration. A compensation notification carries the step's original forward ordinal, so it correlates to the forward step that produced it. When no observer is registered the executor captures no timing and skips the notification entirely, so the no-observer path stays allocation-light.
Diagnostics
Attach a SagaDiagnostics instance to emit metrics. See Telemetry below.
Results
RunAsync returns a SagaResult:
| Property | Meaning |
|---|---|
Outcome | The SagaOutcome: Succeeded, Failed, or Cancelled. |
Succeeded | True when every step completed. |
Failed | True when a step's forward action threw a non-cancellation exception. |
Cancelled | True when the run was cancelled by the caller's token or a per-step timeout, rather than failing on its own. |
TimedOut | True when the cancellation was caused by a step exceeding its per-step timeout. Always false unless Cancelled is true. |
FailedStep | The name of the step that ended the saga, or null on success. |
Failure | The exception that ended the saga, or null on success. For a cancellation this is the observed OperationCanceledException. |
StepsCompleted | How many forward actions completed. Equals the step count on success; the step that ended the saga is not counted. |
StepsCompensated | How many completed steps compensated cleanly during rollback. Zero on success. A compensation that threw is in CompensationFailures, not counted here. |
CompensationFailures | Compensations that themselves threw during rollback. Empty on success or a clean rollback. |
RolledBackCleanly | True when the saga did not succeed but every completed step compensated cleanly. |
Each entry in CompensationFailures is a CompensationFailure(string StepName, Exception Exception)
readonly record struct.
Semantics
- Steps run in the order they are added, sharing one context instance.
- The first step to throw, be cancelled, or overrun its per-step timeout stops forward progress. The step that ended the saga is not compensated; every step that completed is compensated in reverse order.
- A forward fault yields
Outcome == Failed. The caller's token being cancelled or a step overrunning its per-step timeout yieldsOutcome == Cancelled, withTimedOuttrue only for a genuine timeout. - A compensation that itself throws is recorded in
CompensationFailuresand rollback continues for the remaining steps. RolledBackCleanlyis true when the saga failed but every compensation succeeded.- Compensation runs with a non-cancelled token, so a saga cancelled mid-flight still unwinds.
- An empty saga succeeds.
Configuration
SagaBuilder<TContext> is the single entry point. Every method returns the builder for chaining:
| Method | Purpose |
|---|---|
AddStep(name, execute, compensate?, timeout?) | Add a step from a name, a forward action, an optional compensation, and an optional per-step timeout. |
AddResultStep<TResult>(name, execute, apply, compensate?, timeout?) | Add a step whose forward action returns a value; apply flows that value into the context for the next step. A distinct name (not an AddStep overload) so it can never capture an existing AddStep call. |
AddStep(SagaStep<TContext>) | Add an already-constructed SagaStep<TContext>. |
WithObserver(ISagaObserver) | Attach an observer for progress notifications. |
WithDiagnostics(SagaDiagnostics) | Attach the metrics meter. |
Build() | Snapshot the steps and produce a runnable Saga<TContext>. |
Build() snapshots the current steps into an array, so a built saga is immutable and can be reused
across many RunAsync calls. RunAsync keeps no state on the saga between runs.
DI registration
AddOrionSaga() registers the shared SagaDiagnostics as a singleton so it can be injected where
sagas are built:
using Moongazing.OrionSaga;
builder.Services.AddOrionSaga();
Sagas themselves are constructed per definition with SagaBuilder rather than resolved from the
container, so the diagnostics singleton is the only thing registered.
Telemetry
Build a saga with .WithDiagnostics(...) to emit metrics to the Moongazing.OrionSaga meter,
exposed as the SagaDiagnostics.MeterName constant. Three counters are published, each tagged with
an orion.outcome tag:
| Instrument | Tag values | Counts |
|---|---|---|
orion.saga.runs | succeeded / failed | Saga runs. |
orion.saga.steps | completed / failed | Step forward actions. |
orion.saga.compensations | compensated / failed | Compensations run during rollback. |
Wire it up with the DI singleton and subscribe with OpenTelemetry by meter name:
using Moongazing.OrionSaga.Diagnostics;
builder.Services.AddOrionSaga();
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter(SagaDiagnostics.MeterName));
// Where you build the saga:
var saga = new SagaBuilder<OrderContext>()
.WithDiagnostics(diagnostics) // injected SagaDiagnostics
.AddStep(/* ... */)
.Build();
SagaDiagnostics owns the Meter and is IDisposable; the DI container disposes the singleton for
you. Use the observer hook for per-step logging and the meter for aggregate counts; they are
independent and can be used together or alone.
Scope
OrionSaga is an in-process orchestrator: it coordinates the steps of one operation within a single process. It is not a durable, persisted workflow engine. If the process dies mid-saga, the in-flight run does not resume from where it stopped. For most "do these few things, undo on failure" cases that is exactly enough, and it stays dependency-light. See docs/FEATURES.md for the full capability list and docs/ROADMAP.md for where it may go.
Testing
The orchestrator is plain in-memory code, so testing a saga needs no harness, broker, or database:
build a saga over a test context, run it, and assert on the SagaResult and the context's recorded
effects. Because each step is just a delegate, a step that throws is the entire failure setup:
var saga = new SagaBuilder<Ledger>()
.AddStep("a",
(c, _) => { c.Events.Add("do-a"); return Task.CompletedTask; },
(c, _) => { c.Events.Add("undo-a"); return Task.CompletedTask; })
.AddStep("b", (_, _) => throw new InvalidOperationException("boom"))
.Build();
var result = await saga.RunAsync(new Ledger());
Assert.False(result.Succeeded);
Assert.Equal("b", result.FailedStep);
Assert.True(result.RolledBackCleanly);
The repository's own suite under tests/Moongazing.OrionSaga.Tests covers success ordering, reverse
compensation, the failing step not being compensated, compensation-failure isolation, the empty
saga, cancellation rollback, observer notifications and fault isolation, and DI registration.
Benchmarks
A BenchmarkDotNet suite under benchmarks/Moongazing.OrionSaga.Benchmarks measures the orchestrator
hot paths: assembling a saga, running it forward, unwinding it on failure, and the overhead of the
optional observer and diagnostics hooks. Every step body completes synchronously, so the numbers
reflect orchestration cost alone, not I/O.
Numbers are intentionally not committed, since micro-benchmark figures are hardware- and runtime-specific. Run the suite on your own machine:
dotnet run -c Release --project benchmarks/Moongazing.OrionSaga.Benchmarks
See benchmarks.md for the benchmark classes and how to run or filter them.
Design
- Multi-targets
net8.0,net9.0,net10.0. TreatWarningsAsErrors, latest analyzers, nullable enabled.- The executor is generic over your context type; the runtime dependencies are the DI abstractions and
Orion.Abstractions(the family's shared contracts spine). - A built saga is immutable and safe to reuse across runs.
Versioning
OrionSaga follows Semantic Versioning. The public surface is
SagaBuilder<TContext>, Saga<TContext>, SagaStep<TContext>, SagaResult, SagaOutcome,
CompensationFailure, ISagaObserver, SagaDiagnostics, and the AddOrionSaga extension. Breaking
changes to that surface come with a major version bump. See CHANGELOG.md for the
release history.
The library is currently at 0.7.0: the API is young and may still change ahead of a 1.0.
More from the Orion family
OrionSaga is one of a set of standalone .NET libraries:
- OrionGuard - guard clauses and validation ecosystem.
- OrionAudit - automatic EF Core change-audit trail.
- OrionKey - source-generated strongly-typed IDs.
- OrionLock - distributed locking.
- OrionPatch - transactional outbox for EF Core.
Contributing
Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.
License
This project is licensed under the MIT License.
Author
Tunahan Ali Ozturk - GitHub
Packages
| Package | Version | Downloads |
|---|---|---|
| OrionSaga | 0.7.0 | 1,044 |