Lesson 35Distributed transactionsAdvanced

The outbox pattern

Reliably fan out side-effects after a ledger entry commits.

By Solomon Ajayi · Free to read, no signup

Every journal entry usually triggers side-effects: SMS the user, email the merchant, fire a webhook to a partner, push a notification to your mobile app. Naive code does the SMS send right after the INSERT in the same function. This has three failure modes, the SMS goes out before the DB commits, the DB commits but the SMS fails, or the process crashes in between. The outbox pattern fixes all three by putting the side-effect intent INSIDE the same database transaction as the ledger entry. A separate worker reads pending outbox rows and sends. At-least-once delivery guaranteed. The journal entries below are simple deposits; the lesson is in the commentary about HOW to commit them safely alongside their side-effects.

A ledger entry rarely travels alone. A deposit should also text the user, a refund should email the merchant and fire a webhook. The naive instinct is to send the SMS right after the INSERT, in the same function, and it looks completely innocent. The problem is that your database transaction protects the ledger write and nothing else; the SMS provider has no idea your commit succeeded or failed.

That mismatch opens three failure modes. The SMS can go out and then the commit fails, so the user is told about money they do not have. The commit can succeed and the SMS fail, so the user has the money and never knows. Or the process can crash in the gap between, leaving you in an unknown state with a support ticket inbound. The journal entry itself is correct in all three; the bug is that the side-effect does not share the entry's transactional guarantees.

The outbox pattern records the intent instead of acting on it. In the same transaction as the journal entry, you insert a row into an outbox table; both rows commit together or roll back together, so the side-effect can never disagree with the ledger. A separate worker then drains pending rows and dispatches them with retries, and an idempotency key on each row means the message goes out exactly once even if the worker crashes mid-send. Three failure modes collapse into zero.

Worked example, step by step

Deposit ₦10,000 (the naive version)

First, the wrong way. Most engineers' first instinct: INSERT the journal entry, then call sms.send() in the same function. Looks innocent. Has three failure modes.

Deposit ₦10,000 (naive code)
AccountDebitCredit
FBO at Sponsor Bank (1300)₦10,000.00
User Wallet (2000)₦10,000.00

Naive code paths fail in three ways: (1) SMS sends BEFORE the DB commits, then the commit fails, user gets a confirmation for money they don't have. (2) DB commits but SMS fails, user got the money but doesn't know. (3) Process crashes between, unknown state, support gets the ticket. The journal entry IS correct. The bug is that side-effects don't share the entry's transaction guarantees. Apply the entry, then read the next step.

Naive side-effect right after the INSERT. Three ways to lose.

def handle_deposit(conn, event):
    with conn.transaction():
        post_entry(conn, deposit_entry(event))

    # (1) Commit could fail; SMS already in flight.
    # (2) SMS could fail; commit already on disk.
    # (3) Process could crash here; nobody knows what state we're in.
    sms.send(event.user_phone, f"Deposit of {event.amount_formatted} received")

Same bug in Go. Side-effect lives outside the transaction.

func HandleDeposit(ctx context.Context, db *sql.DB, ev DepositEvent) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    if err := postEntry(ctx, tx, depositEntry(ev)); err != nil {
        tx.Rollback()
        return err
    }
    if err := tx.Commit(); err != nil {
        return err  // SMS not sent yet, fine
    }

    // Anything from here to the SMS call is unprotected:
    // crash, SMS-API failure, or successful Commit + lost notification.
    return sms.Send(ctx, ev.UserPhone, fmt.Sprintf("Deposit of %s received", ev.AmountFormatted))
}

Same deposit, outbox-style commit

The fix: in the SAME database transaction as the journal entry INSERT, also INSERT into an `outbox` table. One transaction, both rows. Commit succeeds or fails atomically. A separate worker process reads outbox rows, sends the SMS, marks the row as sent (or retries). Result: at-least-once delivery. The user always gets exactly one SMS for every deposit, even when the worker crashes mid-send.

Deposit ₦10,000 (outbox-committed)
AccountDebitCredit
FBO at Sponsor Bank (1300)₦10,000.00
User Wallet (2000)₦10,000.00

Same journal entry as before, but in production, the surrounding code is: BEGIN; INSERT journal_entry; INSERT outbox (sms_send, user_id, amount); COMMIT. If COMMIT fails, BOTH inserts roll back. If COMMIT succeeds, the worker eventually delivers. The idempotency_key on the outbox row ensures the SMS sends exactly once even across worker retries. Three failure modes collapse into zero.

The outbox table. Same transaction as your ledger writes.

