Articles · Sagas & Workflows ·017
Routing Slips & Compensation
Runnable sample on GitHub
Sample:
samples/017-routing-slips— a booking saga that compensates on failure.dotnet test. No database.Prerequisite: 015 — Sagas.
Overview
A distributed process books a flight, a hotel, and a car across three services. The flight and hotel succeed; the car fails. Now you must undo the flight and hotel — but there’s no distributed transaction to roll back; each booking already committed in its own service. The routing slip (Courier pattern) solves this with compensation: as each forward step completes, the saga records the command that would undo it onto a persisted itinerary; on failure, it replays those compensations in reverse order as reliable commands. Because the itinerary position is committed, a redelivered failure compensates nothing new (idempotent rollback). It’s a saga specialization for “do a sequence of distributed steps, and cleanly undo on failure.”
Why this exists
You cannot wrap bookings in three different services in one ACID transaction — there’s no shared
transaction, and 2PC across services is the brittle approach event-driven architectures abandoned
(article 011). So “all or nothing” across services must be achieved semantically: if a later step
fails, explicitly undo the earlier ones with compensating actions (cancel the flight, cancel the hotel).
Doing this ad hoc is error-prone — you must remember which steps completed, undo them in the right
(reverse) order, and not double-undo if the failure is processed twice. The routing slip makes it
systematic: the saga maintains the itinerary (the recorded compensations) as part of its persisted
state, so “what’s been done and how to undo it” is durable and correct, and replaying compensations is a
single Compensate() call. It turns “saga that happens to undo things” into a disciplined,
crash-safe pattern.
When to use this
Use a routing slip when a process performs a sequence of distributed steps that must be undone as a unit on failure:
- Travel/booking: flight + hotel + car; any failure releases the rest.
- Order provisioning: reserve inventory + charge payment + allocate a license; if allocation fails, refund and release.
- Multi-service onboarding: create accounts/resources across systems; on a late failure, tear down what was created.
- Any “saga with compensation” where forward steps acquire resources that must be released on rollback.
The signal: distributed steps that acquire something (a reservation, a charge, a resource), where a failure partway through must release what was already acquired, in reverse.
When not to use this
- Steps with no meaningful “undo.” If a step can’t be compensated (you sent an irreversible email, fired a missile), a routing slip can’t restore the world. Design for forward recovery, idempotency, or a human step instead.
- A single step / no compensation needed. If there’s nothing to undo (or only one step), it’s a plain saga or a domain event — not a routing slip.
- When the steps are local and transactional. If all steps share one database transaction, just use the transaction; compensation is for distributed steps that already committed separately.
The costs: every forward step needs a correct, idempotent compensating command; compensation is eventually consistent (the world is briefly in a partially-undone state); and “undo” is only as good as your compensations — a wrong compensation leaves orphaned resources.
Concepts
Itinerary. The saga’s persisted list of recorded compensations (a JSON array on relay_sagas), each
the command that undoes one completed step.
RecordCompensation(command). Called by a forward step to append its undo-command to the itinerary.
It does not issue the command — it just records it.
Compensate(). Replays the recorded compensations in reverse (LIFO) order as reliable, scheduled
commands (via the scheduler/outbox), so the most recent step is undone first.
ItineraryPosition. The committed count of compensations already replayed. It makes rollback
idempotent: a redelivered failure resumes from the position and re-issues nothing already compensated.
Reliable compensation. Compensating commands are sent as reliable messages, so they reach their services even across crashes — undo is as durable as the forward steps.
Architecture
sequenceDiagram
participant S as BookingSaga
participant It as Itinerary (relay_sagas)
participant Sch as Scheduler/Outbox (compensating commands)
Note over S: forward legs RECORD compensations
S->>It: RecordCompensation(Cancel flight)
S->>It: RecordCompensation(Cancel hotel)
S->>It: RecordCompensation(Cancel car)
Note over S: a failure REPLAYS them in reverse
S->>Sch: Cancel car
S->>Sch: Cancel hotel
S->>Sch: Cancel flight
Note over It,Sch: ItineraryPosition committed ⇒ a re-delivered failure issues nothing new
Building it step by step
The sample is samples/017-routing-slips.
1. A compensating command per step
public sealed record CancelStep : ICommand { public string Step { get; init; } = ""; } // undoes one booked step
2. Forward legs record; failure compensates
public sealed class BookingSaga : Saga<BookingState>
{
protected override void Configure(ISagaConfigurator<BookingState> cfg) => cfg
.StartedBy<TripStarted>(e => e.TripId, OnStarted)
.Handle<HotelBooked>(e => e.TripId, OnHotel)
.Handle<CarBooked>(e => e.TripId, OnCar)
.Handle<TripFailed>(e => e.TripId, OnFailed);
private Task OnStarted(TripStarted e, CancellationToken ct) { State.TripId = e.TripId; RecordCompensation(new CancelStep { Step = "flight" }); return Task.CompletedTask; }
private Task OnHotel (HotelBooked e, CancellationToken ct) { RecordCompensation(new CancelStep { Step = "hotel" }); return Task.CompletedTask; }
private Task OnCar (CarBooked e, CancellationToken ct) { RecordCompensation(new CancelStep { Step = "car" }); return Task.CompletedTask; }
private Task OnFailed (TripFailed e, CancellationToken ct) { Compensate(); Complete(); return Task.CompletedTask; } // reverse-order undo
}
The declarative DSL does the same with .Compensate(factory) (record) and .Compensate() (trigger) — see
the framework’s tests and article 016.
Complete source code
| File | Contents |
|---|---|
BookingSaga.cs |
The saga, compensating command, and state |
RoutingSlipTests.cs |
Record-only, reverse replay, idempotent rollback |
Running the example
dotnet test samples/017-routing-slips/Sagas.RoutingSlip.Tests
Testing
The three properties that make compensation correct are pinned directly:
// forward legs only RECORD (no commands issued yet)
ScheduledCompensations(harness).Should().BeEmpty();
harness.Repo.Single().ItineraryPosition.Should().Be(0);
// a failure replays compensations in REVERSE order
await coordinator.DeliverAsync(typeof(BookingSaga), new TripFailed { TripId = id }, default);
ScheduledCompensations(harness).Should().Equal("car", "hotel", "flight"); // LIFO
// a REDELIVERED failure compensates nothing new (committed itinerary position)
await coordinator.DeliverAsync(typeof(BookingSaga), new TripFailed { TripId = id }, default);
ScheduledCompensations(harness).Length.Should().Be(afterFirst);
The idempotency test is the crucial one: at-least-once delivery means the failure event can arrive twice;
the committed ItineraryPosition ensures the second processing re-issues no compensations.
Production considerations
- Compensations must be correct and idempotent. “Cancel the flight” might itself be delivered twice; the target service should treat a second cancel as a no-op. A wrong compensation orphans a resource — test compensations as carefully as forward steps.
- Run on reliable messaging + EF Core persistence. The itinerary lives in
relay_sagas; compensating commands go through the scheduler/outbox so they reach their services durably. Compensation is only as reliable as the messaging beneath it. - Accept the partially-undone window. Between a failure and the completion of compensation, the world is partially rolled back (flight cancelled, hotel not yet). That’s eventual consistency; design downstream views/UX to tolerate it.
- Some steps can’t be compensated — plan for them. An irreversible action (a sent notification) has no clean undo. Order steps so irreversible ones come last, or model a forward-recovery / human-intervention path.
- Watch for compensation failures. A compensating command can itself fail; it retries/dead-letters like any reliable message. Alert on stuck compensations — a failed undo leaves an orphan.
Common mistakes
- Issuing compensations on the forward path. Forward legs record; they don’t issue. Issuing early (or not recording) breaks the pattern.
- Compensating in forward order. Undo must be reverse (LIFO) — release the most recent acquisition first. Forward-order compensation can violate dependencies.
- Non-idempotent compensations / ignoring the itinerary position. A redelivered failure must not
double-undo. Rely on the committed
ItineraryPosition; make compensating commands idempotent. - Assuming compensation = rollback. It’s semantic undo, eventually consistent, not an atomic rollback. The system is briefly inconsistent; that’s inherent.
- Compensating the uncompensatable. If a step truly can’t be undone, no routing slip saves you — solve it by ordering, idempotency, or a human step, not by pretending it compensates.
Tradeoffs
Benefits. “All-or-nothing across services” without 2PC; correct reverse-order, crash-safe, idempotent undo; the itinerary makes “what’s done and how to undo it” durable and explicit; built on reliable messaging.
Costs. A correct, idempotent compensating command per step; eventual consistency (a partially-undone window); reliance on compensations actually being possible and correct; and the operational need to watch for failed compensations/orphans.
Alternatives
- Two-phase commit / distributed transactions. Atomic in theory, brittle and poorly supported across services in practice — the approach routing slips replace.
- Forward recovery (retry until success) instead of compensation. When a step will eventually succeed (a transient failure), retrying forward beats undoing. Use forward recovery for transient failures, compensation for genuine, undo-required failures — often both in one saga.
- Idempotent “set” operations with no undo. If steps are convergent/idempotent set-operations rather than resource acquisitions, you may not need compensation at all — re-running converges. Routing slips are for acquiring steps that must be released.
Objectively: routing slips win for distributed acquire-then-maybe-undo sequences; forward recovery wins for transient failures; and convergent operations may need neither.
Lessons from production systems
- The reverse-order, idempotent rollback is exactly what teams get wrong by hand. Hand-rolled
compensation forgets a step, undoes in the wrong order, or double-undoes on a duplicate failure. The
itinerary +
Compensate()makes all three correct by construction. - Compensations are second-class until they fail in production. Teams test the happy path and under-test the undo path; the first real failure reveals a wrong or non-idempotent compensation. Treat compensating commands as first-class, tested code.
- Irreversible steps are the real-world snag. The booking-cancel examples compensate cleanly; a sent email or a fired webhook doesn’t. The mature pattern is to sequence irreversible steps last and accept forward-only handling for them.
- Orphans come from failed compensations. A compensation that itself fails and isn’t noticed leaves a reserved resource forever. Monitoring stuck/dead-lettered compensations is as important as monitoring the forward flow.
Should you use this?
| Situation | Recommendation |
|---|---|
| Distributed sequence that must undo on failure | Strong fit — the core use |
| Acquire resources across services, release on rollback | Strong fit |
| Transient failures that will eventually succeed | Prefer forward recovery (retry) |
| Convergent/idempotent set-operations | May need no compensation |
| Steps with no possible undo | Avoid — sequence last / human step instead |
| Single step / one service / local transaction | No — overkill |
Next steps
Sagas schedule timeouts — work to run at a future time. That scheduling capability is useful on its own, beyond sagas: “send this reminder in 30 minutes,” “retry this in an hour.” The next article covers the scheduler directly — durable, delayed execution of commands and messages.