Design a digital wallet.

The complete Staff+-level answer — built from the same question definition the interview simulator probes, and scored against the same rubric it grades with.

HARD ~25 min read fintechconsistencyconcurrency

Last updated · built from the live interview engine’s question definition and rubric

Ledger view — one transfer, two entries

DEBIT

sender’s wallet

−$80.00

CREDIT

receiver’s wallet

+$80.00

Σ = 0 — money moved, never created or destroyed

Two ways to read this. Night before the interview: the revision sheet plus the one-line checkpoint that closes each section. With a weekend: read straight through — the instruments are optional depth, not required reading.

Before you read

The answer in four anchors

Use these as the mental checklist while you skim; the sections below unpack each one.

Contract

Never double-spend

Say the contract before the boxes: “a balance can never be spent twice, and money is never created or destroyed.” Everything below serves that sentence.

Scale read

Shard forces the saga

~11.6K TPS average, ~35K peak, 100K+ on festival days. The number that matters is not the rate — it is that sharding by account is forced, which is what makes atomic transfer hard.

Core commit

Reserve, credit, confirm

Same shard, one ACID transaction: debit + credit + outbox, Σ = 0. Different shards, reserve-then-credit-then-confirm as a persisted saga. Async begins only after the commit.

Deep dives

Conserve the money

Every deep dive is the same test: money is conserved. Name the seam, give the mechanism, bound the worst case to “briefly held,” never “lost or created.”

01 · The brief · minute zero

What you are handed

Interviewer brief — verbatim

Design a digital wallet like PayPal, Venmo, or Paytm — users hold a stored balance, top up from a bank or card, transfer to other users, pay merchants, and cash out, with zero tolerance for a balance being spent twice.

Scale: 1B wallet transactions/day, 500M accounts, strong consistency on every balance, sub-second P2P transfers, multi-currency, and financial-grade auditability.

Key challenges: preventing double-spend under concurrent debits, a double-entry ledger where balances are derived not stored, atomic transfer between two accounts that may live on different shards, holds and available-vs-total balance, provisional-credit risk on reversible top-ups, and reconciliation that proves money was conserved.

Decide: how do you make a transfer atomic once accounts are sharded? What is spendable the instant a top-up lands?

The shape — hold this before any detail

Append-only ledger

Every balance is derived from a double-entry log, never a column you mutate. The system-wide invariant: all entries sum to zero.

Atomic transfer core

The debit is a conditional write, not a read-then-update. Same shard: one transaction. Different shards: a saga with holds.

External rails + float

Bank and card networks for top-up and cash-out. A top-up is provisional money — you never treat it as final until it clears.

client → gateway → wallet service → coordinator  ·  hold on source → credit dest → confirm → outbox → Kafka → notify / recon

Three zones, and the whole interview is fought in the seams between them. The full diagram is in §06 — but draw this much in the first two minutes, so the interviewer has a model to hang everything else on.

What is really being asked

This looks like a scale problem and partly is — 1B transactions/day forces you to shard accounts, which a single Postgres cannot hold. But every hard constraint is a conservation-of-money constraint: a balance can never be spent twice, money is never created or destroyed, and a balance that shows as spendable must actually be there. The question rewards engineers who model a wallet as a ledger, not a bank-balance field.

Winning shape: an append-only double-entry ledger as the source of truth, a debit that is atomic by construction, and — because you shard by account — a transfer that is a small persisted saga (reserve → credit → confirm), never two naked UPDATEs. Candidates fail in the seams: the race between two debits, the cross-shard transfer that half-commits, the top-up clawed back after the money is already gone.

02 · Requirements · minutes 0–8

Four functional requirements, six constraints

Functional

  1. A user tops up their wallet from a linked bank account or card.
  2. A user transfers funds to another user’s wallet (P2P) or pays a merchant.
  3. A user withdraws (cashes out) their balance to a bank account.
  4. A user reads their balance — available and total — and history; a transaction can be reversed via a compensating entry.

Top-up and withdrawal look symmetric and are not: a withdrawal debits money you already hold; a top-up credits money you do not have yet — it can be clawed back days later, which is the wallet’s hardest correctness seam.

Non-functional

No double-spend, stated as a contract: a balance can never be spent twice, and money is never created or destroyed. The single most important requirement — say it unprompted.

Strong consistency on the balance path, eventual elsewhere. The ledger and every debit agree at every instant; history feeds, analytics, and notifications may lag.

