SYSTEM DESIGN · DESIGN PROBLEMS · COMMERCE AND MONEY · HARD · 31 OF 41

Design a Digital Wallet: Atomic Transfers, Cross-Shard Money and Hot Accounts

Design a wallet app where a hundred million people top up, send money to friends and pay shops. Requirements and scale, the wallet API and ledger data model, then the hard parts: atomic same-shard transfers that never go negative, cross-shard transfers with a saga and a clearing account, single-writer event sourcing for very high throughput, hot merchant accounts, holds, limits and reconciliation with the bank.

Reported atUberSwiggy

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 19

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Scope it. Users top up from a card or bank, send money to another wallet, pay merchants, sometimes with a temporary hold, withdraw to a bank, and see their balance and history. The non-functional rules are strict: a balance can never go negative, money is never created or lost, a transfer completes in a few hundred milliseconds and the service is always on. Currency exchange and credit lines are out of scope; we assume a single currency.

Requirements
  • Functional
  • top up from card or bank
  • send to another wallet
  • pay a merchant (with holds)
  • withdraw to a bank account
  • balance and history
  • Non-functional
  • no negative balances
  • money never created or lost
  • under 300 ms, 99.99% up

Ask scale

A hundred million users, twenty million active each day, doing five transactions each is a hundred million a day: about twelve hundred transfers a second on average and twelve thousand at a festival peak. Design with headroom for fifty thousand. Balance checks are ten times more frequent. Each transfer writes a transaction row and two ledger entries, around a kilobyte, so a hundred gigabytes a day and tens of terabytes a year, kept for a decade. One database cannot take that write load, so we will shard.

Back of the envelope
100M users, 20M daily active
20M x 5 transactions = 100M/day
  = 1,200 TPS avg
  festival peak x10 = 12,000 TPS
  design for 50,000 TPS headroom
balance reads about 10x writes

per transfer: 1 txn + 2 entries
  about 1 KB -> 100 GB/day
  = 36 TB/year, kept 10 years

Define API

The API is small. Get my wallet, returning balance, held and available. Create a transfer to another wallet with an amount in paise and an idempotency key, so a retry after a dropped connection cannot send the money twice. Top-ups and withdrawals go through the payment system, from Design Problems thirty. And a paginated history. Every endpoint that moves money is idempotent.

API
GET  /v1/wallets/me
  -> { balance, held, available }
POST /v1/transfers
  Idempotency-Key: 3c9e...
  { to: "w_88", amount: 50000,
    note: "dinner" }
  -> 201 { txn_id, status: DONE }
POST /v1/topups      { amount, method }
POST /v1/withdrawals { amount, bank }
GET  /v1/wallets/me/history?cursor=

Data model

Each wallet row holds its current balance, the amount on hold, a version and its KYC tier. Every change also appends a ledger entry with a signed amount and the balance after it, so the balance is always explainable. A transfers table records each transaction with its idempotency key. Use a relational database for transactions and constraints, and shard by wallet id across, say, sixty-four shards. The global invariant: all entries across all wallets sum to zero.

Tables (SQL per shard)
wallets(id, user_id, balance, held,
  version, status, kyc_tier)
entries(id, txn_id, wallet_id,
  amount (+ or -), balance_after, at)
  append only
transfers(txn_id, idem_key UNIQUE,
  from, to, amount, status, at)

shard = hash(wallet_id) % 64
invariant: sum of all entries = 0

High-level design

Trace a transfer. The app calls the gateway, which authenticates the user and the device. The wallet service checks the idempotency key, then asks the limits and risk service whether this transfer fits the sender's KYC tier and velocity limits. Then it moves the money: debit the sender, credit the receiver, write both entries. When both wallets are on the same shard, that is one ordinary database transaction.

Deep dive: same shard

Within one shard the transfer is a single transaction. The debit is conditional: subtract the amount only where the balance minus holds is at least that much. If zero rows change, roll back and report insufficient funds; a negative balance is simply impossible. Then credit the receiver and write both entries. One more detail interviewers love: always lock the two wallets in a fixed order, say lower id first, or two people paying each other at once can deadlock.

One transaction
BEGIN;
UPDATE wallets SET balance = balance
  - 50000 WHERE id = A
  AND balance - held >= 50000;
-- 0 rows -> ROLLBACK, insufficient
UPDATE wallets SET balance = balance
  + 50000 WHERE id = B;
INSERT entries (A, -50000), (B, +50000);
COMMIT;
lock order: lower wallet id first

Deep dive: cross shard

