Skip to content
System design course
Ch.4 · Designing real systems·how to build it ·6 min read

Building a digital wallet

Implement the atomic transfer (debit + credit + ledger in one transaction), idempotency, and async top-up over a state machine.


The atomic transfer

A transfer must debit the sender and credit the receiver atomically, never overdraft, and be retry-safe:

def transfer(idempotency_key, sender, receiver, amount):
    prior = idempotency.get(idempotency_key)
    if prior: return prior.result                    # retry → same result, no double move

    with db.txn():                                   # all-or-nothing
        idempotency.reserve(idempotency_key)         # unique key → blocks concurrent dup
        ok = db.execute(                             # conditional debit (no overdraft)
            "UPDATE wallets SET balance = balance - :a "
            "WHERE user_id = :s AND balance >= :a", a=amount, s=sender)
        if not ok: raise InsufficientFunds()
        db.execute("UPDATE wallets SET balance = balance + :a "
                   "WHERE user_id = :r", a=amount, r=receiver)
        txn_id = new_id()
        post_entry(txn_id, debit=sender, credit=receiver, amount=amount)   # double-entry
        result = {"txn_id": txn_id, "status": "completed"}
        idempotency.complete(idempotency_key, result)
    return result

Everything — the overdraft check, both balance updates, the ledger entries, the idempotency record — commits in one transaction. A crash rolls it all back; a retry returns the stored result. No double-spend, no half-transfer.

Why the conditional update matters

WHERE balance >= :amount makes the check-and-decrement a single atomic step. Two concurrent transfers from the same wallet can’t both pass if only one is affordable — the DB serializes them. (Reading balance then updating in app code would race.)

Reading balance and history

def balance(user):   return wallets.get(user).balance          # maintained column (fast)
def history(user):   return ledger.entries_for_account(user)    # derived from ledger (truth)

The materialized balance is updated in the same transaction as the ledger, so it can’t drift; the ledger remains the auditable source if you ever need to recompute.

Async top-up over a state machine

External money (bank/card) is async — credit the wallet only when the provider confirms:

def top_up(idempotency_key, user, amount, source):
    tx = transfers.create(user=user, amount=amount, state="initiated")
    psp.charge(source, amount, idempotency_key=idempotency_key)   # async; webhook later
    return tx.id

def on_psp_webhook(event):
    with db.txn():
        tx = transfers.get(event.tx_id)
        if tx.applied(event.id): return                          # idempotent
        if event.type == "succeeded":
            credit_wallet(tx.user, tx.amount)                    # NOW credit (balance + ledger)
            tx.state = "completed"
        else:
            tx.state = "failed"
        tx.record_applied(event.id); transfers.put(tx)

Crediting on initiated would let users spend money that never arrived — credit on confirmation only. Withdrawal is the mirror (debit on request, finalize on bank confirmation, reverse on failure).

Scale and failure handling

  • Concurrent payments from one wallet → conditional decrement serializes; no overdraft.
  • Retries / double-tap → idempotency key returns the original result.
  • Crash mid-transfer → transaction rolls back; nothing partial.
  • Top-up provider fails → wallet never credited (credited only on webhook success); reconcile.
  • Hot wallet (big merchant) → row contention; shard sub-accounts or batch settlements.
  • CP → on doubt, leave pending and reconcile rather than risk a wrong balance.

The takeaway

Concrete signals: an atomic transfer (conditional debit + credit + double-entry ledger + idempotency record, all in one transaction), a maintained balance kept in sync with the ledger, and async external top-up/withdraw credited only on confirmation. This atomic- ledger-transfer is the exact pattern UPI extends across different banks next.