Design a Digital Wallet

Hold balances and move money between users safely: a double-entry ledger, idempotent transfers, no overdrafts, and transfers across database shards.

Case study32 minLesson 7 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Alice owes Bob $20 for dinner. She opens a wallet app, types Bob's name and taps Send. Her balance drops by $20, Bob's rises by $20, and both see the transfer in their history. The kind of product this question means is a payments-app wallet such as Venmo. Behind that one tap: two balances that must change together, a double tap that must not send $40, and two phones spending the same $30 at the same moment.

It looks like Design a Payment System, and it reuses its idempotency keys and ledger rules. The difference is that here both sides of every transfer are accounts you hold, possibly on different database shards, so you have to move money between two of your own databases without ever creating or losing a cent. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design a digital wallet.

You

Users top up from a card, send money to other users, and see their balance and history. Do they withdraw to a bank, and is there more than one currency?

Interviewer

One currency. Withdrawals can wait. Top-ups go through an existing payment system.

You

How many users and transfers?

Interviewer

100 million wallets, about 50 million transfers and 5 million top-ups a day.

You

Then the rules I won't bend: a balance never goes below zero, a retried transfer moves money once, and every cent is traceable to ledger entries that balance. I'd rather reject a transfer than risk any of those. Bob may see money a second after Alice sends it, but Alice must never see money vanish. Fraud checks and limits I'll leave out.

  • Functional: top up a wallet from a card; send money to another user; view the balance and a paged history.
  • Non-functional: no negative balances, even under concurrent requests; a transfer retried any number of times moves money once; no acknowledged transfer is lost; every balance is explained by an append-only ledger kept for 7 years; a send answers within about 500 ms; the recipient is credited within seconds.
  • Out of scope: withdrawals, currencies, fraud, spending limits, card processing (the payment system's job).

Estimate the scale

  • Transfers: 50 million ÷ 86,400 ≈ 580 a second on average. Payday and holidays run hotter; plan for 5x, about 3,000 a second.
  • Top-ups: 5 million ÷ 86,400 ≈ 60 a second, small next to transfers.
  • Balance reads: if users open the app 300 million times a day, 300 million ÷ 86,400 ≈ 3,500 single-row reads a second.
  • Database writes: assume one relational primary safely takes about 5,000 short write transactions a second. Today's peak of 3,000 transfers fits on one. At 10x, 30,000 a second, it does not.
Estimate: Peak transfer rate

About how many transfers a second must the wallet handle at a 5x peak?

You know
  • 50 million transfers a day
  • A day is 86,400 seconds
  • Peaks run 5 times the average

Show the working
  1. 50 × 106 ÷ 86,400 ≈ 580 a second
  2. 580 × 5 ≈ 2,900, call it 3,000 a second

Estimate 3 thousand/s (anything from 1 thousand/s to 10 thousand/s is a good answer)

Within what one primary can take today, which is why the first answer is a single database.

Estimate: Ledger storage per year

Each transfer writes 4 ledger entries of about 200 bytes (two on the sender's side, two on the receiver's). How much ledger data accumulates per year?

You know
  • 50 million transfers a day
  • 4 entries per transfer
  • About 200 bytes per entry
  • Entries are never deleted

Show the working
  1. 50 × 106 × 4 × 200 B = 40 GB a day
  2. 40 GB × 365 ≈ 14.6 TB a year
  3. Seven years: about 100 TB

Estimate 15 TB (anything from 5 TB to 50 TB is a good answer)

Recent months stay in the main database; closed periods move to cheaper, still-queryable storage.

Your turn: Define the API

Write the endpoints for top-up, send, balance and history. Who creates the identifier that makes a retried send harmless, and when?

Compare with a model answer

The app creates a transfer id when the user taps Send, before any network call, and every retry carries the same id. The server stores it as the transfer's primary key, so a duplicate cannot become a second transfer.

Define the API

HTTP API
POST /v1/topups            Idempotency-Key: tp-6f1c
  body: { "amount_cents": 5000, "payment_method": "pm_..." }
  201 { "topup_id": "tp-6f1c", "status": "processing" }
 
POST /v1/transfers
  body: { "transfer_id": "t-81", "to_user": "bob", "amount_cents": 2000, "note": "dinner" }
  201 { "transfer_id": "t-81", "status": "sent" }
  200 { "transfer_id": "t-81", "status": "sent" }       same id seen before
  409 { "error": "insufficient_funds", "balance_cents": 1000 }
  422 same transfer_id with a different body
  404 no such recipient
 
GET /v1/wallet
  200 { "balance_cents": 1000, "as_of_entry": 99812 }
 
GET /v1/wallet/history?before=99812&limit=50
  200 { "entries": [ { "transfer_id": "t-81", "amount_cents": -2000,
                       "balance_after": 1000, "at": "..." }, ... ], "next": "99761" }

The decision worth arguing is when "sent" is true. The API answers once the money has left Alice's balance, not once it reaches Bob's. From that moment the transfer cannot be lost: it either completes or is reversed back to Alice, automatically. Waiting for Bob's side would put a second database on the latency path for no gain in safety. Money is always whole cents in integers, never floating point.

Your turn: Model the data

Sketch the records. Is the balance a column you update, or something you compute? Where do the ledger entries of one transfer live if Alice and Bob are on different shards?

Compare with a model answer

The ledger entries are the truth and the balance column is a cache of their sum, updated in the same transaction. Entries live with their account, so one transfer has entries on two shards, and each shard's half must balance on its own.

Model the data

Per shard; every table is partitioned by wallet
wallets
  wallet_id      VARCHAR(32) PRIMARY KEY
  balance_cents  BIGINT        -- cache of SUM(ledger_entries), same transaction
  status         VARCHAR(8)    -- active | frozen
 
ledger_entries                 -- append only
  entry_id       BIGINT PRIMARY KEY
  account_id     VARCHAR(32)   -- "alice", or "transit:shard-A"
  transfer_id    VARCHAR(36)
  amount_cents   BIGINT        -- change to that account: -2000, +2000
  balance_after  BIGINT        -- cheap history and audits; NULL for transit
  INDEX (account_id, entry_id DESC)
 
transfers                      -- on the sender's shard
  transfer_id    VARCHAR(36) PRIMARY KEY
  from_wallet, to_wallet, amount_cents, request_hash
  status         VARCHAR(10)   -- sent | completed | reversed
 
applied_credits                -- on the receiver's shard
  transfer_id    VARCHAR(36) PRIMARY KEY
  outcome        VARCHAR(8)    -- credited | refused
 
outbox                         -- on the sender's shard
  transfer_id, payload, created_at

Every operation is a short transaction over a few rows that must all commit or none: a conditional debit, two ledger entries, a transfer row. That calls for a relational database with ACID transactions, with synchronous replication so an acknowledged transfer survives losing the primary. When it outgrows one primary, shard it by wallet_id: a wallet's balance, entries and history then always sit together, so balance reads and history pages stay single-shard.

Your turn: High-level design

Sketch the boxes for a send between two wallets on different shards. What commits first, what carries the transfer to the second shard, and what happens if that second step can never succeed?

Compare with a model answer

A wallet service debits Alice on her shard and, in the same transaction, writes an outbox message. A transfer worker delivers it and credits Bob on his shard, once. If the credit is permanently refused, the worker reverses the debit. Each step is a local, balanced transaction.

High-level design

This is the design for the 10x scale, sharded. At today's scale the same service talks to one database, and both legs below become one transaction. A top-up is simpler: the payment system charges the card, and when it confirms, the wallet service credits Alice from a card-funding account, keyed by the top-up id so a repeated confirmation credits once.

A send is two local transactions: the debit on the sender's shard, then the credit on the receiver's shard, joined by an outbox and a worker.Mobile app to API gateway (send, read); API gateway to Wallet service; Wallet service to Shard A (Alice) (debit leg); Wallet service to Payment system (charge card); Shard A (Alice) to Transfer worker (outbox), asynchronous; Transfer worker to Shard B (Bob) (credit leg).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)send, readdebit legcharge cardoutboxcredit leg

