How do you handle compensating transactions in a SAGA?
A compensating transaction semantically undoes a previously-committed local transaction. It is NOT a database ROLLBACK — the original transaction already committed. The compensation is a new transaction that reverses the effect.
Forward step → compensation
Forward Compensation
───────────────────────── ─────────────────────────
ReserveStock(orderId, items) → ReleaseStock(orderId, items)
ChargeCard(orderId, amount) → RefundCard(orderId, amount)
SendEmail(orderId, type) → SendCancellationEmail(orderId)
AwardPoints(customer, n) → RevokePoints(customer, n)
Properties every good compensation must have
1. Idempotent
Retries are inevitable. Calling ReleaseStock(orderId, items) ten times must have the same effect as calling it once.
public async Task ReleaseStockAsync(Guid orderId, List<LineItem> items)
{
// Use orderId as the idempotency key
if (await _db.StockReleases.AnyAsync(r => r.OrderId == orderId))
return; // already released, no-op
foreach (var item in items)
await _db.IncrementStockAsync(item.Sku, item.Quantity);
await _db.StockReleases.AddAsync(new { OrderId = orderId, At = DateTimeOffset.UtcNow });
await _db.SaveChangesAsync();
}
2. Must "always" succeed (or have its own retry policy)
A failed compensation leaks state — stock stays reserved forever, money stays held. Wrap with Polly:
await Policy
.Handle<TransientException>()
.WaitAndRetryAsync(5, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)))
.ExecuteAsync(() => _inventory.ReleaseAsync(orderId, items, ct));
// After max retries, push to a dead-letter table for manual ops review:
// SELECT * FROM saga_failures WHERE compensation_failed = true;
3. Reversible side effects only
You cannot compensate "ship physical box from warehouse to Delhi". Once the truck leaves, that's it.
Design rule: put compensable steps EARLY in the saga; put irreversible steps LAST. This way, if any step fails, you only need to compensate reversible work.
✅ Reserve stock → Charge card → Hand off to warehouse (point of no return)
❌ Hand off to warehouse → Charge card → Reserve stock
4. Audit-logged
Every compensation should leave a record. "Refunded ₹1,500 to order #12345 at 14:32 because shipping_failed."
When compensation is impossible — design alternatives
| Situation | Strategy |
|---|---|
| Email already sent | Send a follow-up "order cancelled" email — semantic compensation, not literal undo |
| Notification pushed | Same — push an "order cancelled" notification |
| Physical fulfilment started | Move the "point of no return" earlier in the saga; require ALL pre-commits before fulfilment |
| Third-party API has no refund endpoint | Use a manual ops queue; compensation is "alert humans" |
Common interview follow-up
"What if the compensation itself fails permanently?"
Answer: Three layers:
- Retry with exponential backoff (Polly, Temporal retry policy)
- Dead-letter queue / table — surface to ops dashboard
- Last resort: manual intervention runbook documented per failure type
A saga that "gets stuck" must page an on-call human. Silently failing compensations are how money disappears.
Anti-patterns
- Compensation in a different service than the forward step — keep them paired
- Compensation that needs synchronous downstream calls — those calls fail too; design to be async with retries
- Skipping idempotency — "it'll be fine, retries are rare" — they're not
- Forgetting compensations on new forward steps — code review must enforce the pair