Most transfers cross shards. Use a saga with a clearing account. Step one, on the sender's shard, in one local transaction: debit the sender, credit clearing, and write a credit-receiver message to an outbox. Step two, on the receiver's shard: debit clearing and credit the receiver, deduplicated on the transaction id. If the receiver's wallet is closed, compensate by refunding the sender. The books balance at every instant.

Be ready to compare. Two-phase commit makes the transfer atomic across shards, but it holds locks across a network round trip, and if the coordinator dies at the wrong moment both shards wait. The saga uses only local transactions, so it is fast and never blocks, at the cost of the receiver seeing the money a few milliseconds later and the need for a compensation path. For wallets, the saga with a clearing account is the usual choice. See Concepts episode 33.

Saga with a clearing account
step 1 (shard of A), one txn:
  debit A, credit clearing,
  outbox: CreditB(txn_id)
step 2 (shard of B), one txn:
  debit clearing, credit B
  dedupe on txn_id
B closed? compensate:
  debit clearing, refund A

clearing balance = money in flight
2PC vs saga
  • two-phase commit
  • + atomic across shards
  • - locks held across a network
  • round trip; coordinator
  • failure blocks both shards
  • saga + clearing account
  • + local transactions only
  • - receiver sees money a few
  • ms later; needs compensation

Deep dive: single writer

For far more throughput, change the model. Route every command for a wallet to one Kafka partition, handled by a single processor holding balances in memory. With only one writer per wallet, it validates and applies commands with no locks, emitting debited and credited events. Events become the source of truth and the database is a projection. After a crash, load a snapshot and replay. Cross-wallet transfers still use the saga, as messages between processors.

Event-sourced processors
commands partitioned by wallet id
  -> Kafka partition -> one processor
processor keeps balances in memory
  validates: available >= amount
  emits events: Debited, Credited
events are the source of truth
  DB rows are a projection
crash: reload snapshot + replay

one thread per partition, no locks

Deep dive: hot accounts

Now the hot account. A large merchant may receive five thousand payments a second, all crediting one row, and that row's lock becomes a queue. Split the merchant into sixteen sub-wallets and credit a random one; the balance is their sum. Credits never fail for lack of funds, so this is safe. Payouts are rare, so sweep the sub-wallets together first. Another option is batching: add up a hundred milliseconds of credits and apply them in one update.

Hot merchant wallet
5,000 payments/s into one row
  -> one row lock -> queue

split into 16 sub-wallets
  credit a random sub-wallet
  balance = sum of the 16
debits (payouts) are rare:
  sweep all subs into one, then pay
or batch: sum credits every 100 ms
  into one update

Deep dive: holds

Some payments need a hold, like a fuel pump or a cab ride whose final price is not known yet. Authorizing increases held, but only if the available amount covers it, and records the hold with an expiry. Capturing reduces the balance by the final amount and releases the hold. A void or an expiry just releases it. Available is always balance minus held, and every step is a conditional, idempotent update.

Holds for merchant payments
authorize: held = held + 30000
  WHERE balance - held >= 30000
  hold(id, wallet, amount, expires)
capture: balance - 25000,
  held - 30000 (final price 250)
void or expiry: held - 30000

available = balance - held
all transitions conditional, idempotent

Deep dive: limits

Wallets are regulated, so limits run before any money moves. Each KYC tier caps the balance, monthly inflow and per-transfer amount. Velocity rules watch for many transfers in an hour, a new device or a brand new payee. These synchronous checks must take under about thirty milliseconds, so keep the counters in Redis. Heavier fraud models run asynchronously on the event stream and can freeze a wallet afterwards. Freezing is a status change, never a deletion.

Limits and risk
  • KYC tier caps: balance limit,
  • monthly inflow, per transfer
  • velocity: transfers per hour,
  • new device, new payee
  • synchronous, under 30 ms:
  • counters in Redis per user
  • asynchronous: fraud models on
  • the event stream, can freeze
  • freeze = status, not deletion

Deep dive: reads

Reads come in two kinds. The balance must reflect the transfer you just made, so read it from the shard's primary, or a replica that is known to be caught up. History is different: stream ledger entries through change data capture into a wide-column store keyed by wallet id and time, which pages efficiently and keeps that load off the money database. A second of lag in history is fine; a second of lag in the balance generates support tickets.

Serving reads
  • balance: read from the wallet
  • shard primary, so you always
  • see your own transfer
  • history: change data capture
  • -> wide-column store,
  • key (wallet_id, time desc)
  • a second of lag is fine for
  • history, not for balance
  • cache balance only with care