Availability vs consistency — for money, choose consistency. On a partition, reject the transfer; never serve or mutate a divergent balance. Five-nines is the aspiration, but conflicts fail closed.

Latency: know which leg you are timing. A P2P transfer never leaves your system, so it should feel instant (~200ms p99). Top-up and withdrawal inherit bank-rail latency (seconds to days) and are asynchronous by nature. Distinguishing the two is a senior signal.

Provisional-credit risk: card and ACH top-ups can be reversed days later. Model available vs total balance and a float/reserve. Frame as money-you-fronted, not a compliance detail.

Auditability & regulation: immutable multi-year trail of every movement; reversals are entries, never deletions. Aside: AML and velocity limits gate transfers by KYC tier — staff-level nuance.

Scope cuts to state explicitly

Interest / yield on balances; the KYC onboarding pipeline (assume a tier already sits on the account); card issuing; fraud-ML internals (place a risk service, skip its model); full multi-currency FX treasury (hold a balance per currency; FX is a deep dive); crypto / stablecoin rails.

Checkpoint 02

Say the contract before the boxes: “a balance can never be spent twice, and money is never created or destroyed.” Everything below serves that sentence.

03 · Back-of-envelope · minutes 8–13

Numbers you should be able to re-derive

These are fixed engine reference figures, not a calculator. Each result follows from the arithmetic next to it.

Quantity Derivation Result
Average TPS 1B / 86,400s ~11,600 TPS
Peak TPS ~3× daily average; festival days ~10× ~35K / ~116K TPS
Ledger entries ≥2 per transfer (debit + credit); fees / FX legs → 3–4 ~2–4B rows/day
Storage ~300B/entry → ~1 TB/day → ~365 TB/yr, multi-year retention ~2.5+ PB
Balance reads every debit checks available balance; hot merchant / promo accounts ~cache + sub-accounts
Idempotency keys ~1B keys/day at 24h TTL; ~256B/key ~250 GB in Redis

What the numbers mean — the conclusions, not the arithmetic

  • Sharding by account_id is mandatory — driven by petabyte retention and 100K+ festival peaks — and that is exactly what turns an atomic transfer into a distributed problem. This is the whole interview.
  • One transfer is a handful of internal ops (claim, limits, hold, two ledger writes, outbox) → ~50–100K internal ops/sec at peak.
  • CP over AP: on a partition, reject the transfer; never serve or mutate a divergent balance.
  • Retention is petabyte-scale → cold tiering + partitioning from day one; recent balances stay hot, old entries archive.
  • P2P dominates volume; external rails (top-up / withdrawal) are a minority of traffic but carry all of the provisional-credit risk.
Checkpoint 03

~11.6K TPS average, ~35K peak, 100K+ on festival days. The number that matters is not the rate — it is that sharding by account is forced, which is what makes atomic transfer hard.

04 · Core entities · minutes 13–17

Five entities and one state machine

Account (Wallet)

account_id, user_id, currency, kyc_tier, status (active / frozen / closed). Balance is NOT a column — it is derived from the ledger and cached. account_id is the shard key that decides where this wallet’s ledger lives.

LedgerEntry

Append-only double-entry: every movement is ≥1 debit + 1 credit summing to zero, tagged with transfer_id, account_id, direction, amount in minor units. No UPDATE, no DELETE; corrections are reversing entries. System-wide invariant: the sum of all entries is always zero.

Transaction (Transfer)

transfer_id, type (topup / transfer / payment / withdrawal / reversal), source + destination account, amount, currency, idempotency_key, status as a state machine.

Hold (Reservation)

hold_id, account_id, amount, expiry, status. Reduces available balance without moving money — this is what reserves the sender’s funds during a cross-shard transfer or a pending withdrawal. available = total − active holds.

Event log

Append-only record of ALL state changes including non-financial: limit checks, risk scores, hold placements, notification deliveries. Distinct from the ledger, which records money movements only — conflating them is a listed misconception.

The transfer state machine

step with ← → or click a state

failure exits

Entry — INITIATED

Request accepted; the atomic idempotency claim is taken and the transfer row is written.

Exit

Funds validated and reserved → PENDING.

What candidates get wrong here

Debiting with a bare UPDATE balance = balance − amount — a mutable balance corrupts under concurrency and leaves no audit trail.

Recurring follow-ups: “the same balance backs two transfers at once?” (atomic conditional debit) and “a top-up is charged back after the money has been cashed out?” (provisional credit → negative balance → recovery).

Checkpoint 04

The ledger is append-only and every balance is derived; available = total − holds. A mutable balance column is the fastest no-hire in this question.

