03. Digital Wallet — Interview Q&A¶
~15 min read · Part 4 of 4 (Overview → HLD → LLD → Q&A)
These are the questions an interviewer asks once the ledger is on the board. Each answer is the strong version, followed by the wrong answer that quietly sinks candidates.
Q1. Priya has $100 and fires two $70 transfers at the same instant. Walk me through why exactly one succeeds and the balance never goes negative.
Both transfers run a guarded update — UPDATE balance SET available = available - 7000 WHERE account_id = :priya AND available >= 7000 — inside its own transaction. The database serializes them on the row lock: whichever acquires the lock first (call it T1) sees 10000, passes the guard, writes 3000, and commits; the other (T2) blocks until T1 commits, then re-checks the guard against the now-current 3000, finds 3000 < 7000, matches zero rows, and is rejected with 409 insufficient_funds. The balance never goes negative because the check and the deduction are the same statement — there is no moment where T2 subtracts from a stale 10000. Exactly one transfer commits and the balance settles at $30.
Common wrong answer to avoid: "Read the balance, check it's ≥ $70, then subtract." That read-then-write has a race: both transfers read $100, both pass the check, both write $30, and $140 leaves a $100 account — a $40 overdraft.
Q2. Why model the balance as a ledger at all? Why not just an integer column you increment and decrement?
Because the balance is a derived fact, and the durable truth must be the history that produced it. An append-only, double-entry ledger gives you an immutable audit trail for disputes and regulators, makes every movement reversible (a refund is a new balanced entry, not an edit), lets you recompute a corrupted balance by replaying entries, and enforces conservation of money because every transfer's two legs sum to zero. The materialized balance row exists only as a fast, authoritative cache of that ledger, updated in the same transaction. A wallet is not a CRUD app on a balance column; it is an append-only ledger with a derived balance.
Common wrong answer to avoid: "Just keep a balance column and log changes for analytics." A separate log that isn't the source of truth drifts from the column, and when they disagree you have no principled way to know which is right — or where the money went.
Q3. Consistency or availability for balances — which do you sacrifice, and why? Consistency wins; this is a CP system. A stale balance that permits an overdraft is real money lost and a manual reconciliation, whereas a briefly unavailable payment is an annoyance the user retries. So under a partition that threatens correctness, the debit path refuses the write rather than accepting it optimistically. Reads for display can be eventually consistent and cached, but the read that decides a payment always goes to the row under lock inside the transaction — the cache is never consulted for a debit decision. Common wrong answer to avoid: "Make it highly available and reconcile discrepancies later." Reconciling invented money after the fact is exactly the failure a wallet must not have; you cannot un-send funds a fraudster already withdrew.
Q4. A transfer touches two accounts. How do you make debit and credit atomic, and what changes when they're on different shards?
When both accounts share a shard, the whole transfer — guarded debit, credit, and both immutable ledger entries — is one local ACID transaction, so it commits or rolls back as a unit. When they cross shards there is no cheap single transaction, so you use reserve-then-commit: place a hold on the sender's shard (atomically moving available → held, which enforces the no-overdraft guard up front), credit the receiver's shard, then capture the hold to finalize the debit. If the credit fails, you release the hold and the sender is made whole; an expiry sweeper releases any hold a crashed coordinator abandoned. You keep related accounts co-located so most transfers stay on the fast single-shard path.
Common wrong answer to avoid: "Wrap both shards in a distributed transaction / two-phase commit and move on." 2PC across shards is a coordinator-blocking, latency-heavy hammer; the hold/settle saga achieves atomicity with reversible steps and no global lock, which is why real systems use it.
Q5. Where does fraud checking sit — inline or async — and how do you keep it from slowing payments? Both, at two speeds. A synchronous gate runs cheap deterministic checks (velocity limits, blocklists, device and geo reputation, amount thresholds) in under ~20 ms and returns allow/deny/challenge before the debit commits, because you cannot let a stolen-account drain complete and reconcile afterward. A heavier asynchronous scorer consumes the committed-ledger event stream, runs the expensive ML and graph models that are too slow for the hot path, and — using the ledger's reversibility — freezes the account or posts a compensating reversal within seconds when it flags something the fast rules missed. Fast rules protect latency; slow models provide depth; the immutable ledger is the safety net that makes after-the-fact clawback possible. Common wrong answer to avoid: "Run the full ML risk model synchronously on every payment." A 100–300 ms model inline blows the latency budget and makes the fraud service a hard dependency that takes down all payments when it slows.
Q6. How do you handle a client that retries the same transfer after a network timeout?
Require an idempotency key on the request and enforce it with a UNIQUE constraint in the same transactional store as the money movement. The first attempt inserts the transfer row and commits the money and the key together; a retry collides on the unique key and returns the stored result instead of moving money again. An in-flight duplicate (status pending) gets a retry-later so two attempts never both proceed. Because dedup and the ledger write share one transaction, there is no window where money moved but the key wasn't recorded.
Common wrong answer to avoid: "Check if a matching transfer already exists, then insert if not." That check-then-insert is a race — two retries both see "no existing transfer" and both move money. The uniqueness must be enforced atomically by the store.
Q7. What's the throughput limit of this design, and where does it actually bind? Aggregate throughput isn't the constraint — 4,600 ledger writes/second at peak spreads across shards by account, and you add shards to scale it. The real ceiling is per-account write serialization: one account's balance row can only be updated one transaction at a time, because each update takes its row lock. For ordinary users that's far above their real rate. It bites on a hot account — a flash-sale merchant taking thousands of payments a second — where transactions queue behind the single row. You relieve it by splitting that merchant's balance into N sub-accounts (a sharded counter); payments hit a random sub-account and the true balance is their sum, reconciled continuously. Common wrong answer to avoid: "It scales fine because total TPS is only a few thousand." That hides the per-account hot spot; the average looks healthy at 2,300/second while one merchant's account serializes and times out.
Q8. Money moved out of the sender but the receiver's credit failed on a cross-shard transfer. What's the state, and how do you recover?
There's no torn state, because the flow never finalizes the sender before the receiver is credited. The sequence is hold → credit → capture: the sender's funds sit in held (out of available, so unspendable) until the receiver's credit confirms. If the credit fails, the coordinator releases the hold and the sender's available is restored — fully auditable, since nothing was mutated, only a hold placed and released. If the coordinator itself crashed mid-saga, the expiry sweeper releases the stale hold at its deadline. The sender is never permanently debited for a credit that didn't happen.
Common wrong answer to avoid: "Debit the sender, then credit the receiver, and if the credit fails, refund the sender." Debiting first means a crash between the two steps leaves the sender out real money with no reservation to unwind — the exact partial-transfer failure the hold prevents.
Q9. How do you issue a refund or reverse a fraudulent transfer without corrupting the ledger?
Never edit or delete the original entries. Post a new balanced pair of entries — credit back the original sender, debit the original receiver — carrying the original transfer's lineage and marking the transfer reversed. The balance moves back through the normal guarded-update path, and the history now shows both the charge and its reversal, which is exactly what a dispute or audit needs. The ledger stays append-only, so its integrity as the source of truth is never in question.
Common wrong answer to avoid: "Delete the transaction row and adjust the balance." A deleted entry destroys the audit trail, breaks reconciliation (the ledger no longer sums to the balance), and leaves you unable to prove what happened when the customer or regulator asks.
Q10. How do you know the balances are actually correct over time?
A reconciler periodically re-sums every account's ledger_entry rows and compares the total to that account's materialized available + held. A nonzero delta means drift — a bug wrote one without the other — so the account is frozen and the ledger, being the immutable source of truth, recomputes the correct balance. Each entry's balance_after snapshot pinpoints the exact entry where the drift began. This reconciliation delta is the metric an operator watches first, because in a wallet a silent mismatch is unaccounted money.
Common wrong answer to avoid: "We trust the balance column; it's updated transactionally so it can't drift." Transactions reduce drift but don't eliminate it across bugs, partial deploys, or manual data fixes; without continuous reconciliation you learn about a leak from an angry customer, not a graph.
Q11. Can you cache balances to serve 58,000 reads/second at peak?
Yes for the display balance — a short-TTL read-through cache in front of the balance row is fine, because a slightly stale number on the "your balance" screen is harmless and self-corrects. But the read that decides a payment must never come from cache; the guarded update reads the row directly under lock inside the transaction. So caching accelerates the "show my balance" path while the "can I pay" path stays strongly consistent. The two reads look identical but have opposite consistency requirements.
Common wrong answer to avoid: "Cache the balance and check it before debiting to save a DB read." A cached balance in the debit decision reintroduces the exact double-spend race — two payments read a stale cached $100 and both proceed.
Q12. Why integer minor units instead of a decimal or float amount?
Because floating point can't represent most decimal money values exactly — 0.1 + 0.2 isn't 0.3 — and those tiny errors accumulate into reconciliation failures where the ledger won't sum to the balance. Storing everything as integer cents (or paise) makes arithmetic exact and comparisons reliable. Where a division is unavoidable (an FX conversion, splitting a fee), you round with an explicit, documented policy and book the leftover remainder to a system account, so not a single unit disappears.
Common wrong answer to avoid: "Use a float/double for the amount; the error is tiny." Tiny per-operation errors are exactly what break a system whose defining invariant is that money sums to zero across billions of entries.
Deeper follow-ups¶
- How would you support multi-currency wallets and FX, keeping double-entry balanced when a transfer converts USD to INR (hint: the two legs are in different currencies — where does the rate and the rounding remainder book)?
- How do you enforce daily/velocity spending limits without adding a second hot row that serializes alongside the balance?
- A regulator asks for every account's exact balance as of midnight three months ago. How does the design answer that cheaply?
- How would you make the async fraud clawback safe against a race where the user withdraws the flagged funds to a bank before the scorer freezes the account?
- How do you migrate a hot merchant from a single balance row to sharded sub-accounts with zero downtime and no lost or double-counted payments?
- What consistency and latency do you accept if the wallet must serve users across regions, and how does the ledger shard placement follow from that?
How this round is scored¶
Interviewers use the wallet to see whether you treat correctness as the primary constraint and design the concurrency accordingly. The single strongest signal is handling the double-spend race the right way — fusing the check into a guarded, serialized write rather than reading the balance and deciding in application code; a candidate who reaches for SELECT then if has revealed the gap immediately. Seniority shows in the tradeoff discussions — CP over AP on balances, single-shard ACID versus the cross-shard hold/settle saga, synchronous fraud gate versus asynchronous scorer, exact balance versus sharded-counter throughput — where you name both sides and pick with a reason tied to money at risk. The double-entry-ledger instinct (immutable, reversible, reconciled) separates people who have built financial systems from those who have built CRUD apps. And doing the math out loud — per-account serialization as the real bottleneck, not aggregate TPS — is what pushes an answer from "correct" to "senior."