CREATE TABLE outbox (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  topic           text NOT NULL,             -- 'sms.deposit', 'webhook.partner', etc.
  payload         jsonb NOT NULL,
  idempotency_key text NOT NULL,             -- so workers never double-deliver
  created_at      timestamptz NOT NULL DEFAULT now(),
  sent_at         timestamptz,
  attempt_count   int NOT NULL DEFAULT 0,
  next_attempt_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX outbox_idem ON outbox (topic, idempotency_key);

-- The worker pulls pending rows. ORDER BY + LIMIT + FOR UPDATE SKIP LOCKED
-- lets many workers consume in parallel without stepping on each other.
CREATE INDEX outbox_pending
  ON outbox (next_attempt_at)
  WHERE sent_at IS NULL;

Commit the entry and the side-effect together. One transaction.

import json

def handle_deposit(conn, event):
    with conn.transaction():
        post_entry(conn, deposit_entry(event))

        conn.execute(
            """
            INSERT INTO outbox (topic, payload, idempotency_key)
            VALUES (%s, %s::jsonb, %s)
            """,
            (
                "sms.deposit",
                json.dumps({"user_phone": event.user_phone, "amount": event.amount_formatted}),
                event.id,  # same key as the ledger entry
            ),
        )
    # Worker takes it from here. Nothing else to do.

Worker drain: SKIP LOCKED lets many workers consume in parallel.

BEGIN;

SELECT id, topic, payload, attempt_count
  FROM outbox
 WHERE sent_at IS NULL
   AND next_attempt_at <= now()
 ORDER BY next_attempt_at
 LIMIT 50
 FOR UPDATE SKIP LOCKED;

-- For each row, dispatch the side-effect. On success:
UPDATE outbox SET sent_at = now() WHERE id = $1;
-- On failure, backoff:
UPDATE outbox
   SET attempt_count = attempt_count + 1,
       next_attempt_at = now() + (interval '1 second' * pow(2, attempt_count))
 WHERE id = $1;

COMMIT;

Worker loop in Go. The SKIP LOCKED query is the only interesting line.

func DrainOutbox(ctx context.Context, db *sql.DB, dispatch func(topic string, payload []byte) error) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    rows, err := tx.QueryContext(ctx, `
        SELECT id, topic, payload, attempt_count
          FROM outbox
         WHERE sent_at IS NULL AND next_attempt_at <= now()
         ORDER BY next_attempt_at
         LIMIT 50
         FOR UPDATE SKIP LOCKED
    `)
    if err != nil {
        return err
    }
    defer rows.Close()

    for rows.Next() {
        var id, topic string
        var payload []byte
        var attempts int
        if err := rows.Scan(&id, &topic, &payload, &attempts); err != nil {
            return err
        }
        if err := dispatch(topic, payload); err != nil {
            _, _ = tx.ExecContext(ctx, `
                UPDATE outbox
                   SET attempt_count = attempt_count + 1,
                       next_attempt_at = now() + (interval '1 second' * pow(2, attempt_count))
                 WHERE id = $1
            `, id)
            continue
        }
        if _, err := tx.ExecContext(ctx, `UPDATE outbox SET sent_at = now() WHERE id = $1`, id); err != nil {
            return err
        }
    }
    return tx.Commit()
}

Refund ₦10,000, outbox enables compensation

User wants a refund. Post the counter-entry. The outbox pattern handles the refund's side-effects (SMS the user, notify the merchant, fire the chargeback webhook) the same way as the deposit. Original deposit's outbox row stays as-is, history is immutable, side-effects are new events.

Refund ₦10,000
AccountDebitCredit
User Wallet (2000)₦10,000.00
FBO at Sponsor Bank (1300)₦10,000.00

Same shape as Lesson 5's refund. The new wrinkle: the refund commit also INSERTS an outbox row for 'refund_notification.' The worker fires the SMS to the user, the email to the merchant, the webhook to your accounting system. If ANY of these recipients are down, the outbox row stays pending and retries. None of them are tied to the ledger commit, so no recipient outage can corrupt your books.

Takeaway

Side-effects (SMS, email, webhook, queue push) are NOT protected by your DB transaction. The outbox pattern fixes this with one extra table: INSERT journal_entry + INSERT outbox in the same transaction, commit atomically, let a separate worker read outbox rows and dispatch with retries. The idempotency_key on the outbox row ensures exactly-once delivery even across worker crashes. Without outbox, your fintech ships notification bugs every quarter. With it, side-effects are as reliable as ledger entries themselves.

The code behind it

Reference solution

-- Worker dequeues pending outbox rows to dispatch.
-- FOR UPDATE SKIP LOCKED is the magic: row-level lock + skip-if-busy
-- semantics, so N workers in parallel each get a disjoint slice.
SELECT id, topic, payload, attempt_count
  FROM outbox
 WHERE sent_at IS NULL
   AND next_attempt_at <= now()
 ORDER BY next_attempt_at
 LIMIT 50
 FOR UPDATE SKIP LOCKED;

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.