The most surprising thing about Sagas is that they’re not a direct replacement for ACID transactions, but rather a pattern to manage distributed transactions without locks, accepting eventual consistency.

Let’s build a simple order processing Saga in Node.js. Imagine we have OrderService, PaymentService, and InventoryService. When an order is placed, we need to:

  1. Create the order.
  2. Process the payment.
  3. Update inventory.

If any step fails, we need to compensate by undoing previous steps.

Here’s a simplified Node.js setup using async/await to illustrate the flow. We’ll use basic in-memory "databases" for simplicity.

// --- Mock Services ---
const orders = {};
const payments = {};
const inventory = {
    'item-123': { quantity: 10 }
};

// Simulate network latency and potential failures
const delay = (ms, shouldFail = false) => new Promise((resolve, reject) =>
    setTimeout(() => {
        if (shouldFail) reject(new Error('Simulated service failure'));
        else resolve();
    }, ms)
);

const orderService = {
    async createOrder(orderId, item, quantity) {
        console.log(`[OrderService] Creating order ${orderId}...`);
        await delay(100);
        orders[orderId] = { id: orderId, item, quantity, status: 'PENDING' };
        console.log(`[OrderService] Order ${orderId} created.`);
        return orders[orderId];
    },
    async updateOrderStatus(orderId, status) {
        console.log(`[OrderService] Updating order ${orderId} to ${status}...`);
        await delay(50);
        if (!orders[orderId]) throw new Error('Order not found for status update');
        orders[orderId].status = status;
        console.log(`[OrderService] Order ${orderId} status updated to ${status}.`);
        return orders[orderId];
    },
    async compensateOrder(orderId) {
        console.log(`[OrderService] Compensating order ${orderId}...`);
        await delay(100);
        if (!orders[orderId]) {
            console.warn(`[OrderService] Order ${orderId} already compensated or never existed.`);
            return;
        }
        orders[orderId].status = 'CANCELLED';
        console.log(`[OrderService] Order ${orderId} compensated (status: CANCELLED).`);
    }
};

const paymentService = {
    async processPayment(orderId, amount) {
        console.log(`[PaymentService] Processing payment for order ${orderId} ($${amount})...`);
        await delay(200, Math.random() > 0.8); // 20% chance of failure
        payments[orderId] = { orderId, amount, status: 'PAID' };
        console.log(`[PaymentService] Payment processed for order ${orderId}.`);
        return payments[orderId];
    },
    async refundPayment(orderId) {
        console.log(`[PaymentService] Refunding payment for order ${orderId}...`);
        await delay(150);
        if (payments[orderId]) {
            payments[orderId].status = 'REFUNDED';
            console.log(`[PaymentService] Payment refunded for order ${orderId}.`);
        } else {
            console.warn(`[PaymentService] No payment found to refund for order ${orderId}.`);
        }
    }
};

const inventoryService = {
    async updateInventory(orderId, item, quantity) {
        console.log(`[InventoryService] Updating inventory for order ${orderId} (Item: ${item}, Qty: ${quantity})...`);
        await delay(150);
        if (inventory[item].quantity < quantity) {
            throw new Error(`Insufficient stock for ${item}. Available: ${inventory[item].quantity}`);
        }
        inventory[item].quantity -= quantity;
        console.log(`[InventoryService] Inventory updated for ${item}. Remaining: ${inventory[item].quantity}.`);
        return { success: true };
    },
    async restoreInventory(orderId, item, quantity) {
        console.log(`[InventoryService] Restoring inventory for order ${orderId} (Item: ${item}, Qty: ${quantity})...`);
        await delay(120);
        inventory[item].quantity += quantity;
        console.log(`[InventoryService] Inventory restored for ${item}. New total: ${inventory[item].quantity}.`);
    }
};