request and replyasynchronous message

A send is two local transactions: the debit on the sender's shard, then the credit on the receiver's shard, joined by an outbox and a worker.
A send, an overdraft race, a crash and a reversalUse the arrows or ← → keys
  1. A send, an overdraft race, a crash and a reversal, step 1Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)
    alice
    3000
    bob
    500
    in transit
    0

    1Alice has $30 and taps Send $20 to Bob. The app creates transfer id t-81 first, so any retry carries the same id.

  2. A send, an overdraft race, a crash and a reversal, step 2Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)t-81 sentTransfer workerShard B (Bob)
    alice
    1000
    bob
    500
    in transit
    2000

    2One transaction on shard A: insert t-81, debit Alice only if her balance is at least 2000 (1 row changed), write Alice −2000 and transit:A +2000, and an outbox row. The debit balances on its own.

  3. A send, an overdraft race, a crash and a reversal, step 3Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)
    alice
    1000
    bob
    500
    in transit
    2000

    3Alice sees "sent". Her money is now in a transit account on her shard: never in two places, never nowhere.

  4. A send, an overdraft race, a crash and a reversal, step 4Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile app409 for t-82API gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)
    alice
    1000
    bob
    500
    in transit
    2000

    4The same instant, Alice's tablet sends $20 to Carol (t-82). Its debit waited for the row lock, then rechecked the balance: 1000 is less than 2000, 0 rows changed, so 409 insufficient funds.

  5. A send, an overdraft race, a crash and a reversal, step 5Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)
    alice
    1000
    bob
    500
    in transit
    2000

    5The transfer worker reads t-81 from shard A's outbox and sends the credit leg to shard B.

  6. A send, an overdraft race, a crash and a reversal, step 6Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerShard B (Bob)t-81 applied
    alice
    1000
    bob
    2500
    in transit
    0

    6One transaction on shard B: insert t-81 into applied_credits, write transit:B −2000 and Bob +2000. Transit nets to zero across the two shards.

  7. A send, an overdraft race, a crash and a reversal, step 7Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)Transfer workerdownShard B (Bob)duplicate, no-op
    alice
    1000
    bob
    2500
    in transit
    0

    7The worker crashes before marking t-81 completed, so after restart it delivers the credit again. The applied_credits key already exists: nothing changes, and Bob is credited once.

  8. A send, an overdraft race, a crash and a reversal, step 8Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)t-81 completedTransfer workerShard B (Bob)
    alice
    1000
    bob
    2500
    in transit
    0

    8It marks t-81 completed on shard A. Retried delivery plus a unique key gives an effect that happens exactly once.

  9. A send, an overdraft race, a crash and a reversal, step 9Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)t-90 sentTransfer workerShard B (Bob)
    alice
    500
    bob
    2500
    in transit
    500

    9Later Alice sends $5 to Dan, whose wallet on shard B was just frozen. The debit leg commits as before: Alice −500, transit:A +500.

  10. A send, an overdraft race, a crash and a reversal, step 10Mobile app to API gateway; API gateway to Wallet service; Wallet service to Shard A (Alice); Wallet service to Payment system; Shard A (Alice) to Transfer worker, asynchronous; Transfer worker to Shard B (Bob).Mobile appAPI gatewayWallet servicePayment systemShard A (Alice)t-90 reversedTransfer workerShard B (Bob)Dan frozen
    alice
    1000
    bob
    2500
    in transit
    0

    10Shard B refuses the credit and records the refusal under t-90, so a late redelivery can never credit Dan. The worker runs the compensation on shard A: transit:A −500, Alice +500, status reversed.

