Mehdi Akiki
Published on

Compensation Is a Business Operation, Not a Database Rollback

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Reference

When step four of a distributed workflow fails, we often say: “Roll back the first three steps.”

There is no database rollback across independent services after their commits. There are only new operations that try to repair the business outcome.

That difference changes how I design a saga.

A refund does not erase a charge

Consider an order workflow:

1. reserve inventory
2. charge payment
3. book shipment
4. confirm order

If shipment booking fails after the charge succeeds, the database history cannot jump backwards. The payment provider may support a refund, but a refund is another financial record. It may take time, incur a fee, fail, or require review.

The repaired history becomes:

payment charged
shipment booking failed
refund requested
refund accepted
inventory released
order cancelled

The customer and audit system can observe both the charge and refund. Calling this a rollback hides the real states.

Define compensation beside each forward step

I design the workflow as a table:

Forward stepDurable success evidenceCompensationIs compensation exact?
reserve stockreservation IDrelease reservationusually close
charge cardprovider charge IDrefund chargeno; money moved twice
book shipmentbooking IDcancel bookingdepends on carrier state
send confirmationmessage IDsend correctionno; email cannot be unsent

The final column prevents fictional symmetry. Some effects can be reversed closely, some only counteracted, and some cannot be meaningfully undone.

The Azure compensating transaction pattern makes the same core point: undo work is application-specific, may not restore the original state, and can itself fail.

Keep the saga state explicit

I store state outside the model or request process:

type Saga = {
  sagaId: string;
  orderId: string;
  status:
    | "running"
    | "compensating"
    | "compensation_failed"
    | "completed"
    | "cancelled";
  nextStep: string;
  version: number;
};

type SagaEffect = {
  sagaId: string;
  step: string;
  direction: "forward" | "compensate";
  effectKey: string;
  status: "planned" | "requested" | "confirmed" | "failed";
  externalReference?: string;
  attempts: number;
};

effectKey identifies the intended operation across process retries. The external reference is evidence returned by the service, such as a charge or reservation ID.

A conversation transcript is not enough. It does not enforce unique effects or tell a restarted worker which remote outcome is already durable.

Compensation order follows dependencies, not a slogan

Undoing in reverse order is a useful starting point:

forward:     reserve -> charge -> ship
compensate:  cancel ship -> refund -> release

But independent operations can compensate in parallel, and business constraints may require another order. For example, stock can be released immediately while a refund waits for the provider.

I draw dependencies:

cancel shipment confirmed
        |
        v
close order

refund requested -------> customer notification
release inventory ------> inventory available

The saga is complete only when its required terminal conditions are true, not when one reverse loop reaches index zero.

Every compensation needs idempotency

The refund request can time out after the provider accepts it. Retrying with a new identity may refund twice.

I derive a stable key:

compensation key = saga_id + forward_step + forward_effect_id + compensation_kind

All attempts for the same intended refund use this key. On an ambiguous result, the worker reconciles with the provider before deciding whether another request is safe.

Forward and compensation identities must differ. “Charge order 17” and “Refund charge ch_82” are two effects, not two attempts of one effect.

A compensation can become impossible

Inventory may already be consumed. A parcel may have left the carrier. A domain deadline may pass while retries run.

I model the outcomes:

compensated
compensation_pending
compensation_rejected
manual_resolution_required
accepted_residual_effect

The last two are uncomfortable but honest. A system that retries forever is not more consistent; it is hiding a business decision inside infrastructure.

For an irreversible effect, I move approval or a final invariant check before the point of no return when possible.

Concurrent changes require preconditions

Between the forward step and compensation, another workflow may legitimately change the resource.

This command is too broad:

set inventory.available = inventory.available + 1

A safer release refers to the exact reservation:

release reservation res_441 if it is still active

Compensation should target the effect it owns, not calculate a guessed inverse from current aggregate state.

Failure handling is a state machine

For each compensation I define:

SituationResponse
network timeoutreconcile by effect key, then bounded retry
provider rate limitwait according to policy and deadline
validation rejectionstop retrying and classify
resource already compensatedrecord idempotent success
resource changed by another processevaluate precondition and escalate
retry budget exhaustedenter visible manual state

This is why a generic catch { undoAll() } is insufficient.

Test the second failure

Teams test that step four fails and compensation starts. I also fail the compensation:

crash after refund accepted but before local confirmation
release succeeds twice from duplicate delivery
carrier cancellation returns unknown
manual operator and worker compensate concurrently
workflow restarts halfway through reverse steps

I assert the final external effects and saga evidence, not only the final status string.

The user-facing outcome belongs in the design

Compensation can take minutes or days. During that time the product needs an honest state such as “Cancellation in progress; refund requested.” Showing “Cancelled” before the refund is confirmed can make the system look finished when important work remains.

The operational signals I keep are:

  • sagas in compensation by age and business value;
  • compensation retry rate by provider and error class;
  • ambiguous remote effects awaiting reconciliation;
  • manual cases and their owner;
  • time from forward failure to safe terminal outcome.

A compensation is a forward-moving repair. Once I model it this way, it receives the same care as any production operation: stable identity, current preconditions, bounded retries, observability, and a named outcome when automation cannot finish.