Design a Digital Wallet
Ledger & Balance System Design
Overview
A digital wallet holds a user's money inside your system. Think of the Venmo or PayPal balance, an in-app wallet, or a gift-card ledger: you top up, you send money to another user, you withdraw. The defining feature is an internal balance and money that moves wallet-to-wallet without touching a card network on every transfer. That's the crucial difference from a card-charging payment system: here you are the source of truth for the money, so if your books are wrong, money is genuinely lost or conjured out of nothing.
It is "Hard" because the whole problem is money correctness under concurrency and failure. A transfer must move funds out of one wallet and into another as one atomic act — both or neither. A retried request must not send twice. Two withdrawals racing on the same wallet must not both succeed and overdraw it. And every cent must be auditable forever. The wrong instinct — a balance column you UPDATE in place — fails all four. The right design tracks money as an immutable, append-only double-entry ledger and derives the balance from it.
In this walkthrough you'll scope the wallet, size it, model the ledger, design the API, draw the transfer and top-up flows, and go deep on the three things that actually decide correctness: the double-entry ledger and how the balance relates to it, transfer atomicity and idempotency, and concurrency control that makes double-spend impossible.
Functional requirements
- Hold a balance. Each user has a wallet with a current balance they can view at any time.
- Transfer wallet-to-wallet. A user sends money from their wallet to another user's wallet; the sender's balance drops and the recipient's rises by the same amount.
- Top up and withdraw. Move money into the wallet from an external source (bank/card) and out of it to an external destination.
- View transaction history. A user sees an ordered, itemized history of every movement in and out of their wallet.
- Never lose or double-spend money. A retry must not transfer twice; concurrent withdrawals must not overdraw; a crash mid-transfer must not leave money half-moved.
Out of scope (state it): funding the top-up/withdraw itself (the card charge or bank ACH — that's the payment-system problem; we treat the external rail as a black box that confirms or fails), multi-currency FX, interest/lending, and fraud ML. Naming these keeps interview time on the ledger-and-balance core.
Non-functional requirements
- Money correctness above all. The sum of all wallet balances must always equal the money the system holds. No transfer creates or destroys value. This is the CP choice in CAP — we'd rather reject a transfer than record an inconsistent one.
- Atomicity of every transfer. Debit and credit happen together or not at all. There is no observable state where money has left one wallet but not arrived in the other (except an explicit, tracked in-flight state).
- Exactly-once effect. Repeating the same transfer request has the same effect as doing it once — a double-click or network retry never moves money twice. This forces idempotency on every money-moving call.
- No double-spend under concurrency. Two operations debiting the same wallet at the same instant must be serialized so the balance can never go negative.
- Durability and auditability. Once a transfer commits it survives crashes, and every movement is traceable forever. This pushes us to an immutable append-only ledger, not mutable balance rows.
- Modest QPS, precious writes. Wallet volume is far lower than a social feed, but each write moves real money. Optimize for correctness first, throughput second.
Estimations
State assumptions; the goal is to justify the ledger, the per-account serialization, and the balance cache — not to hit exact numbers.
Rounded assumptions
- Users: ~100M wallets, ~20M daily active.
- Transfers + top-ups + withdrawals: ~50M money movements/day ⇒ ~600/sec average, ~5K/sec at peak. Low QPS by web standards.
- Each transfer writes 2 ledger entries (one debit, one credit); top-up/withdraw also 2 (wallet + an external-clearing account). So ~100M ledger rows/day.
- Reads (balance checks, history) are ~10–20× writes ⇒ ~10K reads/sec average, spiky on paydays.
- Retries: assume ~5–10% of write requests are retried by clients or the network ⇒ idempotent dedup must absorb hundreds of duplicate requests/sec at peak.
- Hot wallets: a few merchant/exchange wallets take thousands of transfers/sec — concentrated contention on a single account.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| Every write moves real money | Optimize for correctness/durability, not raw throughput. |
| 5–10% of requests are retries | Require an idempotency key + atomic dedup on every transfer. |
| 100M ledger rows/day, append-only | Sharded relational store partitioned by account; never mutate a balance in place. |
| Reads 10–20× writes | Cache/snapshot balances so a read isn't a full ledger scan. |
| A few hot wallets take thousands/sec | Per-account serialization must not serialize the whole system — shard the lock by account. |
Storage
- Per ledger entry: ids (~32 bytes), account (~16), amount as integer minor units (8), direction (1), transfer id (~16), timestamp (~8) ≈ ~90 bytes.
- 100M entries/day × ~100 bytes ≈ ~10 GB/day, ~3.5 TB/year. Modest — it fits a sharded relational store partitioned by account. The data is small; the difficulty is keeping it correct and balanced, not storing it.
Caution
Do not optimize this system for QPS or storage — both are small. The whole problem is atomicity, idempotency, and no double-spend. Design for correctness and auditability first.
Important
Store money as integer minor units (cents, or micro-units for crypto), never floats. 0.1 + 0.2 != 0.3 in floating point; a rounding error on money is a bug that silently loses or creates value. Every amount in this design is an integer.
Core entities / data model
The model separates the wallet (an account with an identity), the ledger (the immutable record of every movement), the transfer (the intent that groups a matched debit and credit), and the idempotency key (dedup for retries).
| Entity | Key fields |
|---|---|
Wallet |
wallet_id (PK), user_id, currency, cached_balance, held_balance, version, status, created_at |
LedgerEntry |
entry_id (PK), account_id (wallet or system account), transfer_id, direction (debit/credit), amount (integer), posted_at |
Transfer |
transfer_id (PK), from_account, to_account, amount, status, idempotency_key, created_at |
IdempotencyKey |
idempotency_key (PK — unique), request_fingerprint, transfer_id, stored response, status, expires_at |
SystemAccount |
account_id (PK), type (e.g. bank_clearing, fees, reserve) — the "other side" of top-ups and withdrawals |
The LedgerEntry rows are the double-entry ledger. A wallet-to-wallet transfer of $10 writes exactly two rows: a debit of 1000 on the sender and a credit of 1000 on the recipient, sharing one transfer_id, summing to zero. The ledger is append-only: you never edit an entry; a correction is a new, reversing entry. Balances are derived from these entries (deep dive 1).
A key insight: a top-up isn't money from nowhere. It's a transfer from a SystemAccount (bank clearing) into the wallet — still a balanced debit/credit. A withdrawal is the reverse. That's what keeps the whole system balanced, not just individual wallets.
Transfer.status is a forward-only state machine:
stateDiagram-v2
[*] --> PENDING
PENDING --> POSTED
PENDING --> FAILED
POSTED --> REVERSED
Most in-wallet transfers go PENDING → POSTED in a single database transaction and are never observably PENDING. The explicit PENDING state matters for cross-service transfers (deep dive 2) and for holds (extensions).
Important
A debit and a credit always come in a balanced pair. If you ever write one without the other, you've created or destroyed money. The pairing is the invariant the entire design protects.
API design
A small REST API. Every money-moving call carries a client-generated idempotency key; every amount is an integer in minor units.
Create a transfer (wallet-to-wallet)
POST /api/transfers
Header: Idempotency-Key: <client-generated-uuid>
Body: { "from_wallet": "w_alice", "to_wallet": "w_bob", "amount": 1000, "currency": "usd" }
201 Created
Returns: { "transfer_id": "t_789", "status": "POSTED", "from_balance": 4200 } (replay returns the same body)
Debit the sender, credit the recipient, atomically (deep dive 2). The Idempotency-Key is mandatory: a double-click or retry with the same key returns the original result instead of transferring again (deep dive 2). If the sender lacks funds, the call fails with 422 insufficient_funds and writes no ledger entries.
Top up (money in from an external rail)
POST /api/wallets/{wallet_id}/topups
Header: Idempotency-Key: <client-generated-uuid>
Body: { "amount": 5000, "source": "bank_tok_..." }
201 Created
Returns: { "topup_id": "tu_1", "status": "PENDING" } (settles to POSTED when the rail confirms)
Modeled as a transfer from the bank_clearing system account into the wallet. It starts PENDING — the external rail (the payment-system) confirms asynchronously — and only credits the wallet as spendable when the rail settles. Crediting a wallet before the money actually arrives is how wallets get drained by reversed deposits.
Withdraw (money out to an external rail)
POST /api/wallets/{wallet_id}/withdrawals
Header: Idempotency-Key: <client-generated-uuid>
Body: { "amount": 3000, "destination": "bank_acct_..." }
201 Created
Returns: { "withdrawal_id": "wd_1", "status": "PENDING" }
Debits the wallet immediately (so the funds can't be spent twice) into a clearing account, then pays out to the external rail. Between the immediate debit and either a successful payout or a failed-payout reversal, the funds sit in a tracked in-flight/clearing state — they've left the user's spendable balance but haven't yet left the system, so they're accounted for in the bank_clearing system account, not lost. If the payout fails, a reversing entry credits the wallet back.
Get balance
GET /api/wallets/{wallet_id}
200 OK
Returns: { "wallet_id": "w_alice", "balance": 4200, "currency": "usd" }
Returns the current balance — served from the cached running total, guaranteed consistent with the ledger (deep dive 1). Read-heavy path; can use a replica.
Get transaction history
GET /api/wallets/{wallet_id}/transactions?cursor=<opaque>&limit=50
200 OK
Returns: { "items": [ { "transfer_id": "t_789", "direction": "debit", "amount": 1000, "posted_at": "..." }, ... ], "next": "<cursor>" }
Paginated, reverse-chronological ledger entries for the wallet. This is the audit view; it reads straight from the append-only ledger.
High-level architecture
Two flows matter: the transfer write path (idempotent, atomic debit+credit) and the external-rail path (top-up/withdraw that spans your ledger and an outside system). We'll draw each, then combine.
1. Transfer write path
A transfer claims its idempotency key, checks funds, and writes the balanced ledger pair in one transaction.
flowchart LR
Client[Client] -->|"transfer + Idempotency-Key"| WS[Wallet Service]
WS -->|"insert key unique or return stored result"| DB[(Wallet DB)]
WS -->|"one tx debit + credit + balance update"| DB
WS -->|"return result"| Client
- The request arrives with a client-generated
Idempotency-Key. The Wallet Service tries to insert it under a unique constraint. If it already exists, this is a retry — return the stored result and stop (deep dive 2). - If the key is new, in one local transaction: verify the sender has sufficient funds under concurrency control (deep dive 3), write the debit entry on the sender and the credit entry on the recipient, update both cached balances, and store the idempotency response.
- Commit. Because the two entries and the balance updates are one transaction, there is no state where money left one wallet but not the other.
Caution
Do not write the debit, then write the credit in a second transaction. A crash between them destroys money on one side and never delivers it on the other. Both entries commit together or not at all.
2. External-rail path (top-up / withdraw)
Money crossing the boundary can't be a single local transaction, because the external rail is a separate system.
flowchart LR
Client[Client] -->|"topup or withdraw"| WS[Wallet Service]
WS -->|"PENDING transfer + outbox row one tx"| DB[(Wallet DB)]
Relay[Outbox Relay] -->|"poll pending"| DB
Relay -->|"call rail"| Rail[(External Rail)]
Rail -->|"webhook confirm or fail"| WS
WS -->|"POST or REVERSE the entries"| DB
- The Wallet Service records a
PENDINGtransfer plus an outbox row in one transaction. - An Outbox Relay polls pending rows and calls the external rail (idempotently).
- The rail confirms or fails asynchronously via webhook. On confirm, the entries move to
POSTED; on failure, a reversing entry undoes any provisional balance change. This is a saga, not a distributed transaction.
3. Combined architecture
flowchart LR
Client[Client] --> GW[API Gateway]
GW -->|"transfer topup withdraw"| WS[Wallet Service]
WS -->|"idempotency + ledger + balance one tx"| DB[(Wallet DB sharded by account)]
WS -->|"reads"| Replica[(Read Replica)]
Relay[Outbox Relay] -->|"pending external ops"| DB
Relay --> Rail[(External Rail payment-system)]
Rail -->|"webhook"| WS
Recon[Reconciliation Job] -->|"sum ledger vs cached balances"| DB
Recon -.->|"flag mismatch"| Alert[Exceptions Queue]
The Wallet DB is the strongly consistent hub holding wallets, the append-only double-entry ledger, idempotency keys, and the outbox — sharded by account so hot wallets don't serialize the whole system. Reads go to replicas. The external rail is reached through the outbox + saga. A reconciliation job continuously re-sums the ledger and checks it against cached balances and the system-wide zero-sum invariant (deep dive 1) — the safety net.
Tip
Keep the wallet's internal ledger separate from the external rail, connected by an outbox + saga — never a synchronous "debit wallet then call the bank" chain. The decoupling is what keeps the ledger consistent across a crash or a slow rail.
Deep dives
1. Double-entry ledger + balance
The most important decision in the whole design is how you represent money. The tempting answer — a balance column you read and UPDATE — is the one to avoid. It has no history (you can't answer "how did this balance get here?"), it makes every bug a silent bug (an overwrite leaves no trace), and it turns every concurrent write into a lost-update race. Money systems don't store balances; they store movements, and the balance is derived from them:
flowchart LR
T[Transfer 10 dollars] -->|debit 1000| DE[Debit entry on alice]
T -->|credit 1000| CE[Credit entry on bob]
DE --> L[(Append-only ledger)]
CE --> L
L -->|sum credits minus debits| Bal[Derived balance]
L -->|updated in same tx| Cache[Cached running balance for fast reads]
Mutable balance column: UPDATE wallets SET balance = balance - amount (avoid — no history, silent corruption)
Keep one authoritative balance per wallet and mutate it on every transfer.
transfer $10 alice -> bob
UPDATE wallets SET balance = balance - 1000 WHERE id = alice
UPDATE wallets SET balance = balance + 1000 WHERE id = bob
- Pro: trivial reads (
SELECT balance), tiny storage. - Con: no audit trail — you cannot reconstruct or prove a balance, so a bug or a missed update is undetectable. There's no invariant to check: if the two updates diverge (a crash between them, a bug, a race), money is silently created or destroyed and nothing alerts. History has to be bolted on separately and can drift from the balance.
- Verdict: avoid. For anything that touches real money, a bare mutable balance is disqualifying — you've thrown away the only thing that makes money correct: the ability to prove it.
Append-only double-entry ledger, balance derived from it (recommended)
Store every movement as two immutable, balanced ledger entries — a debit and a credit that sum to zero — and treat the ledger as the source of truth. The balance is derived:
transfer $10 alice -> bob (transfer_id t_789)
INSERT LedgerEntry(account=alice, direction=debit, amount=1000, transfer=t_789)
INSERT LedgerEntry(account=bob, direction=credit, amount=1000, transfer=t_789)
-- balance(alice) = SUM(credits) - SUM(debits) over alice's entries
Because entries are never updated, history is permanent and provable: any balance can be recomputed by summing, and the whole system must satisfy one invariant — every transfer's entries sum to zero, so all balances across all accounts sum to zero. Crucially that sum is over all accounts including the system/clearing accounts — user wallets alone are net-positive (they hold real money), and the offsetting negative sits in the bank_clearing and fees system accounts. Zero-sum only holds when they're counted. If the grand total isn't zero, an entry was dropped or duplicated, and you've caught a bug with a pure arithmetic check, no external system needed.
The one catch: summing the entire ledger on every balance read is too slow. So keep a cached running balance (the cached_balance column) that is updated in the same transaction as the ledger insert. It's a performance cache, not the source of truth — the ledger is. A reconciliation job periodically re-sums the ledger and compares it to the cached balances; if they disagree, the ledger wins and the cache is rebuilt.
Be precise about what the same-transaction write buys you. Writing the ledger entry and the cache update together eliminates crash-induced drift — the partial-commit failure where one lands and the other doesn't. It does not guarantee arithmetic correctness. A code bug — a wrong sign, a wrong amount, a ledger row inserted without the matching cache update in a different code path — drifts the two while both still commit cleanly. That's exactly why reconciliation exists: same-transaction writes close the crash class, and re-summing the ledger against the cache catches the logic class. You need both, because they fail for different reasons.
Internal reconciliation isn't enough — reconcile the boundary too. The zero-sum sum and the cache re-sum are both internal checks: they prove your ledger is self-consistent, but a self-consistent ledger can still disagree with the outside world. The bank_clearing system account is where money enters and leaves; the sum of its entries must match the rail's own settlement report. The failure this catches: a top-up credits a wallet, settles to POSTED, and then the ACH pull returns days later (insufficient funds at the bank). Internally everything still sums to zero — but real money never arrived, so the clearing account now claims funds the bank says don't exist. A daily external reconciliation of bank_clearing against the settlement file flags the gap and triggers a reversing entry to claw the credit back, before the user spends money the system never received.
- Pro: every cent is auditable and provable; the zero-sum invariant makes corruption loud; the cached balance keeps reads fast; corrections are new reversing entries, so even fixes are auditable.
- Con: more storage and a second write per movement; you must maintain the cache in-transaction and reconcile it.
- Verdict: recommended — the standard for money. Append-only ledger as truth, cached balance for speed, reconciliation to prove they agree.
Note
"Derived vs cached" isn't either/or. Pure derived (sum on read) is always correct but slow; a cached running total is fast but can drift. Do both: cache in the same transaction as the ledger write, keep the ledger as the fallback truth.
2. Transfer atomicity + idempotency
A transfer has two properties that must both hold. Atomicity: the debit and credit happen together or not at all. Exactly-once: a retried request moves money once, not twice. These are different failures — atomicity is about a crash within one transfer; idempotency is about the same transfer arriving twice — and each needs its own mechanism.
Atomicity is the easy half when both wallets are in one database: write both ledger entries and both balance updates in a single local transaction. Either it commits (money moved) or it rolls back (nothing happened). There is no partial state.
Idempotency is the subtle half. Duplicates aren't rare: a double-click, a mobile network that times out and auto-retries, a load balancer re-sending. The defense is an idempotency key — one per intended transfer, reused on every retry of it. The trap is the race, and you cannot fix it by reading more carefully:
Check-then-act: SELECT the key, transfer if absent (avoid — double-transfer race)
key = "idem-abc" ($10 alice -> bob)
T1: SELECT key -> not found
T2: SELECT key -> not found both saw absent
T1: do transfer, insert key
T2: do transfer, insert key
-> bob credited $20, alice debited $20. Double transfer.
- Con: the gap between
SELECTandINSERTis a race window; concurrent retries both pass the check and both move money. - Verdict: avoid — the read and write aren't atomic, so under exactly the concurrency idempotency exists to defend against, it fails.
Atomic claim: insert the key under a unique constraint first (recommended)
Make the first action an atomic insert of the idempotency key into a table with a unique constraint, before doing the transfer. The database guarantees one insert wins; the loser gets a duplicate-key error and knows it's a retry.
key = "idem-abc", status starts IN_PROGRESS
T1: INSERT key -> succeeds (T1 owns this transfer)
T2: INSERT key -> UNIQUE violation -> this is a retry
T1: in one tx: debit + credit + balances, then UPDATE key status=DONE, store response
T2: read stored response (or wait if still IN_PROGRESS) -> return it
-> money moved once; T2 replays the same result
The row stores the key, a request fingerprint (hash of the body, to reject a key reused with a different amount or recipient), the transfer_id, the stored response, and a status (IN_PROGRESS → DONE). Ideally the idempotency insert and the ledger writes commit in the same transaction, so a crash can't leave a DONE key with no ledger entries. A retry that arrives mid-flight (owner still IN_PROGRESS) waits or gets a retryable 409 rather than transferring again; a lease/timeout on IN_PROGRESS lets a retry retake a key whose owner crashed. Retake resets the status on the existing key row (the expired lease flips IN_PROGRESS back to claimable) — the key is not deleted and re-inserted, and the retry reuses the same transfer_id, so dedup still keys on that one transfer. Deleting the key would reopen the double-transfer window the unique constraint just closed.
- Pro: the unique constraint makes the claim atomic in one step — no read-modify-write window. Exactly-once regardless of concurrency; replays are cheap.
- Con: you must store and expire keys (a TTL of 24–72h, longer than any realistic retry); handle the
IN_PROGRESSrace by waiting. - Verdict: recommended — the canonical dedup. An atomic insert-or-return on a unique key is the only race-free way to make retries exactly-once.
When the wallets live on different shards or services, one local transaction no longer spans both. Two options: a two-phase commit (strongly atomic but blocks if any shard is slow — a poor fit at scale), or a saga. The saga models the cross-shard transfer as PENDING: debit the sender locally, emit an event to credit the recipient, and if the credit ultimately fails, run a compensating entry that credits the sender back.
flowchart LR
S1["Sender shard: debit + write PENDING one tx"] -->|"emit credit event transfer_id"| R1["Recipient shard: credit under UNIQUE transfer_id"]
R1 -->|"credit applied confirm"| S2["Sender shard: mark POSTED"]
R1 -.->|"credit fails"| C["Sender shard: compensating credit-back, mark FAILED"]
Two things make this safe. Idempotency across the boundary: the credit event is delivered at-least-once, so the recipient shard can see it twice. It applies the credit under a UNIQUE constraint on transfer_id (or (transfer_id, leg) when one transfer has multiple legs), so a re-delivered event hits a duplicate-key and is a no-op replay — the exact same atomic-claim pattern as the idempotency key, just enforced on the recipient side.
The IN-FLIGHT ledger state: while the transfer is PENDING, the sender has a debit entry with no paired credit yet — the credit lives on the other shard and hasn't landed. That transiently breaks zero-sum: summed alone, the ledger is short by the in-flight amount. So the reconciliation sum must exclude PENDING transfers (or account for their debits as a tracked in-flight balance) and check zero-sum only over POSTED and REVERSED transfers, whose debit and credit have both committed. A PENDING transfer that never resolves is an alert, not a balance error.
Caution
Do not generate the idempotency key on the server per request — every retry then gets a fresh key and dedup never fires. The client generates one key per intended transfer and reuses it on every retry of that same transfer.
Warning
Give idempotency keys a TTL longer than any retry could realistically take, and reject a reused key whose request body differs from the original (the fingerprint check) — otherwise a client bug can ride one key into two different transfers.
3. Concurrency / no double-spend
Here's the race that keeps money-systems engineers up at night. A wallet has $100. Two withdrawals of $80 arrive at the same instant. Each reads the balance ($100), each sees enough, each debits $80 — and the wallet is now -$60. The system just let a user spend money they didn't have. This is a check-then-debit race, and like the idempotency race, you cannot fix it by reading the balance more carefully. The check and the debit must be a single atomic decision.
The naive version:
balance = 100
T1: read balance 100, need 80 -> OK
T2: read balance 100, need 80 -> OK both passed the check
T1: debit 80 -> balance 20
T2: debit 80 -> balance -60 overdraw. double-spend.
The fix is to serialize the decision on a per-account basis — either optimistically (a conditional update that only one writer can win) or by funneling one wallet's operations through a single lane:
flowchart LR
W1[Withdraw 80 A] --> Q[Per-account lane for wallet alice]
W2[Withdraw 80 B] --> Q
Q -->|serialized| Chk1["Check 100 >= 80 debit balance 20"]
Chk1 --> Chk2["Check 20 >= 80 reject insufficient funds"]
B[Wallet bob other account] -->|runs fully parallel| BQ[Its own lane]
Two standard ways to close it:
Read balance in app, decide, then write (avoid — the check-then-debit race)
Load the balance into the application, compare to the amount, and if it's enough, issue the debit.
- Con: the balance can change between your read and your write. Under concurrency both requests read the same stale balance and both proceed. This is the exact race above; no amount of application-side validation closes it because the decision isn't atomic with the write.
- Verdict: avoid. Correctness cannot live in the application layer when two requests race — it must be enforced where the write is serialized.
Pessimistic lock: SELECT ... FOR UPDATE the wallet, then check and debit (works, but latency + lock ordering)
Take a row lock on the wallet first, so the check-then-debit runs with no concurrency on that account:
BEGIN
SELECT cached_balance FROM wallets WHERE wallet_id = alice FOR UPDATE
-- now serialized: no other tx can read-for-update alice until we commit
-- if cached_balance < 1000 -> ROLLBACK, reject insufficient_funds
INSERT LedgerEntry(alice, debit, 1000, t_789)
INSERT LedgerEntry(bob, credit, 1000, t_789)
UPDATE wallets SET cached_balance = cached_balance - 1000 WHERE wallet_id = alice
UPDATE wallets SET cached_balance = cached_balance + 1000 WHERE wallet_id = bob
COMMIT
- Pro: correct and easy to reason about — the lock removes the race outright, and it handles a hot contended wallet predictably (writers queue rather than retry-storm).
- Con: every debit now holds a row lock for the duration of the transaction, adding latency and capping per-wallet throughput; a transfer touches two wallets, so you must acquire both locks in a canonical order (e.g. by
wallet_id) or two opposing transfers deadlock. Under low contention it is strictly slower than a lock-free conditional update. - Verdict: acceptable, and the right default for a genuinely hot account — but for the common low-contention case, the optimistic conditional update below avoids the lock entirely.
Optimistic locking (version/CAS) guarding the whole ledger write (recommended)
Make the "have enough funds?" check and the debit one atomic operation at the storage layer, and compose it with the ledger writes so a failed check writes nothing. The guarded UPDATE lives inside the same transaction as the two ledger inserts and the recipient credit:
BEGIN
UPDATE wallets
SET cached_balance = cached_balance - 1000, version = version + 1
WHERE wallet_id = alice AND version = <read_version> AND cached_balance >= 1000
-- affected rows = 0 -> ROLLBACK the WHOLE tx: no ledger entries, no credit
-- (someone moved first, or insufficient funds) -> retry against fresh balance / reject
-- affected rows = 1 -> we won; continue in the SAME tx:
INSERT LedgerEntry(alice, debit, 1000, t_789)
INSERT LedgerEntry(bob, credit, 1000, t_789)
UPDATE wallets SET cached_balance = cached_balance + 1000 WHERE wallet_id = bob
COMMIT
The cached_balance >= 1000 guard plus the version check means the debit only applies if the balance is still sufficient and nobody changed it since we read. If the guarded UPDATE affects zero rows, roll back the entire transaction — the two ledger entries and the recipient credit are never written, so the transfer atomically did nothing. If it affects one row, the ledger pair and the recipient credit commit alongside it. This is the same atomicity promise as deep dive 2: the funds check and the balanced double-entry write are one transaction, not a guarded debit followed by a separate ledger write.
Serialized per-account processing. The pessimistic path above is one form of this: funnel all operations on one wallet through a single lane — a per-account lock (SELECT ... FOR UPDATE), or a partition/queue keyed by account_id so one worker processes a wallet's transfers one at a time. The check-then-debit is safe because there's no concurrency on that account. Crucially, you shard the lock by account, so different wallets still run fully parallel — only the same wallet serializes.
- Pro: overdraw becomes impossible; the atomic conditional is the single point where the balance rule is enforced, and because it guards the same transaction as the ledger inserts, a failed check leaves no partial write. Lock-free and fast when contention is low.
- Con: optimistic locking retries under high contention (a hot merchant wallet may see many CAS failures — batch or queue those); when a transfer touches two accounts you still lock/update them in a canonical order to avoid deadlock.
- Verdict: recommended. Use optimistic locking (CAS on version + balance guard, guarding the full ledger write) by default; switch a hot account to the pessimistic serialized path when CAS contention gets high. Either way the invariant lives at the storage layer, not the app.
Important
A transfer touches two wallets, so a lock-based approach can deadlock (T1 locks alice then bob, T2 locks bob then alice). Always acquire the two account locks in a canonical order (e.g. by wallet_id) so two opposing transfers can't circularly wait.
Warning
The insufficient-funds check must be atomic with the debit — a conditional UPDATE ... WHERE balance >= amount, or done inside a per-account lock. A separate "check balance, then debit" is the double-spend bug; it looks correct in single-threaded tests and fails exactly when two requests race.
Tradeoffs & bottlenecks
- Derived balance vs cached balance. Summing the ledger on every read is always correct but slow; a cached running total is fast but can drift from bugs. Resolve it by updating the cache in the same transaction as the ledger and reconciling against the ledger — fast reads, ledger as truth.
- Strong consistency vs availability for money. We choose CP: reject or retry a transfer rather than record an inconsistent one. Reads (balance, history) can be AP off replicas — a balance that's a second stale is fine; a debit that half-applies is not.
- 2PC vs saga for cross-shard transfers. 2PC is atomically clean but blocks when a participant is slow — unacceptable at scale. A saga (PENDING + compensating entries) trades atomic simplicity for resilience; it's the right trade when transfers cross shards or services.
- Optimistic vs pessimistic concurrency. Optimistic (CAS) is lock-free and fast under low contention but retries under a hot wallet; per-account serialization is predictable under contention but adds latency. Pick per account based on its contention.
- Hot-wallet contention. A merchant/exchange wallet taking thousands of transfers/sec is the real bottleneck — every write contends on one row. Mitigate with a per-account queue, or split the hot balance into sub-accounts summed periodically. Sharding by account keeps this contained to that one account.
- Append-only growth. The ledger grows forever (auditability requires it). Partition by account and time, archive cold entries to cheaper storage, and rely on periodic balance snapshots so you never re-sum from the beginning of time.
Extensions if asked
Add only the extension that changes the design discussion.
- Holds / authorizations. Reserve funds without moving them yet (a pending purchase). Track reserved funds in the wallet's
held_balancefield, sospendable = cached_balance − held_balance; placing a hold raisesheld_balance(funds stay on the ledger but become unspendable), and capturing it posts the real debit while releasing it just lowersheld_balance. This is the wallet analog of authorize-then-capture in the payment-system. - Multi-currency. Currency-tag every account and never mix currencies in one balance. Cross-currency transfers become two transfers through an FX account with a captured rate — the ledger model absorbs it cleanly.
- Interest / fees. Post fee entries to a
feessystem account as part of the transfer (double-entry keeps them balanced). Interest is a scheduled batch of credit entries. - Fraud / velocity checks. A scoring service that can hold or reject a transfer before it posts, plugged in at the
PENDINGstage — its own subsystem, orthogonal to ledger correctness. - External payment rails. Funding top-ups and settling withdrawals is the card/bank-charging problem — cross-link the payment-system breakdown; the wallet treats the rail as a black box behind the outbox + saga.
- Per-rail reconciliation cadence. External reconciliation (deep dive 1) generalizes per rail: card, ACH, and wire each settle on their own schedule, so run a separate clearing-account reconciliation against each rail's settlement file on that rail's cadence, routing mismatches to the exceptions queue.
What interviewers look for & common mistakes
What interviewers usually reward:
- A double-entry, append-only ledger as the source of truth, with the balance derived from it and a cached running total for reads — plus the zero-sum invariant as a correctness check.
- Atomic transfers — debit and credit in one transaction — and idempotent transfers via an atomic insert-or-return on a unique key, so retries are exactly-once.
- Concurrency control that makes overdraw impossible — optimistic locking (CAS on version + a
balance >= amountguard) or serialized per-account processing — with the funds check atomic with the debit. - Money as integer minor units, never floats.
- Consistency over availability for writes, reads served from replicas.
- A saga (not 2PC) for cross-shard/cross-service transfers, with compensating entries.
Before you finish, do a quick mistake check:
- Did you use an append-only double-entry ledger instead of mutating a
balancecolumn, and derive/reconcile the balance from it? - Did you make transfers idempotent with a client-generated key and an atomic unique-constraint claim (not a racy read-then-transfer)?
- Did you make the debit and credit atomic (one transaction), so a crash never half-moves money?
- Did you close the concurrent-withdrawal race with an atomic conditional update or per-account serialization — and lock two accounts in canonical order?
- Did you store money as integer minor units, never floats?
- Did you choose a saga over 2PC for cross-shard transfers, and consistency over availability for money writes?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →