Compensating Transaction Pattern
Undo the effects of a completed transaction by executing a counter-operation, enabling eventual consistency in long-running distributed business processes.
Overview
The Compensating Transaction Pattern undoes the effects of a completed business operation by executing a semantic counter-operation. Unlike database rollback (which undoes uncommitted changes), compensating transactions undo operations that were already committed to external systems — payments that have been charged, inventory that has been reserved, or emails that have been sent.
In distributed systems, ACID transactions across services are impractical. The Saga Pattern coordinates a sequence of local transactions, and when one step fails, compensating transactions roll back previously completed steps. That’s what lets long-running business processes keep eventual consistency without distributed locks or two-phase commit.
When to Use
Use the Compensating Transaction Pattern when:
- A business process spans multiple distributed services or databases
- You need to undo operations that have already been committed externally
- Two-phase commit (2PC) is unavailable or impractical (most microservice architectures)
- Long-running processes (seconds to days) need failure recovery semantics
When to Avoid
- The operation is within a single database and a simple transaction rollback works
- Compensating logic is impossible (e.g., an email already sent to a customer)
- The business process is so short that distributed transactions are acceptable
- Compensating transactions would themselves fail, creating an unrecoverable state
Solution
Python
from dataclasses import dataclass
from typing import List, Callable, Optional
from datetime import datetime
import uuid
@dataclass
class StepResult:
success: bool
step_name: str
compensation_needed: bool = False
compensation_error: Optional[str] = None
class SagaOrchestrator:
"""Coordinates a saga with compensating transactions"""
def __init__(self):
self.completed_steps: List[dict] = []
self.compensation_log: List[dict] = []
def execute(self, steps: List[dict]) -> StepResult:
"""
steps: list of dicts with 'name', 'action', 'compensate'
Each is a callable that returns success boolean
"""
for i, step in enumerate(steps):
print(f"Executing step {i+1}: {step['name']}")
success = step['action']()
if success:
self.completed_steps.append({
"index": i,
"name": step["name"],
"compensate": step["compensate"]
})
else:
print(f"Step {step['name']} failed! Running compensating transactions...")
self._compensate()
return StepResult(success=False, step_name=step["name"])
return StepResult(success=True, step_name="all_steps")
def _compensate(self):
"""Run compensating transactions in reverse order"""
for step in reversed(self.completed_steps):
print(f"Compensating: {step['name']}")
try:
step["compensate"]()
self.compensation_log.append({
"step": step["name"],
"status": "success",
"timestamp": datetime.now().isoformat()
})
except Exception as e:
self.compensation_log.append({
"step": step["name"],
"status": "failed",
"error": str(e),
"timestamp": datetime.now().isoformat()
})
print(f"WARNING: Compensation failed for {step['name']}: {e}")
# ============================================================================
# DOMAIN SERVICES WITH COMPENSATION
# ============================================================================
class PaymentService:
def __init__(self):
self.charges = {}
def charge(self, order_id: str, amount: float) -> bool:
txn_id = str(uuid.uuid4())
self.charges[order_id] = {"txn_id": txn_id, "amount": amount, "status": "charged"}
print(f" [Payment] Charged ${amount} for order {order_id}, txn={txn_id}")
return True
def refund(self, order_id: str) -> bool:
charge = self.charges.get(order_id)
if charge:
charge["status"] = "refunded"
print(f" [Payment] Refunded ${charge['amount']} for order {order_id}")
return True
print(f" [Payment] No charge found for order {order_id}")
return False
class InventoryService:
def __init__(self):
self.stock = {"SKU-001": 100, "SKU-002": 50}
self.reservations = {}
def reserve(self, order_id: str, sku: str, qty: int) -> bool:
if self.stock.get(sku, 0) >= qty:
self.stock[sku] -= qty
self.reservations[order_id] = {"sku": sku, "qty": qty}
print(f" [Inventory] Reserved {qty}x {sku} for order {order_id}")
return True
print(f" [Inventory] Insufficient stock for {sku}")
return False
def release(self, order_id: str) -> bool:
reservation = self.reservations.pop(order_id, None)
if reservation:
self.stock[reservation["sku"]] += reservation["qty"]
print(f" [Inventory] Released {reservation['qty']}x {reservation['sku']}")
return True
return False
class ShippingService:
def __init__(self):
self.shipments = {}
def create_label(self, order_id: str, address: str) -> bool:
self.shipments[order_id] = {"address": address, "status": "label_created"}
print(f" [Shipping] Label created for order {order_id}")
return True
def cancel_label(self, order_id: str) -> bool:
shipment = self.shipments.pop(order_id, None)
if shipment:
print(f" [Shipping] Label cancelled for order {order_id}")
return True
return False
# ============================================================================
# SAGA DEFINITION
# ============================================================================
class OrderSaga:
def __init__(self, payments: PaymentService, inventory: InventoryService,
shipping: ShippingService):
self.payments = payments
self.inventory = inventory
self.shipping = shipping
def create_order(self, order_id: str, amount: float, sku: str, qty: int,
address: str) -> StepResult:
saga = SagaOrchestrator()
steps = [
{
"name": "charge_payment",
"action": lambda: self.payments.charge(order_id, amount),
"compensate": lambda: self.payments.refund(order_id)
},
{
"name": "reserve_inventory",
"action": lambda: self.inventory.reserve(order_id, sku, qty),
"compensate": lambda: self.inventory.release(order_id)
},
{
"name": "create_shipping_label",
"action": lambda: self.shipping.create_label(order_id, address),
"compensate": lambda: self.shipping.cancel_label(order_id)
}
]
return saga.execute(steps)
# ============================================================================
# USAGE
# ============================================================================
payments = PaymentService()
inventory = InventoryService()
shipping = ShippingService()
saga = OrderSaga(payments, inventory, shipping)
# Successful order
print("=== ORDER 1 (Success) ===")
result = saga.create_order("ORD-001", 99.99, "SKU-001", 2, "123 Main St")
print(f"Result: {'SUCCESS' if result.success else 'FAILED'}")
# Failed order (insufficient stock triggers compensation)
print("\n=== ORDER 2 (Failure -> Compensation) ===")
result = saga.create_order("ORD-002", 999.99, "SKU-999", 500, "456 Oak Ave")
print(f"Result: {'SUCCESS' if result.success else 'FAILED'}")
print(f"Payment refunded: {payments.charges.get('ORD-002', {}).get('status')}")
Java
import java.util.*;
import java.util.function.*;
// Domain services
class PaymentService {
private final Map<String, Map<String, Object>> charges = new HashMap<>();
public boolean charge(String orderId, double amount) {
Map<String, Object> charge = new HashMap<>();
charge.put("amount", amount);
charge.put("status", "charged");
charges.put(orderId, charge);
System.out.println(" [Payment] Charged $" + amount + " for " + orderId);
return true;
}
public boolean refund(String orderId) {
Map<String, Object> charge = charges.get(orderId);
if (charge != null) {
charge.put("status", "refunded");
System.out.println(" [Payment] Refunded $" + charge.get("amount") + " for " + orderId);
return true;
}
return false;
}
}
class InventoryService {
private final Map<String, Integer> stock = new HashMap<>(Map.of("SKU-001", 100));
private final Map<String, Map<String, Object>> reservations = new HashMap<>();
public boolean reserve(String orderId, String sku, int qty) {
int available = stock.getOrDefault(sku, 0);
if (available >= qty) {
stock.put(sku, available - qty);
Map<String, Object> res = new HashMap<>();
res.put("sku", sku); res.put("qty", qty);
reservations.put(orderId, res);
System.out.println(" [Inventory] Reserved " + qty + "x " + sku);
return true;
}
System.out.println(" [Inventory] Insufficient stock for " + sku);
return false;
}
public boolean release(String orderId) {
Map<String, Object> res = reservations.remove(orderId);
if (res != null) {
String sku = (String) res.get("sku");
int qty = (Integer) res.get("qty");
stock.put(sku, stock.get(sku) + qty);
System.out.println(" [Inventory] Released " + qty + "x " + sku);
return true;
}
return false;
}
}
// Saga step
class SagaStep {
final String name;
final Supplier<Boolean> action;
final Runnable compensate;
SagaStep(String name, Supplier<Boolean> action, Runnable compensate) {
this.name = name; this.action = action; this.compensate = compensate;
}
}
// Saga orchestrator
class SagaOrchestrator {
private final List<SagaStep> completedSteps = new ArrayList<>();
public boolean execute(List<SagaStep> steps) {
for (SagaStep step : steps) {
System.out.println("Executing: " + step.name);
if (step.action.get()) {
completedSteps.add(step);
} else {
System.out.println(step.name + " failed! Compensating...");
compensate();
return false;
}
}
return true;
}
private void compensate() {
List<SagaStep> reverse = new ArrayList<>(completedSteps);
Collections.reverse(reverse);
for (SagaStep step : reverse) {
System.out.println("Compensating: " + step.name);
try {
step.compensate.run();
} catch (Exception e) {
System.err.println("WARNING: Compensation failed for " + step.name + ": " + e.getMessage());
}
}
}
}
// Usage — put this class in CompensatingTransactionDemo.java
public class CompensatingTransactionDemo {
public static void main(String[] args) {
PaymentService payments = new PaymentService();
InventoryService inventory = new InventoryService();
SagaOrchestrator saga = new SagaOrchestrator();
List<SagaStep> steps = List.of(
new SagaStep("charge", () -> payments.charge("ORD-001", 99.99), () -> payments.refund("ORD-001")),
new SagaStep("reserve", () -> inventory.reserve("ORD-001", "SKU-001", 2), () -> inventory.release("ORD-001"))
);
boolean success = saga.execute(steps);
System.out.println("Saga result: " + (success ? "SUCCESS" : "FAILED"));
}
}
JavaScript
class PaymentService {
constructor() {
this.charges = new Map();
}
charge(orderId, amount) {
this.charges.set(orderId, { amount, status: 'charged' });
console.log(` [Payment] Charged $${amount} for ${orderId}`);
return true;
}
refund(orderId) {
const charge = this.charges.get(orderId);
if (charge) {
charge.status = 'refunded';
console.log(` [Payment] Refunded $${charge.amount} for ${orderId}`);
return true;
}
return false;
}
}
class InventoryService {
constructor() {
this.stock = new Map([['SKU-001', 100]]);
this.reservations = new Map();
}
reserve(orderId, sku, qty) {
const available = this.stock.get(sku) || 0;
if (available >= qty) {
this.stock.set(sku, available - qty);
this.reservations.set(orderId, { sku, qty });
console.log(` [Inventory] Reserved ${qty}x ${sku}`);
return true;
}
console.log(` [Inventory] Insufficient stock for ${sku}`);
return false;
}
release(orderId) {
const res = this.reservations.get(orderId);
if (res) {
this.stock.set(res.sku, this.stock.get(res.sku) + res.qty);
console.log(` [Inventory] Released ${res.qty}x ${res.sku}`);
this.reservations.delete(orderId);
return true;
}
return false;
}
}
class SagaOrchestrator {
constructor() {
this.completedSteps = [];
}
async execute(steps) {
for (const step of steps) {
console.log(`Executing: ${step.name}`);
const success = await step.action();
if (success) {
this.completedSteps.push(step);
} else {
console.log(`${step.name} failed! Compensating...`);
await this.compensate();
return { success: false, failedStep: step.name };
}
}
return { success: true };
}
async compensate() {
const reverse = [...this.completedSteps].reverse();
for (const step of reverse) {
console.log(`Compensating: ${step.name}`);
try {
await step.compensate();
} catch (e) {
console.error(`WARNING: Compensation failed for ${step.name}: ${e.message}`);
}
}
}
}
// Usage
async function demo() {
const payments = new PaymentService();
const inventory = new InventoryService();
const saga = new SagaOrchestrator();
const steps = [
{
name: 'charge',
action: () => payments.charge('ORD-001', 99.99),
compensate: () => payments.refund('ORD-001')
},
{
name: 'reserve',
action: () => inventory.reserve('ORD-001', 'SKU-001', 2),
compensate: () => inventory.release('ORD-001')
}
];
const result = await saga.execute(steps);
console.log('Result:', result.success ? 'SUCCESS' : 'FAILED');
}
demo().catch(console.error);
Explanation
A compensating transaction is a semantic undo rather than a database rollback:
- Charge payment → compensation is refund payment
- Reserve inventory → compensation is release inventory
- Create shipping label → compensation is cancel shipping label
The Saga orchestrator executes steps sequentially. If any step fails, it runs compensations in reverse order for all previously completed steps. That’s how the system gets back to a consistent state, even though the individual operations were already committed.
Key properties:
- Compensations are themselves business operations, not database commands
- Compensations may fail (e.g., a refund rejected by the payment processor) and must be monitored
- The saga log records what happened for audit and manual intervention
What production code adds on top of the toy orchestrator above: the saga log lives in durable storage so a crashed orchestrator can resume in-flight sagas, and compensations run through a retry mechanism — a compensation queue with exponential backoff that escalates to a human when retries are exhausted. A refund rejected by the payment processor shouldn’t be dropped; it should page someone.
Variants
| Variant | Coordination | Use Case |
|---|---|---|
| Orchestrated Saga | Central coordinator manages steps and compensations | Complex workflows with clear ordering |
| Choreographed Saga | Services emit events; listeners trigger next steps or compensations | Decoupled, event-driven architectures |
| Parallel Saga | Independent steps run concurrently; compensations run for all on failure | High-throughput, loosely coupled steps |
| Nested Saga | A saga step is itself a sub-saga with its own compensations | Recursive business processes |
What Works
- Design compensations upfront. They are harder to retrofit than the original operations.
- Make compensations idempotent. They may be retried if the first attempt fails.
- Log everything. Saga state, compensation results, and failures must be observable.
- Set timeouts. A step that hangs forever blocks the entire saga.
- Provide manual intervention hooks. Some compensations require human approval (e.g., refunds over a threshold).
Common Mistakes
- Assuming compensations always succeed. Payment refunds can be rejected; inventory may already be shipped.
- Missing compensation for a step. Every saga step must have a defined counter-operation.
- Non-idempotent compensations. Running a compensation twice shouldn’t refund twice.
- Losing saga state. If the orchestrator crashes, in-flight sagas must be recoverable from a persistent log.
- Ignoring partial failures. A step that “half succeeds” (e.g., payment charged but not recorded) is the hardest case to compensate.
Real-World Examples
E-Commerce Order Processing
Placing an order involves payment, inventory reservation, and shipping. If shipping fails after payment succeeds, the saga compensates by refunding the payment and releasing inventory.
Travel Booking
Booking a trip involves flights, hotels, and car rentals. If the hotel booking fails after the flight is booked, the saga cancels the flight reservation (if possible) and refunds the customer.
Banking Transfers
An inter-bank transfer saga debits the source account, initiates a wire, and credits the destination. If the wire fails, the saga credits the source account back (compensating the debit).
Further Reading
- Sagas — Garcia-Molina & Salem, SIGMOD 1987 (original paper, PDF) — the paper that defined sagas and compensating transactions.
- Pattern: Saga — microservices.io — Chris Richardson’s catalog entry on saga coordination.
- Compensating Transaction pattern — Microsoft Learn — Azure architecture guidance on undoing distributed work.
- Runnable companion repo — the saga orchestrator from this page, executable in Python, Java, and JavaScript.
Frequently Asked Questions
What is the difference between compensating transaction and database rollback?
Rollback undoes uncommitted changes inside a single database transaction — the rows never happened. Compensation undoes changes that were already committed, across systems that don't share a transaction manager, by running counter-operations. The record exists; you write an offsetting record.
Can all operations be compensated?
No. Some operations are irreversible: an email already sent, a package already shipped, a trade already executed. For those you need a different strategy — a cancellation window before the step commits, a follow-up correction (retraction email, return label), or accepting the inconsistency and alerting. Design the saga so irreversible steps run as late as possible.
How does this relate to the Saga Pattern?
Saga is the coordination strategy — the orchestration or choreography that sequences local transactions. Compensating Transaction is the undo mechanism Saga uses when a step fails. You can have sagas without full compensation (some teams prefer forward recovery), but you can't roll back a distributed saga without compensating transactions.
What happens when a compensation itself fails?
The same thing that fails anywhere else: retry with backoff, but don't let it die silently. Production systems push failed compensations onto a dedicated queue that retries until it succeeds or escalates to manual intervention — a stuck compensation means inconsistent state, which is exactly what the pattern exists to prevent. Monitor the compensation log like you'd monitor the main flow.
Should compensations run synchronously or as queued jobs?
Depends on how fast the user needs an answer. In-request compensation is simpler and keeps the demo readable, but production systems usually queue compensations — the orchestrator marks the saga as "compensating" and a worker drains the queue with retries. That way a slow refund API doesn't hold the original request hostage, and a transient processor outage doesn't lose the rollback.
How is this different from just catching an exception and undoing things?
Scope. A try/catch undo works inside one process and one connection. A compensating transaction is a durable, business-level counter-operation that survives process restarts, works across service boundaries, and shows up in your audit trail — "order ORD-002 compensated: inventory released, payment refunded" is a first-class event, not an error handler's side effect.
Related Resources
Saga Pattern: Orchestration vs Choreography
Manage distributed transactions across multiple services by chaining local transactions with compensating actions for rollbacks. A microservices pattern.
PatternOutbox Pattern
Reliably publish domain events by persisting them in an outbox table within the same database transaction as the business operation.
PatternIdempotent Consumer Pattern
Process messages from a queue exactly once regardless of duplicates by using idempotent operations, unique identifiers, and deduplication strategies at the consumer level.
RecipeManage Distributed Transactions with the Saga Pattern
How to implement saga orchestration and choreography to maintain data consistency across microservices without distributed transactions or two-phase commit.
PatternEvent-Carried State Transfer Pattern
Replicate state changes across services by publishing events that carry the full updated entity state, enabling consumers to maintain their own local copies without querying the source.