05 · API design · minutes 17–20

Three endpoints and one header that carries the interview

POST /v1/transfers              Idempotency-Key: <client-generated>
POST /v1/topups
POST /v1/withdrawals
GET  /v1/accounts/:id/balance   → { available, total, holds }

User identity comes from the auth token — never the source account_id in the body. Amounts in minor units with an explicit currency. Balance responses return available and total separately — a client that shows total as spendable invites overdrafts and disputes. Transfers, top-ups, and withdrawals all take idempotency keys; a reversal is POST /v1/transfers/:id/reversals, never DELETE.

Idempotency-Key semantics — all four cases

Pick a case: what does the server do?

New key

Process normally; store key → response (24h TTL).

Show all four cases as a table
Case Behavior
New key Process normally; store key → response (24h TTL).
Retry: same key + same params Replay the stored original response — no second movement.
Same key, request still in flight Return a conflict (409) or block briefly — never process twice, never go silent.
Same key, different params Reject as a validation error — the client has a bug; guessing intent moves money wrongly.

Keys are scoped per user — cross-account collisions must be impossible.

Notifications & webhooks — the five decisions that matter:

  • Typed events: transfer.posted / topup.cleared / withdrawal.settled / transfer.reversed …
  • HMAC-signed — unsigned callbacks invite a spoofed “topup.cleared”.
  • At-least-once with exponential backoff + DLQ; the receiver dedupes by event ID.
  • No ordering promise across a user’s events — the payload carries a sequence number.
  • Version from day one — a wallet’s clients live in mobile apps you cannot force-upgrade.
Checkpoint 05

One header — Idempotency-Key — and four cases; balance responses always separate available from total. Get those and the API section is won.

06 · High-level design · minutes 20–30

The living architecture

One architecture diagram, left to right — the shape you’re drawing toward on the whiteboard. Play a P2P transfer through the happy path, or click any component to see what interviewers listen for there.

