Articles · Sagas & Workflows ·036
Standalone Courier Activities
Runnable sample on GitHub
Sample:
samples/036-standalone-courier-activities— a checkout courier that compensates on failure.dotnet test. No database.Prerequisite: 017 — Routing Slips & Compensation.
Overview
017 showed compensation inside a saga: forward legs record undo-commands onto a durable itinerary, and a failure replays them in reverse as reliable, scheduled commands. That machinery — persisted saga state, the scheduler, the outbox — is exactly what you want when the process is long-running, spans message round-trips, and must survive crashes.
But sometimes you just have a bounded sequence of steps to run right here, right now, each of which acquires something that must be released if a later step fails: reserve stock, charge the card, book a courier. There’s no waiting on inbound events, no timeout, no need to outlive the current call — but you still want the routing-slip discipline of “undo what already committed, in reverse, on failure.”
Relay’s CourierExecutor is that standalone primitive. You hand it an ordered list of
ICourierActivity steps. It runs each one forward; if any step throws, it compensates the
already-completed steps in reverse (LIFO) order and returns a CourierResult describing the outcome.
Same Courier pattern as the in-saga routing slip — minus the saga.
Why this exists
The routing-slip-in-a-saga from 017 is the right tool for a durable, long-running, distributed process.
It’s overkill for a bounded, in-process one. If your three steps run synchronously inside a single
command handler and the whole thing either finishes or fails before you return, you don’t need a
relay_sagas row, you don’t need the scheduler, and you don’t need to model inbound events to drive each
leg. What you do still need is the part everyone gets wrong by hand: remembering which steps committed,
undoing them in the right (reverse) order, and not undoing the step that failed or the steps that never
ran. CourierExecutor makes that correct by construction, with no infrastructure.
When to use this
- A bounded, in-process sequence with compensation. Reserve → charge → book, run synchronously in one handler, where a late failure must release the early acquisitions in reverse.
- Acquire-then-maybe-undo steps that don’t need durability. The whole sequence completes (or rolls back) within the current operation; you don’t need it to survive a crash mid-flight.
- A drop-in alternative to ad-hoc try/catch rollback. Anywhere you’d otherwise hand-roll “undo the things I already did,” in the right order, on failure.
When not to use this
- When you need durable, long-running state or timeouts. If the process spans inbound events, waits on other services, or must survive a process restart mid-flight, use a saga (and the in-saga routing slip from 017) — its itinerary is persisted and its compensations go through reliable messaging.
- Steps with no meaningful undo. If a step can’t be compensated (an irreversible email, a fired webhook), the courier can’t restore the world any more than a saga can. Sequence irreversible steps last, or design forward recovery.
- A single step / no compensation needed. One step has nothing to roll back; just call it.
- Local steps in one transaction. If every step shares one database transaction, use the transaction — compensation is for steps that already committed separately.
Concepts
ICourierActivity. One step of the itinerary, with two methods:
ExecuteAsync(CancellationToken)— the forward action (acquire the resource).CompensateAsync(CancellationToken)— the undo (release it). Only ever called for an activity whoseExecuteAsynccompleted.
Itinerary. The ordered IReadOnlyList<ICourierActivity> you pass to the executor. Steps run
forward in list order.
LIFO compensation. When a step throws, the executor pops the already-completed steps off a stack and
calls CompensateAsync on each — most-recent first. The step that failed did not commit, so it is
not compensated; steps after the failure never ran, so they are neither executed nor
compensated.
CourierResult. The outcome of the run:
Succeeded—trueif every activity executed;falseif a failure triggered compensation.Failure— the exception that triggered compensation (nullon success).CompensationErrors— any exceptions thrown by compensating actions. Compensation is best-effort: it always attempts every completed step even if one undo throws, and collects the errors here rather than abandoning the rest.
Architecture
sequenceDiagram
participant C as CourierExecutor
participant A1 as ReserveStock
participant A2 as ChargeCard
participant A3 as BookCourier
Note over C: run the itinerary forward, in order
C->>A1: ExecuteAsync() ✓ committed
C->>A2: ExecuteAsync() ✓ committed
C->>A3: ExecuteAsync() ✗ throws
Note over C: failure ⇒ compensate completed steps in REVERSE (LIFO)
C->>A2: CompensateAsync()
C->>A1: CompensateAsync()
Note over C,A3: A3 did not commit ⇒ not compensated; CourierResult.Failure = the A3 exception
Building it step by step
The sample is
samples/036-standalone-courier-activities.
1. An activity per step (forward + compensate)
public sealed class ReserveStock : ICourierActivity
{
public Task ExecuteAsync(CancellationToken ct = default) => _inventory.ReserveAsync(_orderId, ct);
public Task CompensateAsync(CancellationToken ct = default) => _inventory.ReleaseAsync(_orderId, ct); // idempotent undo
}
2. Run the itinerary and inspect the result
var courier = new CourierExecutor();
var result = await courier.ExecuteAsync(new ICourierActivity[]
{
new ReserveStock(inventory, orderId),
new ChargeCard(payments, orderId),
new BookCourier(logistics, orderId),
});
if (!result.Succeeded)
{
// result.Failure is the activity exception that triggered the (already-completed) reverse-order undo.
// result.CompensationErrors holds anything an undo itself threw (compensation still ran the rest).
throw new CheckoutFailedException(result.Failure!);
}
That’s the whole API: implement ICourierActivity per step, hand the list to CourierExecutor.ExecuteAsync,
read CourierResult.
Complete source code
| File | Contents |
|---|---|
CheckoutCourierTests.cs |
A recording ICourierActivity; success runs all forward; failure compensates in reverse (LIFO); the failing and not-yet-run steps are never compensated |
Logistics.Courier.Tests.csproj |
References Nuvora.Nexus.Relay.Core + Nuvora.Nexus.Relay.Sagas (the courier lives in Nuvora.Nexus.Relay.Sagas.Courier) |
Running the example
dotnet test samples/036-standalone-courier-activities/Logistics.Courier.Tests
Needs only the .NET 10 SDK — the courier is pure, in-process, with no database or broker.
Testing
The three properties that make standalone compensation correct are pinned directly. Each test uses an
in-memory RecordingActivity that appends exec:<name> / comp:<name> to a shared log, so order is
observable with no infrastructure:
// (a) all succeed ⇒ every step runs forward, nothing is compensated
result.Succeeded.Should().BeTrue();
log.Should().Equal("exec:ReserveStock", "exec:ChargeCard", "exec:BookCourier");
// (b) a failure at step N ⇒ completed steps undone in REVERSE (LIFO); result reports the failure
result.Succeeded.Should().BeFalse();
result.Failure.Should().BeOfType<InvalidOperationException>();
log.Should().Equal("exec:ReserveStock", "exec:ChargeCard", "exec:BookCourier",
"comp:ChargeCard", "comp:ReserveStock");
// (c) the failing step and not-yet-run steps are never compensated
log.Should().Equal("exec:ReserveStock", "exec:ChargeCard", "comp:ReserveStock");
log.Should().NotContain("comp:ChargeCard"); // failed step did not commit
log.Should().NotContain("exec:BookCourier"); // step after the failure never ran
The LIFO test is the load-bearing one: it proves the undo runs most-recent-acquisition-first, exactly like the in-saga routing slip — but without persisting anything.
Production considerations
- Compensations must be idempotent. Even in-process, a
CompensateAsynccan run against a service that already saw a duplicate request; a second “release stock” / “refund” should be a no-op. Same rule as the in-saga routing slip (017). - Watch
CompensationErrors. Compensation is best-effort: the executor runs every completed step’s undo even if one throws, and collects the failures on the result. An undo that fails leaves an orphaned resource — log/alert on a non-emptyCompensationErrors, don’t swallow it. - Irreversible steps still bite you. A step with no clean undo can’t be rolled back here either. Order irreversible steps last, or model forward recovery.
- This is in-process and not durable — by design. If the process dies mid-itinerary, nothing replays the remaining compensations. When the steps span services or must survive a crash, you need durability: promote to a saga with the in-saga routing slip (017), whose itinerary is persisted and whose compensations go through reliable messaging (014) so undo is as crash-safe as the forward steps.
- The token nuance. Compensation deliberately does not honour the (possibly cancelled) cancellation token — it always attempts to undo committed work even when the trigger was a cancellation. Don’t rely on cancellation to stop a rollback.
Common mistakes
- Compensating in forward order. Undo must be reverse (LIFO) — release the most recent acquisition first. The executor does this for you; don’t re-implement it forward by hand.
- Compensating the failing step. The step that threw did not commit its forward action; compensating
it would double-undo or undo nothing. The executor correctly skips it — your
CompensateAsyncshould assumeExecuteAsyncran. - Non-idempotent compensations. Even in-process, treat undo as something that might run against an already-undone resource and make it a no-op.
- Reaching for a saga when a courier suffices (and vice versa). A bounded in-process sequence doesn’t need persisted state; a long-running, crash-spanning process does. Match the tool to the lifetime.
- Ignoring
CompensationErrors. A cleanSucceeded == falsewith a populatedCompensationErrorsmeans the rollback itself partially failed — that’s an orphan waiting to happen.
Tradeoffs
Benefits. Routing-slip discipline — correct reverse-order, all-or-nothing undo — with zero
infrastructure: no relay_sagas row, no scheduler, no outbox, no inbound-event modelling. Pure and
in-process, so it’s trivially fast to test and reason about. Best-effort compensation that always runs
every completed step and surfaces undo failures instead of hiding them.
Costs. Not durable: a crash mid-itinerary abandons the remaining compensations — there is no replay. No timeouts or long-running waits. Still requires a correct, idempotent compensating action per step, and still can’t undo genuinely irreversible steps. It’s the bounded, in-process end of the spectrum; the moment you need durability you’ve outgrown it.
Alternatives
- In-saga routing slip (017). The durable, long-running sibling: persisted itinerary + reliable compensating commands. Use it when the process spans services/time and must survive crashes. The courier here is the same pattern without the durability.
- Hand-rolled try/catch rollback. What
CourierExecutorreplaces — easy to get the order wrong, forget a step, or undo the failing step. Don’t. - One database transaction. If every step is local and shares a transaction, just use it — no compensation needed.
- Forward recovery (retry). For transient failures that will eventually succeed, retrying forward beats undoing. Use compensation for genuine, undo-required failures.
Lessons from production systems
- The reverse-order, skip-the-failure rollback is exactly what teams get wrong by hand. Hand-rolled in-process cleanup forgets a step, undoes forward, or undoes the step that never committed. The courier makes all three correct by construction.
- “It’s only in-process, so it’s safe” is the trap. A crash mid-itinerary still leaves orphans — there’s no replay. Teams that conflated “in-process” with “durable” learned it the hard way; when durability matters, promote to a saga.
- Compensation failures are invisible until you look.
CompensationErrorsis the early-warning signal that an undo didn’t take. Monitor it, or the first orphaned reservation teaches you.
Should you use this?
| Situation | Recommendation |
|---|---|
| Bounded, in-process sequence that must undo on failure | Strong fit — the core use |
| Acquire-then-maybe-undo steps within one operation | Strong fit |
| Long-running / crash-spanning / event-driven process | Use a saga + routing slip (017) |
| Transient failures that will eventually succeed | Prefer forward recovery (retry) |
| Steps with no possible undo | Avoid — sequence last / forward-only |
| Single step / one local transaction | No — overkill |
Next steps
- 017 — Routing Slips & Compensation: the durable, in-saga sibling of this pattern — when the sequence must survive crashes and span services.
- 015 — Sagas: the full state-stored process manager you graduate to when a bounded courier is no longer enough.