Summary#
Workflow.BeginSaga() opens a saga scope — a durable transactional scope for a multi-step saga. Its one
load-bearing idea is coupling: await saga.Run("step", step, () => Undo(step)) runs a step and registers its
compensation in a single call, so a step can never run without its undo. If any later step fails, disposing the scope
without calling saga.Complete() runs every registered undo in reverse order.
Reach for it only for the complex case. A simple saga — one step, one compensation — is cleaner as a plain
try/catch around Workflow.Run (start a workflow) (nothing to couple). A saga with N steps that must unwind in reverse is where
pure try/catch forces a compensation staircase — each step's catch re-listing every earlier undo — and that
duplicated, hand-ordered list is exactly the bug-farm the scope removes.
Signature#
await using var saga = Workflow.BeginSaga(); // open the scope; dispose-without-Complete unwinds, in reverse
await saga.Run("step", step, () => Undo(args)); // run a child-workflow step AND register its compensation
await saga.Run("step", step); // a step with no compensation (e.g. the last one)
saga.OnUnwind(() => Undo(args)); // register a compensation with NO forward step
saga.Complete(); // every step stood → drop the undo stack (dispose becomes a no-op)step is an entity-typed value whose type has exactly one workflow bound to it — the same inference Workflow.Run (start a workflow)
uses. A compensation lambda's body is a single call: an app function (() => VoidAuth(auth)) or a compensating
child workflow (() => Workflow.Run(new ReturnShipment { Shipment = s })).
Description#
await using var saga = Workflow.BeginSaga(); is a C# using declaration: the scope is disposed at the end of the
enclosing block, on every exit path — normal fall-through, a goto to a terminal, or a thrown fault. Disposal runs
the saga's registered compensations unless saga.Complete() was reached first.
Each step registers its undo, and only committed steps compensate. await saga.Run("step", step, () => Undo(step)) starts
the child workflow bound to step and waits for it (a durable wait — the flow parks and resumes exactly like an
awaited Workflow.Run (start a workflow)). On the step's success, its undo is pushed onto the scope's stack; on a failure, the
call throws WorkflowError (or WorkflowCancelled) and registers nothing — a step that never committed is
never compensated. So a failing step propagates its fault to your catch, and the scope unwinds the steps that did
stand.
Reverse order is automatic, and disposal runs before the terminal transition. The undo stack is LIFO, so
compensations run newest-first. Because a workflow goto is deferred to the end of the body, the await using
disposal runs its compensations before the routing goto takes effect — you compensate, then route, with no
explicit unwind call.
A compensation is a function or a child workflow. Same step-granularity rule as a forward step: a DB-only or
single-call undo is an app function the saga names; a multi-step, waiting undo (a real return: pickup → inspect →
refund) is a child workflow (() => Workflow.Run(new Return { … })). A child-workflow undo waits too — disposal
parks on it and resumes when it terminals, then continues unwinding the rest of the stack.
saga.OnUnwind(() => Undo(…)) registers a compensation that has no forward step — useful when something you did
outside a saga.Run (a side effect earlier in the body) still needs undoing if the saga rolls back. It pushes onto the
same stack, in call order.
saga.Complete() marks the saga successful: it drops the undo stack, so the await using disposal becomes a no-op.
Call it once every step has stood, just before you route to the success terminal.
The scope is durable. The undo stack rides the parked continuation, so a saga that waits across steps (or across a restart) resumes with its registered compensations intact.
Examples#
A four-step fulfillment saga, shown as the complete app the docs gate compiles. Each step is a child workflow that waits
on the outside world; the first three couple a plain-function compensation, the shipment step's compensation is itself a
child workflow (a real multi-step return), and saga.OnUnwind registers one more undo that has no forward step. If any
step fails, the committed steps unwind in reverse and the order routes to the error terminal.
First the world the saga runs in — the order and its customer, the four workflow-bound step entities, the compensating
ReturnShipment child, and the compensation functions the saga names:
enum OStatus { Received, Shipped, Rejected, Cancelled }
enum StepStatus { Waiting, Done }
entity Customer { [Required, MaxLength(60)] string Name; decimal LastRefund; }
entity Order { [Required] Customer Customer; decimal Total; OStatus Status = OStatus.Received; }
entity PaymentAuth { [Required] Order Order; decimal Amount; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity InventoryReservation { [Required] Order Order; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity WarehousePick { [Required] Order Order; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity Shipment { [Required] Order Order; StepStatus Status = StepStatus.Waiting; }
entity ReturnShipment { [Required] Shipment Shipment; StepStatus Status = StepStatus.Waiting; }
void VoidAuth(PaymentAuth auth) { auth.Undone = true; }
void ReleaseReservation(InventoryReservation reserve) { reserve.Undone = true; }
void CancelPick(WarehousePick pick) { pick.Undone = true; }
void RevokeLoyalty(Customer customer, decimal amount) { customer.LastRefund = amount; }
workflow PaymentAuthFlow { Tracks = PaymentAuth.Status; Autostart = false; Initial = Waiting;
event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow InventoryReservationFlow { Tracks = InventoryReservation.Status; Autostart = false; Initial = Waiting;
event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow WarehousePickFlow { Tracks = WarehousePick.Status; Autostart = false; Initial = Waiting;
event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow ShipmentFlow { Tracks = Shipment.Status; Autostart = false; Initial = Waiting;
event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow ReturnShipmentFlow { Tracks = ReturnShipment.Status; Autostart = false; Initial = Waiting;
event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }Then the saga itself — one await using scope in the order workflow's enter body, coupling each step with its undo:
workflow OrderFlow {
Tracks = Order.Status; Autostart = false; Initial = Received;
state Received {
enter {
await using var saga = Workflow.BeginSaga(); // dispose-without-Complete unwinds, in reverse
try {
var auth = Workflow.Once("make-auth", () => new PaymentAuth { Order = this.Item, Amount = this.Item.Total });
await saga.Run("auth", auth, () => VoidAuth(auth)); // run step 1 + register its undo, atomically
var reservation = Workflow.Once("make-reservation", () => new InventoryReservation { Order = this.Item });
await saga.Run("reservation", reservation, () => ReleaseReservation(reservation));
var pick = Workflow.Once("make-pick", () => new WarehousePick { Order = this.Item });
await saga.Run("pick", pick, () => CancelPick(pick));
var shipment = Workflow.Once("make-shipment", () => new Shipment { Order = this.Item });
await saga.Run("shipment", shipment, () => Workflow.Run(new ReturnShipment { Shipment = shipment })); // undo = a child workflow
saga.OnUnwind(() => RevokeLoyalty(this.Item.Customer, this.Item.Total)); // an undo with no forward step
saga.Complete(); // every step stood → drop the stack
goto Shipped;
} catch (WorkflowError e) { goto Rejected; } // dispose unwinds the committed steps (reverse), then routes
catch (WorkflowCancelled e) { goto Cancelled; }
}
}
terminal success Shipped { }
terminal error Rejected { Message = "order rejected"; }
terminal error Cancelled { Message = "order cancelled"; }
}Notes#
- Simple vs complex. One step, one compensation → a plain
try/catcharound Workflow.Run (start a workflow) is cleaner; the scope earns its place only when N steps must unwind in reverse (it removes the compensation staircase). - Couple the undo with the step.
await saga.Run("step", step, undo)is the point — you cannot run a step and forget its compensation, and you never hand-maintain a reverse-ordered undo list. - Only committed steps compensate. A step that throws registered no undo, so it is not compensated; the steps before it are.
awaitis the wait.await saga.Run("step", ...)andawait saga.DisposeUnwind()(the disposal theawait usinggenerates) are durable waits — the flow parks and resumes, even across a restart.saga.OnUnwindandsaga.Completeare not waits.Complete()or it rolls back. Reachingsaga.Complete()is what commits the saga; any exit before it (a fault, or agotoout of the block) runs the compensations.
See also#
- Workflows that outlive the code that started them — what a deploy does to a saga parked halfway through its steps
- Workflow.Run (start a workflow) — start a workflow on an entity; the awaited form is a single durable step (the simple-saga case).
- <span class="planned" title="this page is planned and not written yet">workflow-goto</span> — transition within a workflow body (deferred to body-end, which is why disposal compensates first).
- For(entity).Audit — read a workflow's timeline of events.
- Step labels (naming a child run so it survives a new version) — naming a step so a run parked on it survives a new version of this saga.