Deep dive: reconcile

The money behind the wallets sits in a real bank account held on behalf of users. So every night, check the big invariant: the sum of all wallet balances, plus money in clearing and pending payouts, must equal the bank account's balance. Also check that every transaction's entries sum to zero and every wallet's balance equals its last entry's balance after. A mismatch stops the affected process and pages a human. This is how you prove nothing was created or lost.

Invariants to check
real money sits in a bank pool
  account held for users

nightly checks:
  sum(all wallet balances)
    + clearing + pending payouts
    = bank pool balance
  every txn: entries sum to 0
  balance = last balance_after
mismatch -> freeze job, page

Trade-offs

Trade-offs. Sharded SQL is simple and proven, and handles tens of thousands of transfers a second, which covers this problem. Single-writer event sourcing reaches much higher throughput, but is harder to build, debug and operate, so propose it only when the numbers demand it. The saga leaves money briefly in flight. If a shard fails, only its wallets pause. Step two of a saga retries until it succeeds, and an alert fires if anything sits in clearing too long.

Monitor transfer latency at p ninety-nine, failure reasons such as insufficient funds or limits, the clearing balance and how old its oldest item is, lock waits on hot rows, and the nightly reconciliation, which must match exactly. At ten times the load, add shards and more sub-wallets for hot merchants. At a hundred times, move to single-writer processors.

Trade-offs and failures
  • SQL shards: simple, proven,
  • fine to tens of thousands TPS
  • event sourcing: huge TPS,
  • harder to build and operate
  • saga: brief in-flight money
  • shard down: its wallets pause,
  • others keep working
  • step 2 retries until it lands;
  • clearing age alerts if stuck
Watch and grow
  • watch: transfer p99, failure
  • reasons, clearing balance
  • and age, hot-row lock waits
  • recon result (must match)
  • 10x: more shards (resharding by
  • wallet id ranges), more sub-
  • wallets for hot merchants
  • 100x: single-writer processors

Interview summary

In the interview: twelve thousand transfers a second at peak, so shard SQL by wallet id. Store balances plus an append-only ledger. Debit conditionally so a balance can never go below zero, locking in a fixed order. Cross-shard transfers run as a saga through a clearing account with an outbox. Split hot merchants into sub-wallets. Support holds and limits, serve history from a read model, and reconcile every night against the bank.

Say it like this
  • 12k TPS peak, design for 50k
  • SQL sharded by wallet id
  • balance + append-only entries
  • conditional debit: never < 0
  • lock in wallet id order
  • cross shard: saga through a
  • clearing account + outbox
  • hot merchant: sub-wallets
  • holds, limits, CDC history
  • nightly: balances = bank

Interview questions

How do you guarantee a balance never goes negative?

Make the debit a conditional update that subtracts the amount only where the balance minus holds is at least that amount, and roll back if no row was updated. The database then enforces the rule atomically under concurrency.

How do you transfer money between wallets on different shards?

Run a saga: in one local transaction debit the sender and credit an internal clearing account while writing an outbox message, then in a second local transaction on the receiver shard debit clearing and credit the receiver, deduplicated on the transaction id. If the second step cannot succeed, a compensation refunds the sender.

Why not use two-phase commit for cross-shard transfers?

Two-phase commit holds locks on both shards across network round trips and blocks if the coordinator fails mid-protocol, which hurts latency and availability. A saga uses only local transactions and stays balanced via the clearing account.

How do you avoid deadlocks when two transfers touch the same wallets?

Always acquire locks on the two wallets in a consistent order, such as ascending wallet id, so two opposite transfers cannot each hold one lock while waiting for the other.

How do you handle a merchant wallet receiving thousands of payments per second?

Split it into several sub-wallets and credit a random one so writes spread across rows, computing the total balance as their sum. Since credits cannot fail for lack of funds this is safe, and rare payouts first sweep the sub-wallets together.

What is event sourcing and when would you use it for a wallet?

Commands for each wallet are routed to a single writer that validates them against in-memory state and appends events, which become the source of truth while database tables are projections. It removes lock contention and reaches very high throughput, but it is more complex, so use it only when sharded SQL is not enough.

How do you verify that no money was created or lost?

Run reconciliation checks: each transaction’s entries must sum to zero, each wallet’s balance must equal its latest entry, and the total of all wallet balances plus in-flight and pending amounts must equal the real bank pool account.

Where would you read a user balance from?

From the shard primary or a replica known to be caught up, so users always see their own latest transfer, while transaction history is served from an eventually consistent read model fed by change data capture.