1 / 10

Deep dives

Moving money across shards

With 16 shards and wallets spread evenly, Bob is on Alice's shard only 1 time in 16, so about 94% of transfers cross shards. Sharding by wallet does not avoid the problem; it makes it the normal case. Four ways to handle it:

One transfer, two accounts
OptionGood atBad atPick it when
One database transaction Usual defaultAtomic and simple: both balances and all entries commit together. No in-between state.Every wallet must live in one primary, which caps write throughput.Until peak load nears what one primary takes. Today's 3,000 a second.
Two-phase commitAtomic across shards: both commit or neither does.Locks are held across network round trips. If a lone coordinator dies after prepare, both rows stay locked until it returns.Rarely by hand. A distributed SQL database runs it over replicated groups, which removes the blocking but keeps the latency.
Saga with compensationEach step is a fast local transaction. A shard outage delays transfers instead of locking rows.An in-between state exists, and you must write and test the reversal.Sharded, at 10x. Our design.
Event-sourced ledgerAn ordered log of transfer events is the truth; balances are rebuilt by replaying it, and audit comes free.Overdraft checks need ordered processing per account, plus snapshots, replays and consumers that apply each event once.Very high throughput, or a team that already runs a log as its system of record.

My pick: one database while it fits, and a saga once it does not. Two details make the saga safe. First, the order: debit first, credit second. The debit is the only step that can fail for a business reason (not enough money), so it runs first. Once it commits, the credit is nearly certain, and the rare refusal (a frozen or closed wallet) has a simple reversal: give the money back. The opposite order could credit Bob and then fail to debit Alice, creating money from nothing.