synchronous money path durable state async shell external
SYNCHRONOUS TRANSFER PATHEXTERNALEXTERNALDURABLE STATE — ONE ACID TXN PER SHARDASYNC SHELLPOST /transferauthedatomic claimlimitstransfertop-up / cash-outone ACID txnentriesoutboxpublisheventseventsClient / Wallet Appmobile · SDKAPI Gatewayauth · rate limitsWallet Servicestateless orchestratorTransfer Coordinatorsame-shard txn · cross-shard sagaBank / Card RailsACH · card · UPIIdempotency StoreRedis · 24h TTLRisk & Limitsvelocity · KYC · AMLLedger Servicedouble-entry · shardedSharded SQLaccounts · ledger · holds · outboxOutbox RelayCDC / pollerKafkapartitioned by account_idNotification Deliverypush · webhook · DLQReconciliationinvariant + settlement
trace · P2P transfer— / 6
Every component as plain text (all inspector notes)
Client / Wallet App — external
Submits transfers, top-ups, and withdrawals with a client-generated Idempotency-Key. User identity comes from the auth token — never the source account_id in the body. Amounts in minor units. Reads balance as available and total separately, and shows available as spendable. Reversals are POST, never DELETE — a reversal is a new financial event. Failure mode: Forbidden flows: the client must never write the ledger directly, must not call an external rail directly, and must never treat total balance as spendable.
API Gateway — synchronous money path
Authenticates the user token and applies per-user rate limits. Every request enters here — the client hits the gateway / LB, never a service directly. Per-user velocity limiting starts here; a compromised account cannot drain a balance faster than the rate limit and the risk service allow. Failure mode: The gateway should not bypass the wallet service to query balances directly.
Wallet Service — synchronous money path
Stateless orchestrator: atomic idempotency claim, risk / limits gate, then hands the money movement to the transfer coordinator. Owns no balance state itself. Orchestration over choreography for money. Nothing touches the ledger until the idempotency claim is won and limits pass. Failure mode: The separating probe: two concurrent transfers on one balance. The debit must be an atomic conditional write — read-then-update has a TOCTOU race two retries will find.
Idempotency Store — synchronous money path
Key → outcome mapping with 24h TTL. The claim must be atomic (Redis SETNX / unique constraint) — read-then-write has a TOCTOU race two concurrent retries will find. Sized: ~1B keys/day × ~256B ≈ 250 GB, more with cached responses. Concurrent same-key returns 409, not silence. Failure mode: Redis down together with a shard primary → fail closed, reject transfers rather than risk an unprotected double-spend.
Risk & Limits — synchronous money path
A synchronous gating step before funds are reserved: per-user velocity limits, KYC-tier ceilings, and mule / AML checks, inside the latency budget. Calling out risk / limits as its own step with its own budget is a rubric nice-to-have. Scope cut: place the service, skip the ML internals. Failure mode: A rejected transfer must never touch the ledger — post-movement risk checks mean clawing money back.
Transfer Coordinator — synchronous money path
Owns the money movement. Same shard: ONE ACID transaction writes the debit + credit + hold release + outbox event. Cross-shard: a persisted saga — reserve on the source, credit the destination, confirm the source. Resolves each account_id to its shard. The order is the correctness: reserve-then-credit-then-confirm, never credit-then-debit. Saga state is persisted, so recovery survives coordinator crashes. Failure mode: The staff probe: the credit lands on shard 12 while the source confirm fails on shard 7. Answer: a recovery worker completes or compensates the persisted saga; the hold bounds the worst case to funds briefly reserved, never lost or created.
Bank / Card Rails — external
External funding networks for top-up (money in) and withdrawal (money out) only. Internal P2P transfers never touch them — that is why they are instant and cheap. A top-up over card / ACH is provisional: it can be reversed days later. Model available vs total balance and a float / reserve to absorb the risk. Failure mode: “A top-up is instantly spendable money” — you fronted it; if it is charged back after the user cashes out, the account goes negative and you must recover it.
Ledger Service — durable state
Every movement is ≥1 debit + 1 credit summing to zero. No UPDATE, no DELETE; corrections are reversing entries. Balances are derived, never source-of-truth. Sharded by account_id. The system-wide invariant is Σ of all entries = 0, asserted continuously. Distinct from the event log, which records all state changes — conflating the two is a listed misconception. Failure mode: Hot account: 20K credits/sec against one account row collapses on lock contention. Fix: sub-account sharding + batched writes; balance reads sum sub-accounts (cached).
Sharded SQL — durable state
ACID store, sharded by account_id, holding accounts, the append-only ledger, holds, and outbox events — a same-shard transfer writes them in one transaction. ACID is a constraint, not a preference. Synchronous replication on money tables; RPO for balances is zero. Sharding is forced by storage + peak — and it is why a transfer can’t assume one transaction. Failure mode: Async replication with lag loses committed transfers on failover; a divergent replica read serves a balance that isn’t real.
Outbox Relay — async shell
Relays outbox rows to Kafka. The event row is written in the same ACID transaction as the ledger movement. This is what makes events trustworthy: no event exists for a transfer that never committed. Failure mode: “Publish to Kafka then write the ledger” — ghost credits for transfers that never committed.
Kafka — async shell
Async fan-out to notification delivery and reconciliation feeds. Partition by account_id + sequence numbers + idempotent consumers. Failure mode: Kafka guarantees per-partition ordering only — random keys reorder an account’s events.
Notification Delivery — async shell
Typed events (transfer.posted / topup.cleared / withdrawal.settled …), HMAC-signed, at-least-once with backoff + DLQ. Notifies payer, payee, and any merchant. Receiver dedupes by event ID; no ordering promise — the payload carries a sequence number. Failure mode: Fire-and-forget delivery means users silently missing “you were paid” — the confirmation is the product. Unsigned callbacks invite spoofed events.
Reconciliation — async shell
Two jobs: assert the internal invariant (Σ ledger = 0 across all shards) continuously, and three-way match top-up / withdrawal legs against bank + card-network settlement files. Mismatch categories: matched / missing-ours / missing-theirs / amount drift. A nonzero ledger sum is a leak — a finding to investigate, never a number to overwrite. Failure mode: It doubles as the detection layer for anything that slips past the atomic debit. Money unaccounted for: bucket mismatches, check settlement cutoff timing, replay ledger events for the residual.

Whiteboard minimum

A passing diagram has all of these.

  • Client / Wallet app
  • API gateway with auth + rate limiting
  • Wallet service (stateless orchestrator)
  • Sharded primary store (accounts + ledger + holds)
  • Double-entry ledger service
  • Message queue (Kafka/SQS) for async fan-out
  • External rail integration (bank / card / UPI)
  • Idempotency store

Staff+ additions

Unprompted.

  • Transfer coordinator for cross-shard atomic transfers (saga)
  • Risk / limits / AML service (velocity, KYC tiers)
  • Hold manager + expiry sweeper
  • Reconciliation with the Σ-ledger-=-0 invariant
  • Notification delivery service
  • Monitoring / alerting / audit trail

