Design a Digital Wallet

Design the backend of a mobile wallet. People top up from a bank card, hold a balance, send money to friends by phone number, and cash out to their bank account. Merchants accept wallet payments at checkout.

This is money, so the rules are unforgiving: a balance can never go negative by accident, a transfer that the app retried three times over a flaky network must move money exactly once, and finance and regulators must be able to explain every cent of every balance years later. At the same time the app has tens of millions of daily users, and payday or a holiday sale can triple traffic in minutes.

In 45 minutes, pin down requirements and scale, sketch the API and data model, draw the architecture, and go deep on how a transfer stays correct under retries, concurrency and partial failures, and how you would prove the books balance.

Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.

Design Path · Commerce & Payments · problem 10 of 18

Ask before you design. Try answering each yourself, then compare.

  1. Single currency or multi-currency?
    Typical answer

    One currency per wallet for the core design; FX conversion is a follow-up.

  2. Are peer-to-peer transfers instant, or can they settle later?
    Typical answer

    Instant inside the wallet: the sender's balance drops and the receiver's rises in the same commit. Only bank top-ups and cash-outs settle asynchronously.

  3. What scale should we plan for?
    Typical answer

    About 50 million daily users, around 100 million money movements a day, and roughly 3x average load at peak.

  4. How strong must consistency be for balances?
    Typical answer

    Strong for the spendable balance used to authorise a debit. Transaction history and notifications may lag by a few seconds.

  5. How long do we keep records, and who reads them?
    Typical answer

    At least 7 years, immutable, readable by finance, support and auditors.

  6. Do we need fraud checks and per-user limits?
    Typical answer

    Yes, daily send limits and a synchronous risk score on each outgoing transfer; detailed fraud modelling is out of scope.

  7. Can a user's balance go negative?
    Typical answer

    No for customers. Internal system accounts, such as the card-clearing account, may.

Instead of storing one balance number per user and overwriting it, what if every movement were recorded as entries that must sum to zero?
The app will retry when the network times out. How does the server recognise that it has already done this exact transfer?
Two transfers debit the same account at the same moment. What stops both from spending the last 10 dollars?
Sender and receiver may live on different shards. Do you really need a distributed transaction, or can you change where the money passes through?
Card networks and banks answer slowly and sometimes twice. How do you keep the wallet's books in step with theirs?

Requirements

Functional

  • Top up a wallet from a card or bank account, and withdraw to a bank account.
  • Send money to another user or pay a merchant; the money moves exactly once.
  • Show the current balance and a paginated transaction history.
  • Notify both parties of a completed transfer.
  • Give finance and auditors an immutable record that explains every balance.

Non-functional

  • Zero tolerance for lost or duplicated money; balances never go below zero for customer accounts.
  • Transfer p99 under 300 ms excluding external rails; balance read p99 under 100 ms.
  • 99.99% availability for transfers and balance reads.
  • About 1,200 transfers per second on average, 3,500 at peak; 7-year retention of every ledger entry.

Out of scope

  • Card issuing, KYC onboarding flows and fraud model training.
  • Multi-currency FX and cross-border compliance (discuss as follow-ups).

Back-of-the-envelope estimates

  • Writes: 50 M users × 2 movements a day = 100 M transfers ≈ 1,160 per second on average, about 3,500 per second at peak.
  • Ledger rows: each transfer writes one transfer row and at least two entries ≈ 300 M rows a day, about 1 KB of rows and indexes per transfer, so roughly 100 GB a day, 36 TB a year and 250 TB over 7 years before replication.
  • Reads: about 10 balance or history views per transfer ≈ 12,000 per second average, 35,000 at peak; these can go to replicas and caches except the balance check inside a debit.
  • Hot accounts: a large merchant or the card-clearing account may see hundreds of writes per second on a single row, far above the ~50 to 100 serial updates per second one locked row sustains comfortably.

Raw throughput is modest for a sharded relational store. What drives the design is correctness per transfer, the hot-account problem and long-term immutable storage.

API

POST /v1/transfers        Idempotency-Key: <uuid>
     {from, to, amount, currency, note}  -> 201 {transferId, status}
POST /v1/topups           Idempotency-Key: <uuid>
     {paymentMethodId, amount}           -> 202 {topupId, status: PENDING}
POST /v1/withdrawals      Idempotency-Key: <uuid>  {bankAccountId, amount} -> 202
GET  /v1/wallets/me/balance              -> {available, pending, asOf}
GET  /v1/wallets/me/transactions?cursor= -> {items[], nextCursor}
POST /internal/rails/webhook             (signed bank / card callbacks)

Every money-moving call requires a client-generated idempotency key; the server stores it with the result in the same database transaction, so a retry returns the original outcome instead of moving money again.

Data model

Ledger DB (relational, ACID, sharded by account_id):

  • accounts(account_id PK, owner_id, type, currency, balance, version, status): the balance column is a cached sum of entries, updated in the same transaction as the entries.
  • transfers(transfer_id PK, idem_key UNIQUE, from, to, amount, state, created_at)
  • entries(entry_id, transfer_id, account_id, amount_signed, created_at): append-only, never updated or deleted. Corrections are new reversing entries.
  • outbox(id, payload, published): events written in the same commit and relayed to the stream.

A relational store is chosen for multi-row transactions, row locks, unique constraints and check constraints (balance >= 0 for customer accounts). History reads go to replicas indexed by (account_id, created_at). Entries older than about 90 days are copied to a write-once object store in columnar files for audits and analytics.

High-level design

The app calls the API gateway, which authenticates the user, applies rate limits and forwards a transfer to the Transfer Service. The service first asks Risk & Limits for a quick score and checks the daily send limit. It then opens one transaction on the Ledger DB: insert the transfer row (the unique idempotency key rejects a duplicate), lock the two account rows in a fixed order, check the sender's balance, write a debit and a credit entry, update both cached balances and add an outbox event. Only after that commit does the API return success.

A relay reads the outbox and publishes to the Ledger Events stream. The Notifier pushes "you received 20 dollars" to both phones, and the Reconciler continuously recomputes balances from entries and matches bank and card settlement files from the external rails. Events are also archived to a write-once audit store.

On the read path, the History service serves balances and statements from read replicas, labelled with an asOf time. Top-ups and withdrawals call the rails asynchronously and move through PENDING to SETTLED or FAILED as signed webhooks arrive.

Open the HLD board and press Reference to see the architecture diagram.

Deep dives

Exactly-once transfers with idempotency keys

The client creates a UUID per user intent and sends it on every retry. The transfer row carries a unique index on that key, and the row is inserted inside the same database transaction as the entries. If two retries race, one insert wins and the other hits the unique constraint, rolls back, and reads the stored result to return. Storing the request fingerprint with the key lets the server reject a reused key with a different amount. Keeping the key in a separate cache is not enough: a crash between the cache write and the ledger commit would either lose the transfer or apply it twice.

Concurrency and cross-shard transfers

Within one shard, lock both account rows with SELECT ... FOR UPDATE in ascending account-id order so two opposite transfers cannot deadlock, then check and write. When sender and receiver sit on different shards, avoid two-phase commit across the fleet. Instead split the transfer into steps: debit the sender into an internal in-transit account on the sender's shard, then credit the receiver from the matching in-transit account on the receiver's shard, driven by a durable saga with idempotent steps. The money is always somewhere on the books, and the reconciler alerts if an in-transit account holds a balance for longer than a minute.

Hot accounts

A merchant taking thousands of payments a second, or the clearing account that every top-up touches, becomes one row that every transaction must lock. Split such accounts into N sub-accounts (for example 32 buckets), credit a random bucket, and report the balance as the sum. Debits from a hot account (payouts) are rarer and can sweep buckets in a batch. For accounts that only receive money, you can also skip the row lock and maintain the balance asynchronously from entries, because credits cannot overdraw.

Proving the books balance

Double entry gives a cheap invariant: the sum of all entries is zero, and each account's cached balance equals the sum of its entries. A continuous job checks recent transfers within minutes; a nightly job checks totals per shard and compares the clearing accounts against bank and card settlement files line by line. Any break raises an incident, never an automatic fix. Corrections are posted as new reversing entries with a reason code, so history is never rewritten and an auditor can replay any balance at any point in time.

Failure modes

  • If the Transfer Service crashes after commit but before replying, the client retries with the same key and receives the stored result; no money moves twice.
  • If a ledger shard primary fails, transfers for accounts on that shard pause until a synchronous replica is promoted (seconds); other shards keep working, and asynchronous replicas are never promoted for money.
  • If the outbox relay or stream is down, transfers still commit; notifications and history lag, and the relay catches up from the outbox table afterwards.
  • If a bank or card network times out, the top-up stays PENDING; the system queries status or waits for the webhook and never credits the wallet until the rail confirms.
  • If a cross-shard saga stops halfway, the in-transit account shows a non-zero balance; a recovery worker resumes or reverses the step, both idempotent.

Core algorithm in code

from dataclasses import dataclass
import threading
import uuid