Second, each leg balances on its own through a transit account per shard. Alice's leg moves $20 from Alice to transit:A; Bob's moves $20 from transit:B to Bob. While a transfer is in flight, transit:A is +2000 and transit:B is −2000, so the books across all shards still sum to zero. The sum of the transit accounts always equals money in flight, which gives reconciliation a precise check.

Two traps. Every transfer on a shard touches its transit account, so a cached balance row for it would be the hottest lock in the system: transit accounts get entries only, and the reconciler sums them. And a refusal is final. Shard B stores it in applied_credits, and the reversal runs only if the transfer is still sent; otherwise a redelivery after Dan is unfrozen would pay him after Alice got her money back.

Preventing overdraft under concurrency

Alice has $30 and two devices each send $20 within a millisecond. If the service reads the balance, checks it in code, then writes, both reads see 3000 and both transfers go through: −$10. The check and the write must be one atomic step.

The debit leg on Alice's shard
BEGIN;
INSERT INTO transfers (transfer_id, from_wallet, to_wallet, amount_cents, status)
     VALUES ('t-81', 'alice', 'bob', 2000, 'sent');      -- duplicate id: stop here
UPDATE wallets SET balance_cents = balance_cents - 2000
 WHERE wallet_id = 'alice' AND balance_cents >= 2000;    -- 0 rows: ROLLBACK, 409
INSERT INTO ledger_entries ... ('alice', -2000), ('transit:shard-A', +2000);
INSERT INTO outbox (transfer_id, payload) VALUES ('t-81', '...');
COMMIT;

The conditional UPDATE takes a row lock on Alice's wallet. The second debit waits for it. In PostgreSQL at read committed, it then rechecks its WHERE against the new balance of 1000 and changes nothing. At repeatable read or serializable it fails with a serialization error instead; retry it, and the retry sees 1000. Either way, no overdraft. Transactions and isolation covers the levels; name the one you rely on.

The lock lasts only for this short transaction, and only the sender's row is locked. Credits need no balance check, which matters for hot accounts (see the follow-up questions).

Ledger, audit and reconciliation

The ledger rules are the ones from the payment system lesson: entries sum to zero, are never edited, and mistakes are fixed with reversing entries. What a wallet adds is that the ledger is the balance. The balance_cents column is a cache written in the same transaction as the entries, and balance_after on each entry lets anyone check any point in history without summing years of rows.