Required flows

  • Client must hit gateway / LB, never direct to services
  • Gateway routes to the wallet service
  • Wallet service checks risk / limits before reserving funds
  • Coordinator reserves funds on the source before crediting the destination
  • Same-shard transfer writes debit + credit + outbox in one ACID transaction
  • Wallet service publishes events via the outbox, never directly to Kafka
  • External rails are called only for top-up and withdrawal
  • Balance reads derive from the ledger (+ holds), cached

Forbidden flows — penalized on sight

  • A mutable balance column as source of truth
  • Client or any service writing the ledger directly
  • Debiting the source without an atomic sufficiency check
  • Crediting the destination before the source is reserved (money creation)
  • Publishing to Kafka before the ledger commits (ghost credits)
  • Treating a P2P transfer as if it must call an external rail

Critical paths

  • Every debit passes an atomic sufficiency check against available balance (wallet → coordinator → ledger, one step)
  • Every transfer produces balanced ledger entries that keep Σ = 0 (coordinator → ledger → store)
  • Cross-shard transfers reserve on the source before crediting the destination (coordinator saga)

The request path, narrated — this is your whiteboard script

  1. The gateway authenticates the user token and applies per-user rate limits — every request enters here.
  2. The wallet service — stateless — takes the atomic idempotency claim. Nothing moves until the claim is won.
  3. Risk & limits run synchronously inside the budget: velocity, KYC-tier ceilings, mule / AML checks. A rejected transfer never touches the ledger.
  4. The coordinator reserves funds on the source with a hold — the sufficiency check and the reservation are one atomic step, so two concurrent transfers can’t both win.
  5. Same shard, ONE ACID transaction posts the debit + credit + hold release + outbox event. Cross shard, a persisted saga credits the destination, then confirms the source; compensation releases the hold on failure.
  6. The outbox relay publishes to Kafka; notification delivery and reconciliation fan out from there — off the money path.

The store is sharded by account_id — SQL primaries with synchronous replication on the money tables; RPO for balances is zero. Sharding is forced by petabyte retention and 100K+ festival peaks, and it is precisely why a transfer between two accounts cannot assume a single ACID transaction.

Checkpoint 06

Same shard, one ACID transaction: debit + credit + outbox, Σ = 0. Different shards, reserve-then-credit-then-confirm as a persisted saga. Async begins only after the commit.

07 · Deep dives · minutes 30–45

The four that decide the interview

1 — Double-spend under concurrency

The probe

“Two transfers hit the same $100 balance at the same instant, each for $80. Walk me through why only one wins.”

Your answer, in order

  1. Diagnose out loud: “read the balance, then debit if sufficient” has a TOCTOU race — both reads see $100, both pass the check, both debit. That is the double-spend.
  2. The fix is that the check and the debit are one atomic step: a conditional write under a row lock (SELECT … FOR UPDATE), serializable isolation, or a compare-and-swap on a version — the second transfer either blocks then fails on insufficient funds, or retries.
  3. A cleaner model at scale is single-writer-per-account: all movements for an account_id are serialized on its shard, so two debits can never interleave.
  4. The invariant that makes it checkable: available = total − holds, and no debit may drive available below zero.

Staff extra — name the race before the fix — the diagnosis is most of the credit. Reconciliation (Σ ledger = 0) is the detection layer for anything that still slips through.

2 — Atomic transfer across shards

The probe

“Sender is on shard 7, receiver on shard 12. Your credit to shard 12 succeeded, but the debit’s confirm on shard 7 failed. What happens to the money?”

Your answer, in order

  1. Name why one transaction is impossible: the two accounts live on different shards, so there is no single ACID boundary. Reject 2PC for the right reasons — it blocks on coordinator failure and kills latency and availability at this scale.
  2. Use a saga with a strict order: reserve (hold) on the source, credit the destination, then confirm the source. A hold reserves the funds before the credit, so money is never created.
  3. Saga state is persisted and keyed by transfer_id — recovery survives coordinator crashes, and every step is idempotent.
  4. For the exact failure asked: the credit landed, the source confirm didn’t — a recovery worker replays the confirm (funds are already reserved, so no double-spend, no loss); a stuck-saga sweeper escalates on TTL.

Staff extra — the order is the correctness: reserve-then-credit-then-confirm, never credit-then-debit. The worst case is money briefly held, bounded by the hold’s TTL — never money lost or made.

3 — Hot account / hot shard