@dataclass
class Entry:
    txn_id: str
    account: str
    amount: int  # minor units; debit negative, credit positive


class InsufficientFunds(Exception):
    pass


class Ledger:
    """Double-entry ledger: every transfer writes entries that sum to zero."""
    def __init__(self):
        self.entries = []          # append-only journal
        self.balances = {}         # materialised from entries, updated in the same "transaction"
        self.overdraft = {}
        self.done = {}             # idempotency key -> (request fingerprint, txn id)
        self.lock = threading.Lock()

    def open_account(self, account, overdraft_ok=False):
        self.balances[account] = 0
        self.overdraft[account] = overdraft_ok

    def transfer(self, idem_key, src, dst, amount):
        if amount <= 0 or src == dst:
            raise ValueError("bad transfer")
        fingerprint = (src, dst, amount)
        with self.lock:  # stands in for row locks taken in a fixed account order
            if idem_key in self.done:
                seen, txn = self.done[idem_key]
                if seen != fingerprint:
                    raise ValueError("idempotency key reused with a different body")
                return txn
            if not self.overdraft[src] and self.balances[src] < amount:
                raise InsufficientFunds(src)
            txn = uuid.uuid4().hex[:8]
            for acct, amt in ((src, -amount), (dst, amount)):
                self.entries.append(Entry(txn, acct, amt))
                self.balances[acct] += amt
            self.done[idem_key] = (fingerprint, txn)
            return txn

    def balance(self, account):
        return self.balances[account]

    def audit(self):
        """Recompute every balance from the journal and check the books balance."""
        totals = {}
        for e in self.entries:
            totals[e.account] = totals.get(e.account, 0) + e.amount
        drift = {a: (totals.get(a, 0), b) for a, b in self.balances.items() if totals.get(a, 0) != b}
        return sum(e.amount for e in self.entries) == 0 and not drift


ledger = Ledger()
ledger.open_account("bank_clearing", overdraft_ok=True)   # money entering from card top-ups
for user in ("alice", "bob"):
    ledger.open_account(user)

ledger.transfer("topup-1", "bank_clearing", "alice", 5_000)
t1 = ledger.transfer("p2p-42", "alice", "bob", 1_250)
t2 = ledger.transfer("p2p-42", "alice", "bob", 1_250)    # client retry after a timeout
print("retry returned same txn:", t1 == t2)
try:
    ledger.transfer("p2p-43", "bob", "alice", 9_999)
except InsufficientFunds as e:
    print("rejected, insufficient funds in", e)
print("alice", ledger.balance("alice"), "bob", ledger.balance("bob"),
      "clearing", ledger.balance("bank_clearing"))
print("journal entries:", len(ledger.entries), "books balance:", ledger.audit())

Trade-offs

  • A sharded relational ledger gives ACID transfers and constraints but costs harder horizontal scaling than a key-value store.
  • Synchronous replication protects every committed transfer but adds a few milliseconds to each commit.
  • In-shard transfers are simple and atomic, while cross-shard sagas scale further but leave money briefly in transit.
  • Splitting hot accounts into buckets raises throughput but makes balance reads a sum and debits more complex.
  • Cached balance columns give fast checks but must be kept equal to the entry sum by reconciliation.

What a strong answer shows

  • Models money as an append-only double-entry ledger rather than a mutable balance field alone.
  • Makes every money-moving API idempotent, with the key committed atomically with the entries.
  • Prevents double spending with row locks or conditional updates and a consistent lock order.
  • Handles cross-shard transfers and slow external rails without distributed transactions, using sagas and pending states.
  • Spots the hot-account problem and proposes a concrete mitigation.
  • Describes reconciliation and immutable correction so the books can be audited.

Mistakes to avoid

  • Updating balances with read-modify-write in application code, which loses updates under concurrency.
  • Picking an eventually consistent store for balances because it scales, then patching correctness on top.
  • Storing idempotency keys in a cache outside the ledger transaction.
  • Crediting a wallet as soon as a card charge is requested, before the rail confirms it.
  • Using floating point for amounts instead of integer minor units.

Follow-ups to expect

  • How would you add multi-currency wallets? (One account per currency; FX is a transfer through a house FX account with a locked rate.)
  • How do you handle a chargeback months after a top-up already spent? (Reversing entries; the wallet may go negative into a recoverable debt account.)
  • How would you shard as users grow tenfold? (More account-id shards, directory-based mapping, move accounts with a brief freeze.)
  • How do you serve monthly statements cheaply? (Precomputed snapshots plus entries since the snapshot, from the archive.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url