Run these checks continuously, and page someone when one fails:

  • Per wallet: the cached balance equals the latest balance_after, and the sum of entries. Sample wallets all day; check every wallet nightly.
  • Per transfer: any transfer still sent after a minute is stuck. A sweeper re-drives it from the outbox.
  • Across shards: all accounts together sum to zero, and the transit accounts sum to the amount of transfers still in flight.
  • Against the outside world: total of all wallets equals the money in the company's pooled bank account, and top-ups match the payment system's settlement records.

Bottlenecks and failures

At 10x, 30,000 transfers a second at peak, each cross-shard transfer is two local transactions: 60,000 a second. The worker marks transfers completed in batches, which adds little. At about 5,000 per primary, that is 12 primaries; use 16 for headroom and growth. Balance reads, 35,000 a second at 10x, are one-row lookups on the owner's shard. History pages can come from replicas a second behind, with the app showing its own just-sent transfer as pending.

When this diesWhat happensWhat you do
A wallet service serverRequests in flight fail; committed debits are safe with their outbox rows.The app retries with the same transfer id and gets the same answer.
A shard primaryDebits from and credits to its wallets pause until the synchronous replica is promoted.Return 503 for its senders; credits wait in other shards' outboxes and land after failover.
The transfer workerSenders are debited; recipients wait. Transit accounts grow.Run several workers over partitioned outboxes; alert on transfers stuck past a minute.
The payment systemTop-ups stop.Transfers between wallets are unaffected; they never touch it.
The reconcilerErrors are found later.Nothing breaks today; rerun over the missed period.

What a strong answer sounds like

LevelWhat the answer does
JuniorKeeps a balance column, updates both rows in a transaction, and with prompting handles a double tap.
MidAdds idempotent transfer ids, a conditional debit against overdraft, and a ledger with derived balances.
SeniorStarts with one database and says when it stops being enough, sees that sharding makes most transfers cross-shard, chooses a debit-first saga with balanced legs, and defines the reconciliation checks that prove the books.
Crossing shardsWeak answer
Interviewer

Alice and Bob are on different shards. How does the transfer work?

You

I'd use a distributed transaction so both updates are atomic.

Crossing shardsStrong answer
Interviewer

Alice and Bob are on different shards. How does the transfer work?

You

With 16 shards, about 94% of transfers cross shards, so this is the normal path. I'd run a saga: debit Alice first, in one local transaction that also books the money to a transit account and writes an outbox row; a worker then credits Bob once, keyed by transfer id. If Bob's wallet refuses, the worker reverses Alice's debit.

Interviewer

So Alice is debited before Bob is credited?

You

For a second, yes, and the transit account shows exactly where the money is. That's the price of not holding locks across shards. Before sharding I'd keep one database and do it in one transaction.

Follow-up questions

A merchant wallet receives 1,000 credits a second. What breaks?

Its balance row: every credit waits on its lock. Credits never need a balance check, so append the entries without updating the cached balance, and fold them into it every second. Or split the account into 10 sub-accounts and sum them on read.

How would you add withdrawals to a bank?

Debit the wallet into a "payouts pending" account first, then call the bank with an idempotency key. An unknown outcome stays pending until the bank's records settle it, the same care as in the payment system.

How would you support several currencies?

One wallet per currency. A conversion is a transfer through the company's account for each currency, at a recorded rate, so every currency's ledger still sums to zero.

How do you prove the ledger was never edited?

Give the service insert-only permission on the ledger table, store a hash of the previous entry in each entry so any edit breaks the chain, and publish daily totals somewhere the database cannot change.

Why not compute the balance from entries on every read?

An active wallet has thousands of entries, and 3,500 reads a second would sum them all. The cached column is exact because it changes in the same transaction as the entries, and the nightly check proves it.

Self-review: digital wallet0 of 12

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure