Digital Wallet System Design Interview: The Double-Entry Ledger Behind Every Balance
A wallet balance is one number that a user watches like a hawk, and it can never be wrong. A large consumer wallet handles tens of millions of top-ups, transfers, and payments a day, with peak bursts around paydays and festival sales. Uber's ledger platform alone indexes hundreds of billions of ledgers and processes tens of billions of financial transactions per quarter (Uber Engineering). Stripe reports its Ledger ingests roughly 5 billion money-movement events daily. The read side is even hotter: every app open, every checkout, every notification triggers a balance lookup, so balance reads outnumber money-moving writes by one to two orders of magnitude (estimate).
A digital wallet stores value for a user and lets them top up, withdraw, transfer, and pay while the balance stays exactly correct under retries, crashes, and concurrent access. The heart of the design is a double-entry ledger: money is never created or destroyed, it only moves between accounts, and every transaction is a balanced set of debits and credits that sum to zero. The money path is strongly consistent and ACID, because a lost or duplicated cent is a real financial loss and a compliance problem. Idempotency keys make client retries safe, so a top-up sent twice over a flaky network still moves money once. Holds let you reserve funds during a card authorization and capture or release them later without ever letting the user double-spend the same balance. The balance read path is high-volume and can be served from a cached or precomputed value, which is the central asymmetry you must design around. Multi-currency means every account carries a currency and you never mix them in one posting. Reconciliation runs continuously against banks and payment processors so the internal ledger and the outside world always agree.
Asked at: Asked at PayPal, Stripe, Block (Cash App and Square), Uber, Airbnb, Coinbase, Revolut, and Indian fintechs like Paytm, PhonePe, and Razorpay. It also shows up in general payments and banking rounds at Amazon and Google Pay.
Why this question is asked
This problem tests whether you actually understand money, not just CRUD. A candidate has to reach for the double-entry ledger on their own, defend strict consistency on the write path against the temptation to make everything eventually consistent, and reason precisely about idempotency, holds, and race conditions where two requests spend the same balance. It separates people who say 'just use a transaction' from people who can explain the isolation level, the idempotency key lifecycle, and why balances are derived from an append-only log rather than a mutable column. Interviewers also like it because the read-heavy balance query versus the strongly-consistent write path is a clean, concrete example of splitting a system by access pattern, and because reconciliation forces you to think about the boundary with the outside world where your guarantees end.
Requirements
Always clarify these in the first 5 minutes of the interview. Do not start drawing boxes until both lists are agreed.
Functional requirements
- Top up a wallet from an external funding source (card, bank transfer, UPI) and reflect the balance once the money has actually arrived.
- Withdraw or cash out from the wallet to a bank account, moving funds out of the ledger and into a pending payout.
- Transfer funds peer to peer between two wallets atomically, so the sender is debited and the receiver credited in one transaction.
- Pay a merchant from the wallet balance, with support for authorization (hold) then capture, plus refunds and reversals.
- Show the current available balance and a paginated, immutable transaction history for every account.
- Place, capture, partially capture, and release holds against a balance so reserved funds cannot be spent twice.
- Support multiple currencies per user, with each account and posting denominated in a single currency and explicit FX for cross-currency moves.
- Run fraud, velocity, and limit checks (per transaction, daily, and lifetime caps) before committing a money movement.
- Reconcile the internal ledger against external bank and processor statements and surface any breaks for investigation.
Non-functional requirements
- Correctness first: the ledger must never lose, duplicate, or invent money, and debits must always equal credits.
- Strong consistency and ACID on the write path, with serializable behavior for operations that read then modify a balance.
- Idempotency for every mutating API so client and network retries never double-charge or double-credit.
- High availability for balance reads, which can tolerate slightly stale data far better than the write path can tolerate errors.
- Durability with an immutable, append-only audit trail that satisfies financial regulators and internal audit.
- Low latency: balance reads in the low tens of milliseconds; money moves committed in a few hundred milliseconds.
- Auditability and traceability end to end, so any balance can be reconstructed from the raw postings at any point in time.
Back-of-envelope scale estimates
Show your math. Pulling numbers from thin air signals you have not thought about the load.
Daily money-moving transactions
~30M writes/day (~350/sec average, ~3,500/sec peak)
Assume 50M active users and that ~60% touch money on a given day with one operation each. 30M / 86,400s is about 350/sec average. Payday and sale spikes concentrate load, so assume a 10x peak to ~3,500/sec. Derived, illustrative.
Balance read requests
~1.5B reads/day (~17,000/sec average)
Every app open, checkout, and push notification reads a balance. Assume 50 balance reads per active user per day across 50M users, so 2.5B; taking 60% active gives ~1.5B, about 17,000/sec. Reads dwarf writes by ~50x, which is the core asymmetry. Estimate.
Ledger entries stored per year
~22B postings/year
Each money movement writes at least two postings (a debit and a credit); holds and multi-leg transfers write more. 30M transactions/day times ~2 postings times 365 is ~22B/year, before FX legs and fees. Derived.
Ledger storage growth
~5-8 TB/year raw postings (estimate)
At ~22B postings/year and a conservative ~300 bytes per posting row (ids, amount, currency, account refs, timestamps) that is roughly 6.6 TB/year of primary data, before indexes and replicas. Uber reports petabyte-scale index footprints at their volume. Estimate.
Idempotency key store
~30M keys/day, TTL 24-72h, ~a few billion live
One key per mutating request. At 30M/day with a 72h retention window you hold on the order of ~90M to a few hundred million active keys depending on retry amplification. Kept in a fast KV or an indexed table and expired after the retry window. Derived.
Reconciliation volume
Millions of external line items matched daily
Every top-up and payout produces a bank or processor record that must be matched to an internal transaction. At tens of millions of external-facing moves per day, the recon job compares millions of line items and must finish inside the daily settlement window. Estimate.
High-level architecture
A request enters through an API gateway that authenticates the user and rate limits by account. It lands on a wallet service that owns the business rules for top-up, transfer, withdraw, pay, hold, and capture. Before doing anything, the service checks the idempotency key: it looks up the client-supplied key in a durable store, and if it has seen it, it returns the previously recorded result instead of moving money again. If the key is new, the service runs risk and limit checks (velocity, fraud signals, daily and lifetime caps), then opens a database transaction against the ledger. Inside that transaction it validates the source account has sufficient available balance, appends the balanced set of postings (debit one account, credit another, summing to zero), updates the affected account balances, and records the idempotency key and its result, all atomically. The transaction commits with strong isolation so two concurrent spends against the same balance cannot both succeed. Money that touches the outside world (a card charge, a bank payout) is not a single synchronous act: the ledger records a pending or held state, an external adapter talks to the processor or bank asynchronously, and a webhook or settlement file later confirms or fails the movement, at which point a second posting finalizes or reverses it. The read path is separate and optimized for volume: balance queries hit a precomputed balance row or a cache, and transaction history is served from read replicas or a search store. A continuous reconciliation pipeline ingests bank and processor statements and matches them against ledger transactions, flagging any break for a human or an automated repair.
In a real interview, sketch this on the whiteboard before diving into any single box.
Core components
Walk through each service. The interviewer wants to hear what each one owns, not just the names.
Wallet / Money-Movement Service
The stateful core that orchestrates every operation: top-up, withdraw, transfer, pay, hold, capture, refund. It enforces business rules and limits, opens ledger transactions, and is the only component allowed to write postings. It is deliberately boring and heavily tested, because a bug here loses real money.
Double-Entry Ledger Store
The system of record. It stores accounts and an append-only stream of balanced postings where every transaction's debits equal its credits. It never updates or deletes a posting; corrections are compensating entries. Balances are derived from postings, so the log is always the source of truth.
Idempotency Service
A durable key-value store, or an indexed table, that maps a client idempotency key to the outcome of the request that first used it. It is written inside the same transaction as the money movement so a key and its effect commit or fail together. Retries with the same key return the stored result rather than re-executing.
Balance Read / Cache Layer
Serves the high-volume balance queries and transaction history that the write path should not be burdened with. It reads from precomputed balance rows, read replicas, or a cache invalidated on commit. It can serve slightly stale data for display, but any spend re-checks the authoritative balance inside the write transaction.
Holds / Authorization Manager
Tracks reserved funds during card authorizations or pending transfers. A hold reduces available balance without moving money to a final account, and it later resolves into a capture (funds move) or a release (funds return). This two-phase model is what prevents a user from spending money that is already committed elsewhere.
External Payment Adapters
Integrations with card networks, banks, UPI, and payment service providers for top-ups and withdrawals. They translate internal intents into provider calls, handle asynchronous confirmation via webhooks and settlement files, and expose a clearing account per provider so funds in flight are always visible in the ledger.
Reconciliation & Fraud Engine
Two continuous back-office systems. Reconciliation matches external statements against internal transactions and surfaces breaks. The fraud and velocity engine scores movements before commit and can block, hold, or step up a transaction, feeding decisions back into limit checks.
Data model
Pick the right store per table. Justify each choice with the access pattern, not by reflex.
accountsaccount_idowner_idaccount_typecurrencystatusOne row per logical money bucket: a user's wallet, a provider clearing account, a fees account, an FX account. account_type distinguishes asset versus liability so the balance sign is well defined. Currency is fixed per account; you never mix currencies in one account. status supports frozen or closed accounts.
transactionstransaction_ididempotency_keytypestatuscreated_atThe envelope for one atomic money movement (transfer, top-up, capture). Groups the postings that must balance to zero. idempotency_key is unique so a retried request cannot create a second transaction. status covers pending, posted, failed, reversed.
postingsposting_idtransaction_idaccount_iddirectionamountcurrencyThe immutable double-entry lines. direction is debit or credit, amount is a positive integer in the smallest currency unit (paise, cents) to avoid floating point. Sum of debits equals sum of credits within a transaction_id. Never updated; reversals are new postings.
balancesaccount_idcurrencyavailablepending_holdsversionA derived, denormalized snapshot for fast reads and for the sufficiency check on spend. available is spendable now; pending_holds is reserved by open authorizations. version is an optimistic-lock counter so concurrent writers detect conflicts. Must always be reconstructable from postings.
holdshold_idaccount_idamountstateexpires_atReserved funds during an authorization. state is active, captured, released, or expired. expires_at lets stale authorizations auto-release. Capturing a hold writes the real postings and closes the hold; releasing just returns the reserved amount to available.
idempotency_keysidempotency_keyrequest_fingerprinttransaction_idresponseexpires_atMaps a client key to its committed outcome. request_fingerprint guards against a key being reused with different parameters, which should be rejected. response caches the original result so retries are answered without re-doing the work. Rows expire after the retry window.
Deep dives
These are the conversations the interviewer is steering you toward. Practice each one until you can talk through it without notes.
Why double-entry, and how balances are derived not stored
The single most important decision is to model money as a double-entry ledger rather than a mutable balance column. Every movement is a transaction made of balanced postings: to move 500 from Alice to Bob you debit Alice 500 and credit Bob 500, and the two lines sum to zero. Money is conserved by construction, which means a whole class of bugs simply cannot express themselves, because you can never write a change that creates or destroys value. The postings table is append-only and immutable; you never update a row, and a correction is a new compensating posting so the audit trail stays intact. The true balance of any account is the sum of its postings, so the ledger log is the source of truth and the stored balance is just a cache of that sum. Stripe and Formance both describe balances as derived from an immutable log precisely so that a stored number can never silently drift away from the transactions that produced it. In practice you keep a denormalized balance row for speed, but you must be able to rebuild it from postings at any time, and reconciliation depends on that property.
The ACID write path and isolation for read-modify-write
Moving money is a classic read-modify-write: read the available balance, check it is enough, then debit it. If two requests do this concurrently against the same wallet, naive code lets both read the old balance and both succeed, and the user has spent the same money twice. The fix is real transactional isolation. Wrap the check and the postings in a single database transaction and prevent the two from interleaving, either with serializable isolation, with SELECT ... FOR UPDATE row locks on the account, or with an optimistic version column that fails the second writer. Postgres serializable or row-level locks handle this cleanly for a wallet, since a single account is the natural lock granularity and contention on one wallet is usually low. The transaction must also write the idempotency key and update the balance atomically with the postings, so either the whole movement commits or none of it does. This is why the money path cannot be eventually consistent: a temporarily wrong balance here is not a stale read, it is either a double-spend or a lost deposit.
Idempotency keys and safe retries
Networks drop responses, clients retry, and mobile apps resend on reconnect, so the same top-up or transfer will arrive more than once. Every mutating endpoint takes a client-generated idempotency key. Before doing work, the service persists the key to a unique index; if the insert conflicts, another request with that key already ran, and you return its stored result instead of moving money again. The subtle part is atomicity: the key and the money movement must commit in the same transaction, otherwise a crash between them either loses the guarantee or blocks the retry forever. You also fingerprint the request body, so if a caller reuses a key with different parameters you reject it rather than silently returning the wrong answer. Keys carry a TTL matched to the client retry window, commonly 24 to 72 hours, after which they expire to bound storage. Stripe describes idempotency not as a cache lookup but as a durable state machine that survives crashes and concurrent duplicates, which is exactly the standard a wallet needs.
Holds, authorizations, and two-phase transfers
Many money movements are not instantaneous. A card authorization reserves funds now and captures them later; a transfer to a bank is initiated now and settles hours later. Modeling these as a single posting is wrong because the funds are neither fully spent nor fully available. The pattern is a two-phase transfer. Phase one places a hold: it reduces available balance and increases pending_holds without moving money to a final account, so the user cannot spend the reserved amount elsewhere. Phase two either captures the hold, writing the real postings and closing it, or releases it, returning the reserved amount to available. Partial capture is common, for example authorizing a fuel pump for a round amount and capturing the actual fill. TigerBeetle builds two-phase transfers directly into its data model with pending and posting transfers for this reason. Holds also need an expiry so an abandoned authorization auto-releases rather than freezing a user's money forever, and every state transition is itself recorded so the history explains exactly why available balance differs from the raw posting sum.
The read-heavy balance path versus the strongly-consistent write path
Balance reads outnumber money moves by roughly fifty to one in a consumer wallet, and they have very different requirements, so you split the system by access pattern. The write path is low volume, strongly consistent, and expensive per operation; it is the only place allowed to change money, and it always re-reads the authoritative balance under a lock before spending. The read path is high volume and can tolerate slight staleness for display purposes: showing a balance that is a few hundred milliseconds behind is fine, but authorizing a payment against a stale number is not. So you serve display reads from a cache or read replica invalidated on commit, and you serve transaction history from replicas or a search index, while the sufficiency check that gates a spend runs inside the write transaction against the primary. This is the crux of the interview. The trap is treating the balance as one thing; the insight is that a wallet has two balances, the one you show and the one you spend against, and only the second must be perfectly consistent at the moment of truth.
Multi-currency and foreign exchange
A wallet that holds more than one currency must never add rupees to dollars in a single account or posting. Each account carries exactly one currency, and each posting is denominated in that currency, so balances are always unambiguous. A cross-currency move is not a single transfer; it is two coupled transfers through an FX position. To pay a dollar merchant from a rupee balance you debit the rupee wallet, credit an internal FX account in rupees, debit an FX account in dollars, and credit the destination in dollars, with the rate and the spread recorded on the transaction. The two legs must commit atomically so you never end up long one currency and short the other. Rounding is handled with integer minor units and an explicit rounding account that absorbs sub-unit remainders, because floating point money silently loses fractions. Keeping FX explicit also makes the business side auditable: you can report exactly how much currency risk the platform is carrying and what spread it earned.
Reconciliation against external providers
Your ledger is only correct relative to the outside world if it agrees with the banks and processors that actually hold the cash. Reconciliation is the continuous job that proves this. Every external-facing movement flows through a clearing account per provider, a bucket that holds funds in flight. When you initiate a payout, you debit the user and credit the provider clearing account; when the bank confirms the money left, you clear that account. If everything matches, clearing accounts trend to zero, and any non-zero residual is a specific, named break tied to a provider reference, which is exactly how Formance and Stripe describe using clearing accounts. A daily pipeline ingests bank and processor statement files, matches each line to an internal transaction by amount, reference, and timestamp, and routes unmatched items to an exceptions queue for automated repair or human investigation. Reconciliation is also your safety net for the asynchronous confirmation problem: if a webhook is lost, recon catches the movement that your ledger thinks is still pending and the bank has already completed.
Trade-offs to discuss
Every senior interviewer expects you to surface at least 3 of these. Pick the decisions, state the alternatives, and justify your choice.
Double-entry ledger versus a single mutable balance column
A mutable balance is simpler and faster to read but has no audit trail and makes silent corruption easy: one bad UPDATE and the money is wrong with no way to explain it. Double-entry costs more writes and storage but conserves money by construction, gives a complete history, and lets you rebuild any balance from raw postings. For anything holding real value the ledger wins; the extra rows are cheap next to the cost of an unexplained discrepancy.
Strong consistency on writes versus eventual consistency everywhere
Eventual consistency scales writes and simplifies availability, and it is the right call for the balance display and history. But applying it to the spend path allows double-spends, because two nodes can both approve against a stale balance. The design keeps a hard ACID boundary around the money movement and pushes eventual consistency only to the read side, accepting lower write throughput per account in exchange for never losing or duplicating money.
Derive balances on read versus store a denormalized balance
Summing all postings on every read is perfectly correct but too slow for a hot account with millions of entries. Storing a denormalized balance is fast but can drift. The compromise is to store the balance as a cache with a version counter, always update it inside the same transaction as the postings, and periodically re-derive it from postings to prove it has not drifted. You get read speed and keep the log as the source of truth.
Row lock (pessimistic) versus optimistic version check on the account
Pessimistic SELECT ... FOR UPDATE serializes writers on a wallet and is simple to reason about, but hot accounts (a large merchant) can become a lock bottleneck. Optimistic version checks avoid holding locks and let readers proceed, but they push retries to the application when versions conflict. Most wallets have low per-account contention so pessimistic locking is the pragmatic default, with optimistic or sharded sub-accounts reserved for the few extremely hot accounts.
General-purpose SQL ledger versus a purpose-built financial database
Postgres with careful transactions and constraints is well understood, easy to hire for, and correct if you enforce balance invariants in the schema. A specialized engine like TigerBeetle bakes debit-credit and balance limits into the database, avoids many round-trips, and targets very high transaction rates, but it is a newer operational dependency with a narrower ecosystem. Start on proven SQL for correctness and clarity; adopt a specialized ledger only when transaction volume genuinely outgrows it.
Synchronous external settlement versus asynchronous with pending state
Waiting synchronously for a bank or card network makes the API simple but ties request latency and availability to a slow third party you do not control. Modeling external moves as a pending ledger state that is finalized by a later webhook or settlement file decouples you from provider latency and outages, at the cost of a more complex state machine and the need for reconciliation to close the loop. For anything crossing an external boundary, asynchronous with an explicit pending state is the only safe choice.
How Digital Wallet actually does it
Uber rebuilt its collection and disbursement platform, Gulfstream, on double-entry accounting principles and backs it with LedgerStore, an immutable storage system that provides verifiable completeness and correctness. LedgerStore indexes hundreds of billions of ledgers with over two trillion unique indexes, and Uber reports migrating more than a trillion entries off DynamoDB onto its own Docstore for over six million dollars in annual savings, using strongly consistent indexes with two-phase commits for payment-critical flows like credit card holds. Stripe's Ledger is an immutable, append-only, double-entry system of record that ingests around five billion money-movement events per day and continuously validates that debits and credits clear, reporting over 99.9999 percent explainability of money movement. TigerBeetle is a purpose-built financial database whose entire schema is debit-credit with two-phase transfers and database-enforced balance limits such as debits_must_not_exceed_credits, aimed at very high transaction throughput. Formance's engineering writing lays out the same primitives most consumer wallets use: colon-delimited account paths, compensating entries instead of updates, idempotency keys persisted to unique indexes, and clearing accounts per provider that return to zero when settlement is clean.
Sources
- Stripe: Ledger, the system for tracking and validating money movement
- Uber Engineering: How LedgerStore Supports Trillions of Indexes
- TigerBeetle Docs: Debit/Credit, the Schema for OLTP
- Formance: Double-Entry Accounting for Engineers
- Uber Engineering: Migrating a Trillion Entries from DynamoDB to LedgerStore
Lessons to study before this interview
If any of these topics are fuzzy, the interviewer will catch it. Each lesson is 15 to 60 minutes with diagrams, code, and a quiz.
Related system design interview questions
Practice these next. They lean on the same core building blocks as Digital Wallet.