// --- Saga Orchestrator ---
class OrderSaga {
    constructor(orderId, item, quantity, amount) {
        this.orderId = orderId;
        this.item = item;
        this.quantity = quantity;
        this.amount = amount;
        this.steps = [
            {
                action: () => orderService.createOrder(this.orderId, this.item, this.quantity),
                compensate: () => orderService.compensateOrder(this.orderId)
            },
            {
                action: () => paymentService.processPayment(this.orderId, this.amount),
                compensate: () => paymentService.refundPayment(this.orderId)
            },
            {
                action: () => inventoryService.updateInventory(this.orderId, this.item, this.quantity),
                compensate: () => inventoryService.restoreInventory(this.orderId, this.item, this.quantity)
            }
        ];
        this.currentStep = 0;
    }

    async execute() {
        console.log(`\n--- Starting Saga for Order ${this.orderId} ---`);
        try {
            for (let i = 0; i < this.steps.length; i++) {
                this.currentStep = i;
                await this.steps[i].action();
            }
            await orderService.updateOrderStatus(this.orderId, 'COMPLETED');
            console.log(`--- Saga for Order ${this.orderId} Completed Successfully ---`);
        } catch (error) {
            console.error(`--- Saga for Order ${this.orderId} Failed: ${error.message} ---`);
            await this.compensate();
        }
    }

    async compensate() {
        console.log(`--- Compensating Saga for Order ${this.orderId} ---`);
        // Compensate steps in reverse order, from the last successful to the first
        for (let i = this.currentStep; i >= 0; i--) {
            try {
                await this.steps[i].compensate();
            } catch (compensateError) {
                console.error(`[Saga] Critical error during compensation of step ${i} for order ${this.orderId}: ${compensateError.message}`);
                // In a real system, you'd log this, alert, and potentially have manual intervention mechanisms.
            }
        }
        console.log(`--- Saga Compensation for Order ${this.orderId} Finished ---`);
    }
}

// --- Example Usage ---
async function runSaga(orderId, item, quantity, amount) {
    const saga = new OrderSaga(orderId, item, quantity, amount);
    await saga.execute();
}

// Example 1: Successful Saga
// runSaga('ORD1001', 'item-123', 2, 50.00);

// Example 2: Saga failure during payment (simulated)
// To trigger this, you might need to run it multiple times or adjust the random failure chance.
// For a guaranteed failure, manually inject an error in paymentService.processPayment.
// For example:
// await delay(200, true); // Force failure
// Or, if you want to see inventory failure:
// runSaga('ORD1002', 'item-123', 15, 75.00); // Will fail on inventory

// Example 3: Failure during inventory update
runSaga('ORD1003', 'item-123', 5, 100.00);

This orchestrator pattern is one way to implement Sagas. Each service performs its local transaction, and the orchestrator coordinates the sequence and compensation. The OrderSaga class holds the sequence of actions and their corresponding compensation actions. When execute is called, it iterates through the action functions. If any action throws an error, it catches it, logs it, and then calls compensate. The compensate method iterates backward through the already completed steps, calling their compensate functions.

The problem this solves is maintaining data consistency across multiple independent services in a distributed system. Traditional ACID transactions are often not feasible or desirable in microservices due to performance bottlenecks (locking) and tight coupling. Sagas allow services to remain independent while still ensuring that a multi-step operation either completes successfully or is fully rolled back.

The key is that each service must expose an "undo" or "compensating" operation for every operation that modifies state. orderService.createOrder has orderService.compensateOrder, paymentService.processPayment has paymentService.refundPayment, and inventoryService.updateInventory has inventoryService.restoreInventory.

A common pitfall is assuming compensation is trivial. For instance, if updateInventory fails, and we refund a payment, what if the customer has already spent that money? Or if the item price has changed? Real-world compensation logic can become complex, requiring state management to handle partial successes or even manual intervention.

This implementation uses an orchestration approach, where a central coordinator knows the entire workflow. The alternative is choreography, where services communicate directly via events to drive the workflow, each service reacting to events from others. Choreography can lead to a more decoupled system but can be harder to reason about as the number of services grows.

The next logical step is to consider how to make this resilient. What happens if the orchestrator crashes mid-saga? You’d need a persistent state store for the saga itself, allowing it to resume from where it left off. This often involves using message queues and durable state tracking.

Want structured learning?

Take the full Saga-pattern course →