Reference · Lesson 7

Compensation and sagas

What to do when a later step fails for good after an earlier one succeeded somewhere you can't roll back. One page.

The rule

You can't erase a charge. Write a new fact that cancels it (a refund), deliver that fact the same way you deliver everything else (outbox, key, at-least-once), and believe it only when the provider confirms it.

Words

termmeaning
saga"a reliably coordinated sequence of local transactions" (microservices.io). If one fails for good, run compensations for the ones already done.
compensationundoes a step "from a semantic point of view", not by restoring the old state (Garcia-Molina & Salem, 1987). A refund, a cancellation.
pivotthe step with no compensation (the parcel ships, the email goes out). Put it last.
lack of isolationothers can see the in-between state ("charged, refund on its way"). Design the UI for it.
2PCtwo-phase commit: all participants lock and wait for a coordinator. External APIs won't take part, so we don't use it.

The checkout saga

stepcompensationkeyconfirmed by
charge (PayCo)refundcharge:${orderId} / refund:${orderId}charge response · refund.updated webhook + getCharge
ship (Shipping) · pivotnoneoutbox message id (inbox)shipment.shipped event, or statusOf

State lives in its own row: fulfilment.state ∈ shipped · compensating · refunded. orders.status stays paid: the charge did happen.

Three rules

rulehownaive version, measured (npm run l7, seed 1)
1. Compensate only on a definite nolost or late event → ask Shipping (sweepStuck). null or a failed call → wait.refund-on-silence: 15 of 200 shipped AND refunded
2. Outbox + keyrejection → inbox + compensating + refund.requested outbox row in one tx. Relay → issueRefund with key refund:${orderId}.refund-inline: 3 never refunded (crash), 4 "already refunded" 400s for refunds that worked
3. Verify, ask, dedupesignature → getCharge now → one tx: inbox, compensating → refunded (conditional), one ledger rowignore (L6 flow): 21 charged, never shipped, never refunded

The four functions (src/compensation.ts)

functiondoesreturns
onShippingEvent(deps, ev)one tx: inbox dedupe · order not paid → ignore · shipped → shipped · rejected → compensating + outbox row. Never calls PayCo. Then crash point after-event-commit.applied · duplicate · ignored
issueRefund(deps, msg)PayCo refund through net (timeout + retry), key from the order · crash point after-refund-call · store refund_id if none. State stays compensating (refund is pending).the refund
onWebhook(deps, ev)bad signature → ValidationError · getCharge through net; failure → throw, record nothing · one tx: inbox, then only if that refund succeeded: compensating → refunded + one ledger rowapplied · duplicate · ignored
sweepStuck(deps)paid orders without a fulfilment row → statusOf through net · event → onShippingEvent · null / failure → leave it{ resolved, waiting }

Webhooks (Stripe's contract, which PayCo copies)

factso you…
forgeable: "Always verify that webhook events originate from Stripe before acting on them"check the signature first (HMAC, constant-time compare)
"Stripe doesn't guarantee the delivery of events in the order that they're generated"don't trust the payload's status: fetch the object now
"might occasionally receive the same event more than once""logging the event IDs you've processed" = an inbox
retried "for up to three days" until you answer 2xxon a failed lookup, throw and record nothing
two different events can describe the same factkeep a state guard (WHERE state = 'compensating') beside the inbox

Refunds (Stripe)

Invariants (lib/saga.ts, per order, against ground truth)

invariantprotected by
shipped once or refunded once; never both; never neitherrule 1 (no refund on silence) · rule 2 (outbox delivers the request)
≤ 1 refund at PayCorefund:${orderId} + conditional compensating insert
no refund unless Shipping rejectedrule 1
shop's fulfilment agrees with PayCo and Shippingrule 3 + sweepStuck
one ledger entry per succeeded refundinbox + conditional UPDATE
forged webhooks rejectedsignature check

Commands

npm run test:07                 # 14 tests
npm run l7                      # Part A (naive shops) + Part B (your saga, 50 worlds)
SEEDS=200 npm run l7            # → results/07-compensation.json (evidence: src/, no SEED, ≥ 50 seeds)
REJECT=0.3 npm run l7           # more rejections → results/07-compensation.reject0.3.json
IMPL=mutant SEEDS=50 npm run l7 # → results/07-compensation.mutant.json

Still not handled

Go deeper