Lesson 36Distributed transactionsAdvanced

Sagas and compensating actions

When step 2 fails after step 1 committed, you can't rollback, you compensate.

By Solomon Ajayi · Free to read, no signup

A user wants to wire ₦50,000 to an external bank. The flow has two committed steps: (1) debit your user's wallet and mark the funds as 'wire in flight' (committed in your DB), (2) call your sponsor bank's API to initiate the actual wire (an external system you don't control). What happens when step 1 commits but step 2 fails? You can't ROLLBACK, the commit is final. You COMPENSATE: post a new entry that undoes step 1's effect. The audit trail shows the failure honestly: initiated, failed, compensated. This is the saga pattern, and it's what separates fintechs that survive partial outages from ones that ship corrupt state.

A database transaction gives you all-or-nothing across one connection. The moment your work spans two systems, your DB and the sponsor bank's API, that guarantee evaporates. You cannot wrap an external HTTP call in a BEGIN and COMMIT, so the two steps commit independently, and one of them can succeed while the other fails. Step 1 here, debiting the wallet into Wire In Flight, commits to your DB. Then the sponsor's API returns a 503, and there is nothing left to roll back, because step 1 is already final.

The saga answer is to give every forward action a paired compensating action. The forward action moved the user's money into Wire In Flight; the compensation moves it straight back to User Wallet. You do not delete step 1 and you do not pretend it never happened. You post a third entry that is the exact inverse, so the wallet returns to ₦80,000 and Wire In Flight clears to zero. Three entries on the ledger for one failed wire is the correct count, not a smell.

Wire In Flight is the account that makes this honest. It is a liability that says we have taken the user's money out of their spendable balance but have not yet sent it anywhere, exactly the same shape as an authorization hold. Holding the funds in a named in-flight account, rather than just decrementing the wallet, is what lets the compensation be a clean two-line reversal instead of a guess about how much to refund.

Worked example, step by step

Set the stage: user has ₦80,000 in wallet

Standard funding state. User Wallet ₦80,000 credit. FBO ₦80,000 debit.

Seed user wallet ₦80,000
AccountDebitCredit
FBO at Sponsor Bank (1300)₦80,000.00
User Wallet (2000)₦80,000.00

Standard setup, same shape as Lesson 30's FBO pattern.

Step 1: Initiate ₦50,000 wire, committed in your DB

User clicks 'Wire ₦50,000.' Your code DEBITS the user wallet and CREDITS 'Wire In Flight', committed to your DB. From this moment, the user sees their available balance drop. The wire has NOT been sent to the bank yet, that's the next step.

Initiate ₦50,000 wire (step 1)
AccountDebitCredit
User Wallet (2000)₦50,000.00
Wire In Flight (2500)₦50,000.00

User Wallet DOWN ₦50,000. Wire In Flight UP ₦50,000. Same pattern as Lesson 6's authorization hold. FBO unchanged, money still physically at the sponsor bank. NEXT, your code calls the sponsor bank's API. If that succeeds, you'd post a step-2 entry moving Wire In Flight → FBO down (actual cash out). If it FAILS, you have a problem...

Step 2 FAILED, compensate by reversing step 1

Sponsor bank API returned 503. Their backend is down. Step 1 already committed, you can't 'rollback' a committed transaction. You COMPENSATE: post a new entry that is the exact inverse of step 1. User Wallet returns to ₦80,000. Wire In Flight clears to zero. User gets notified the wire failed.

Compensate failed wire ₦50,000
AccountDebitCredit
Wire In Flight (2500)₦50,000.00
User Wallet (2000)₦50,000.00

Wire In Flight DOWN ₦50,000 (cleared). User Wallet UP ₦50,000 (restored). Notice: this is a NEW entry, not a delete of step 1. The audit trail SHOWS: initiated, failed, compensated. Three entries on the ledger for one failed attempt, and that's the correct answer. Anyone trying to 'just rollback' or 'just delete' the original entry breaks the immutability invariant from Lesson 5.

Saga state lives in a table next to your journal.

CREATE TYPE saga_status AS ENUM (
  'initiated', 'committed', 'failed', 'compensated'
);

CREATE TABLE wire_saga (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id           uuid NOT NULL,
  amount            bigint NOT NULL,
  status            saga_status NOT NULL DEFAULT 'initiated',
  initiate_entry_id uuid REFERENCES journal_entry (id),
  compensate_entry_id uuid REFERENCES journal_entry (id),
  last_error        text,
  updated_at        timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX wire_saga_status_idx ON wire_saga (status)
  WHERE status IN ('initiated', 'failed');

The compensation path. New entry, NOT a delete or rollback.

import { Client, DatabaseError } from "pg";

export async function executeWire(client: Client, sagaId: string): Promise<void> {
  const { rows: [saga] } = await client.query<{
    user_id: string; amount: string;
  }>(`SELECT user_id, amount FROM wire_saga WHERE id = $1`, [sagaId]);

  // Step 1 already committed by the API handler. We're picking up here.
  try {
    await sponsorBank.initiateWire({ amount: BigInt(saga.amount), userId: saga.user_id });
    await client.query(
      `UPDATE wire_saga SET status = 'committed', updated_at = now() WHERE id = $1`,
      [sagaId],
    );
  } catch (err) {
    // The sponsor failed. Compensate: post the inverse of step 1 in a
    // fresh transaction, then mark the saga compensated.
    await compensate(client, sagaId, BigInt(saga.amount), saga.user_id, err);
  }
}

async function compensate(
  client: Client,
  sagaId: string,
  amount: bigint,
  userId: string,
  err: unknown,
): Promise<void> {
  try {
    await client.query("BEGIN");

    const { rows } = await client.query<{ id: string }>(
      `INSERT INTO journal_entry (description) VALUES ($1) RETURNING id`,
      [`Compensate failed wire NGN${amount}`],
    );
    const entryId = rows[0].id;

    await client.query(
      `INSERT INTO journal_line (entry_id, account_code, debit, credit) VALUES
         ($1, '2500', $2, 0),
         ($1, '2000', 0, $2)`,
      [entryId, amount],
    );

    await client.query(
      `UPDATE wire_saga
          SET status = 'compensated',
              compensate_entry_id = $1,
              last_error = $2,
              updated_at = now()
        WHERE id = $3`,
      [entryId, err instanceof Error ? err.message : String(err), sagaId],
    );

    await client.query("COMMIT");
  } catch (compensateErr) {
    await client.query("ROLLBACK");
    throw compensateErr;
  }
}

Takeaway

Once a transaction commits, you cannot undo it. You COMPENSATE, post a new inverse entry that returns state to where it was. The audit trail shows the truth: initiated, failed, compensated. Sagas formalize this across multi-step money movements: every forward action has a paired compensating action; if step N fails, fire compensations for steps N-1, N-2, ..., 1 in reverse order. Most fintech outages are not from any single step failing, they're from systems that lacked compensation paths and corrupted state instead.

The code behind it

Reference solution

CREATE TYPE saga_status AS ENUM (
  'initiated', 'committed', 'failed', 'compensated'
);

CREATE TABLE wire_saga (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id             uuid NOT NULL,
  request_id          text NOT NULL,
  amount              bigint NOT NULL,
  status              saga_status NOT NULL DEFAULT 'initiated',
  initiate_entry_id   uuid REFERENCES journal_entry (id),
  compensate_entry_id uuid REFERENCES journal_entry (id),
  last_error          text,
  updated_at          timestamptz NOT NULL DEFAULT now()
);

-- Partial unique index: one ACTIVE saga per (user, request_id).
-- Failed / compensated sagas don't participate, so the user can retry
-- the same request after a failed wire without hitting this constraint.
CREATE UNIQUE INDEX wire_saga_active_uniq
  ON wire_saga (user_id, request_id)
  WHERE status IN ('initiated', 'committed');

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.