The probe

“A merchant wallet takes 20K credits/sec, and a promo pays $10 from one company account to a million users. Ledger latency blew past the SLA. Fix it.”

Your answer, in order

  1. Diagnose before fixing: every entry contends for one account row — this is lock contention, not capacity. More connections and bigger hardware change nothing.
  2. Shard the hot account into sub-accounts (balance buckets) so writes spread across rows; balance reads sum the sub-accounts, cached.
  3. Batch the ledger writes for the hot account.
  4. The promo is a hot debit from one source — worse than a hot credit, because every debit re-checks sufficiency. Pre-fund a disbursement pool with sub-accounts, or queue and batch the debits.

Staff extra — a hot debit is harder than a hot credit: the sufficiency check serializes on the source. Say “contention, not capacity” before naming any fix.

4 — Provisional credit & reconciliation

The probe

“A user tops up $500 from a card, sends it to a friend who cashes out, and three days later the card top-up is charged back. Now what?”

Your answer, in order

  1. This is the wallet’s signature seam: you fronted money that has now left the system. Prevention first — model available vs total so an uncleared top-up sits in total but is not spendable until it clears (a clearing period), or accept risk-based instant availability backed by a float / reserve.
  2. After the fact, the reversal is a compensating transfer with opposite entries, linked to the original — never a deletion. It can legitimately drive the account negative.
  3. A negative balance is a recovery problem, not a bug to hide: dunning, offset against future top-ups, or write-off against the reserve.
  4. Reconciliation runs two jobs — assert Σ ledger = 0 across all shards continuously (a nonzero sum is a leak), and three-way match the external legs against bank + card-network settlement files.

Staff extra — the internal invariant (money conserved) and the external match (settlement agrees) are different checks — a disagreement is a finding to investigate, never a settlement file to blindly overwrite your ledger with.

Scenario player

The same system under attack. What breaks, what the naive design does wrong, what the correct mechanism does.

critical

What breaks

Two transfers hit the same $100 balance at once, each for $80. Both should not succeed — but a check-then-debit design lets them.

What the naive design does

“Read the balance, and if it’s enough, subtract.” Both reads see $100, both pass, both write — the balance goes to −$60 and money was created.

The correct mechanism

  1. The sufficiency check and the debit are one atomic write — row lock (SELECT … FOR UPDATE), serializable isolation, or a version CAS.
  2. The transfers serialize on the account; the second fails cleanly on insufficient funds.
  3. Single-writer-per-shard makes interleaving impossible in the first place.
  4. Reconciliation (Σ ledger = 0) is the detection layer for anything that slips through.

Components involved

Wallet ServiceTransfer CoordinatorLedger ServiceSharded SQL

Rapid-fire

  • Shard by hash of account_id, never by geography or signup date (permanent hot shard).
  • Multi-currency = minor units + currency code; a wallet holds a balance per currency, never a blended one; FX snapshots the rate at transfer time.
  • Correlated failure (Redis idempotency + a shard primary together) → fail closed, reject transfers rather than risk an unprotected double-spend.
  • Load-test with synthetic accounts and traffic replay; assert Σ ledger = 0 continuously, not just nightly.
Checkpoint 07

Every deep dive is the same test: money is conserved. Name the seam, give the mechanism, bound the worst case to “briefly held,” never “lost or created.”

08 · Traps & misconceptions

Ten claims that fail this interview

Each claim below is wrong. Read the claim, decide why it fails — then open it. These mirror the misconception patterns the simulator probes.

01 “a wallet is just a balance column you increment and decrement”

Why it fails — A mutable balance corrupts under concurrency, keeps no audit trail, and cannot be reconciled — the named red flag in the rubric.

Instead — Append-only double-entry ledger; every balance derived / cached-with-verification; corrections via reversing entries.

02 “check the balance, then debit if it is sufficient”

Why it fails — Two concurrent debits both read the old balance, both pass the check, both write — a double-spend, and the balance goes negative.

Instead — The sufficiency check and the debit are one atomic step: row lock, serializable isolation, or a version CAS.

03 “a transfer is one UPDATE for the sender and one for the receiver”

Why it fails — Two separate writes aren’t atomic; a crash between them creates or destroys money.

Instead — Debit + credit in one ACID transaction (same shard), or a persisted saga with holds (cross-shard).

04 “just use distributed transactions (2PC) across shards”

Why it fails — 2PC blocks on coordinator failure and kills latency and availability at wallet scale.

Instead — A saga: reserve on the source, credit the destination, confirm the source, compensate on failure — state persisted.

05 “available balance equals total balance”

Why it fails — Ignoring holds and uncleared funds lets reserved or provisional money be spent a second time.

Instead — available = total − holds − uncleared; enforce it on every debit.

06 “a top-up is instantly spendable money”

Why it fails — Card / ACH top-ups are provisional — reversible for days. You fronted money that can be clawed back.

Instead — Model a clearing period (uncleared sits in total, not available) or a risk-based float with reserves.

07 “eventual consistency is fine for balances”

Why it fails — Temporary disagreement about a balance is a live double-spend, not a display lag.

Instead — Strong consistency (CP) on the money path; on a partition, reject — never serve a divergent balance.

08 “reverse a transfer by deleting its ledger rows or subtracting from the balance”

Why it fails — Destroys the audit trail; regulation requires immutable multi-year records including reversals, and the account can legitimately go negative.

Instead — A compensating reversing entry linked to the original; drive the account negative and recover it.

09 “one big Postgres is fine — sharding is optional”

Why it fails — Petabyte-scale ledgers and 100K+ festival peaks can’t live on one node; sharding by account is forced.

Instead — Shard by account_id from day one — and design the cross-shard transfer as a first-class saga, because that’s what sharding creates.

10 “publish the event to Kafka, then write the ledger”

Why it fails — Ghost credits for transfers that never committed.

Instead — Transactional outbox: the event row in the same ACID txn as the movement, relayed via CDC / poller.

Failure drills — would you catch it?

Six attack scenarios from the engine, tagged by severity. Commit to an answer before opening the mechanism.

critical Two transfers hit the same balance at the same instant, each for most of it. A check-then-debit design lets both through and the balance goes negative.

The mechanism — Make the sufficiency check and the debit one atomic write (row lock / serializable / version CAS), or serialize all writes for an account on its shard. The loser fails cleanly on insufficient funds.

critical A cross-shard transfer credits the destination shard, but the source confirm fails and the coordinator crashes.

The mechanism — Reserve funds on the source before crediting; persist saga state keyed by transfer_id; a recovery worker replays idempotent steps; a stuck-saga sweeper escalates on TTL. The hold bounds the worst case.

critical A user tops up from a card, sends it on, the recipient cashes out — then the card top-up is charged back.

The mechanism — Model available vs total so uncleared top-ups aren’t spendable (or back instant availability with a reserve). The chargeback posts a compensating reversal; the account can go negative and enters recovery.

high A promo disburses from one company account to a million users; every debit contends for the same source row and latency blows past the SLA.

The mechanism — Pre-fund a disbursement pool sharded into sub-accounts + batched writes; balance reads sum sub-accounts (cached). This is lock contention, not capacity — a hot debit is worse than a hot credit.

high Nightly reconciliation finds the sum of all ledger entries is no longer zero — money is unaccounted for.

The mechanism — Σ ledger = 0 is a hard invariant. Bucket mismatches by category, check settlement cutoff timing, replay ledger events for the residual. A mismatch is a finding, never a number to force.

critical A transfer request times out; the client retries with the same amount but a fresh idempotency key. The recipient is paid twice.

The mechanism — Idempotency keys required and documented — a retried transfer must never move money twice. Without the key, sender + amount matching is ambiguous; reconciliation is the detection layer for what slips through.

09 · How this gets scored

The rubric, verbatim

Scored as: “Staff+ engineer on a wallet / stored-value or payments-platform team.”

Must-haves

  • Designs a double-entry ledger (every movement = balanced debit + credit) and explains why a single mutable ‘balance’ column is wrong.
  • Prevents double-spend with an atomic conditional debit (row lock / serializable / CAS / single-writer), names the TOCTOU race in check-then-update.
  • Makes a transfer atomic: same-shard = one ACID transaction; cross-shard = a saga with holds (reserve → credit → confirm) + compensation, and rejects naive 2PC for the right reasons.
  • Separates available vs total balance with holds, and names the transfer state machine (INITIATED → PENDING → POSTED → SETTLED) and what triggers each transition.
  • Sizes throughput (~11.6K TPS avg, ~35K peak, 100K+ festival), ledger growth, multi-year retention — and concludes sharding by account_id.

Nice-to-haves

  • Addresses provisional-credit / float risk on reversible top-ups: a clearing period or a funded reserve.
  • Proposes reconciliation as both the internal invariant (Σ ledger = 0) and an external settlement match, and explains why it’s not optional.
  • Calls out risk / limits / AML as a separate gating step before funds are reserved, with its own latency budget.
  • Treats idempotency keys as first-class: client-supplied, TTL’d, replay returns the original response — retries never double-move money.

Red flags

  • A single mutable ‘balance’ column instead of a ledger — corrupts under any concurrent write or partial failure.
  • Check-then-debit with no atomicity — two concurrent transfers double-spend the same balance.
  • Crediting the destination before the source is debited, or two separate transactions for a transfer — money created / destroyed on crash.
  • Treating a card / ACH top-up as final, spendable money — a provisional-credit loss waiting to happen.

The verdict ladder

Strong hire Drives ledger + atomic debit + cross-shard saga, separates available/total, treats top-ups as provisional, sizes + shards — unprompted.
Hire Picks ledger + atomic debit correctly, handles the cross-shard transfer with light prompting, sizes the system.
Lean hire Names the ledger but shallow on concurrency control OR the cross-shard transfer OR holds.
Lean no-hire Missing the double-spend story OR proposes a balance column over a ledger OR no atomic transfer.
No hire Single balance column, OR check-then-debit with no atomicity, OR credit-before-debit transfers.
Strong no-hire Fundamental misunderstanding — treats a wallet as generic CRUD with balance as a number, not money in a ledger.

Depth-ladder explorer

Six topics, four levels each — verbatim from the depth rubric. Slide L1→L4 and watch the same answer upgrade.

L1L4L2

probed in: requirements, high-level design, deep dive

L1 — surface

Mentions preventing a balance from being spent twice without a concrete mechanism

L2

Proposes locking the account row or a transaction before debiting

L3

Makes the sufficiency check and the debit one atomic step (SELECT … FOR UPDATE / serializable / version CAS), names the TOCTOU race in check-then-update, and enforces available = total − holds

L4 — staff+

Compares pessimistic locking vs optimistic CAS vs single-writer-per-shard under contention, quantifies retry / abort rates at peak, and reasons about isolation-level anomalies (write skew) on the money path

This question rewards engineers who model a wallet as a ledger, not a balance. A Strong Hire makes the debit atomic, keeps money conserved across shards with a reserve-then-credit saga, separates available from total balance, and treats a top-up as provisional until it clears — unprompted. A Lean Hire names the ledger but is shallow on concurrency control or the cross-shard transfer. A No Hire increments a balance column. Weight reliability_thinking and engineering_judgment most heavily — every shortcut here is either a double-spend or money that vanishes.

— grading guidance, digital_wallet.judge_rubric.yaml

10 · FAQ

Common questions

How hard is the digital wallet interview question?
Among the harder money questions — the failure mode is silent value loss: a race between two debits, a cross-shard transfer that half-commits, a top-up clawed back after it’s been spent. Scale (~11.6K TPS) is real but secondary; interviewers grade imprecision here as either a double-spend or money created from nothing.
What should I cover in the first ten minutes?
Requirements only: the multi-step transfer flow, no-double-spend stated as a contract, explicit scale (which forces sharding), and the strong-vs-eventual consistency line. Reaching for Kafka before you’ve said “balance is derived from a ledger” reads as a red flag.
What is the single most common mistake?
A mutable balance column updated on every transfer — it corrupts under concurrent writes, loses the audit trail, and can’t be reconciled. Its twin: “check the balance, then debit,” which double-spends the moment two requests race.
Do I need distributed transactions or 2PC?
You need to reject 2PC for the right reasons (coordinator blocking, latency, availability at scale) and reach for a saga instead: reserve funds on the source, credit the destination, confirm the source, compensate on failure. Naming 2PC and moving on is a miss.
Can I just use one big Postgres?
For a few thousand TPS, arguably — but multi-year petabyte ledgers and 100K+ festival peaks force sharding by account, and that’s the whole point: once accounts are sharded, a transfer between two of them stops being one ACID transaction.
How should I budget 45 minutes?
Roughly: 8 min requirements + scope, 5 estimation, 7 entities + API, 10 high-level design, 15 deep dives — and the deep dives (double-spend, cross-shard transfer, hot account, provisional credit) are where hire / no-hire separates.

You’ve read the answer. Could you defend it?

Every candidate who fails this question has read a page like this one. The interview isn’t recall — it’s 45 minutes of follow-ups against your specific design, live. The simulator asks this exact question, probes the same double-spend and cross-shard seams, and scores you on the same rubric — then tells you, honestly, where you actually stand.

Free 15-min session · No card · AI interviewer, honest verdict