Digital Wallet Interview Questions | JiQuest

add

#

Digital Wallet

System design deep dive · HLD

Design a Digital Wallet / E-Wallet System: full high-level design.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for a peer-to-peer transfer and a card top-up, and an entity-relationship diagram for a double-entry ledger - with the reasoning an interviewer expects behind every box, arrow, and dollar amount.

50MActive wallets
750MTransactions / month
2:1Ledger rows per txn
Clienttransfer $50 Wallet servicevalidate + orchestrate Fraud enginevelocity check ~5ms Transfer confirmedbalances updated Ledger DBatomic commit debit+credit+2 ledger rows

1. Clarify requirements before drawing any box

A digital wallet is a ledger system wearing a friendly UI - the moment real money is involved, "roughly correct" is not an option. Scope has to separate what must happen from how fast and how safely it must happen, because the safety requirements are what shape every later diagram.

Functional requirements

Top up a walletFund a wallet's balance from an external payment method (card or bank) via a payment gateway.
P2P transferMove funds between two wallets atomically - either both the debit and the credit happen, or neither does.
Double-entry ledgerEvery balance-affecting operation writes a matching debit+credit pair to an immutable ledger.
Fraud / velocity checksRun real-time rules on transfers, e.g. block or flag anything over $X or N transfers in an hour.

Non-functional requirements

Atomic, no double-spendMoney movement must never be lost or duplicated, even under concurrent transfers or partial failures.
Ledger is source of truthAn append-only audit trail sufficient to reconstruct any wallet's balance purely from summing its entries.
Idempotent under retryA client retrying a timed-out transfer must never cause a second, duplicate transfer.
Fast inline fraud checksRisky-operation checks run on the critical path without meaningfully increasing transfer latency.
Explicitly out of scope The actual card-network / bank settlement integration is treated as an external payment gateway black box, not re-implemented. Currency conversion and multi-currency wallets are also out of scope - this design assumes a single currency per wallet, with multi-currency called out as a natural extension in the deep-dive section.

2. Back-of-the-envelope capacity estimation

These numbers decide whether one Postgres primary can carry the write path, how much the ledger grows under a multi-year regulatory retention requirement, and how much headroom the design needs above steady-state for a flash-sale-style spike.

MetricAssumptionResulting estimate
Active wallets50 million monthly active walletsBaseline population for every estimate below
Transactions per wallet~15 transfers + top-ups / wallet / month50M × 15 = 750 million transactions/month
Throughput750M/month over ~2.59M seconds~289 TPS average, ~1,750 TPS at normal daily peak (~6x avg)
Ledger volume (double-entry)2 ledger_entries rows per transaction750M × 2 = 1.5 billion rows/month (~18 billion/year)
Storage per ledger row~150 bytes, fixed-width (ids, type, amount, balance_after, timestamp)18B/year × 150B ≈ 2.7 TB/year raw
7-year retentionFinancial audit trails typically must be retained 7 years2.7TB × 7 ≈ 19 TB raw, ~25 TB including indexes/replicas
Flash-sale peak TPS~5x the normal peak during a high-traffic promo/sale event~9,000 TPS - sizes the transactional DB's write throughput and connection-pool headroom
Why this matters The gap between ~289 TPS average and ~9,000 TPS at a flash-sale peak is exactly why the debit+credit+ledger write has to stay a single, cheap, local ACID transaction scoped to at most two wallet rows - there is no latency budget for a distributed coordination protocol on the common-case path, only on the rare cross-shard fallback.

3. High-level design (HLD)

The HLD names the major components and the direction money and data flow between them, without yet committing to replica counts, regions, or which specific queueing technology carries the async fraud escalation - that belongs in the architecture diagram next.

Clientapp / web API gatewayidempotency-key check Wallet servicevalidate + orchestrateatomic transfer Fraud / velocity enginesync rules + async ML path Ledger serviceappend debit+credit rows Wallets tabledenormalized balance cache Payment gatewayexternal top-up funding Reconciliationledger vs cache,nightly batch
Stateless servicesFast-path cacheDurable ledger storageAsync / external

What each box owns

API gateway

Terminates client requests and enforces the idempotency contract at the edge: every mutating request carries a client-generated idempotency key, and the gateway checks a key-value store before letting the request reach the wallet service. A duplicate key returns the previously cached result instead of reprocessing - the request never even reaches the fraud engine twice.

Wallet service

The orchestrator for every balance-affecting operation. It validates the sender has sufficient balance, calls the fraud/velocity engine synchronously, and then opens the single database transaction that debits one wallet, credits another, and writes the matching ledger_entries rows - or rolls all of it back if any step fails.

Ledger service

Owns the append-only ledger_entries table - the actual source of truth for every dollar in the system. It never updates or deletes a row; corrections happen by inserting new offsetting entries, never by editing history, which is what makes the ledger auditable after the fact.

Wallets table (balance cache)

Holds a denormalized current-balance column so reads (checking "can I afford this transfer?", showing a balance in the app) are a single indexed lookup instead of summing potentially thousands of ledger rows. It is a cache in the strict sense: it must always be reconcilable against the ledger, never authoritative on its own.

Fraud / velocity check service

A fast, rules-based engine that checks a small recent-activity window per wallet (e.g. "more than $2,000 or 5 transfers in the last hour") using cached counters, fast enough to run inline on every transfer. Anything borderline is also queued for a heavier, asynchronous ML risk-scoring pass that can flag a transaction for hold or reversal after the fact.

Payment gateway integration

The only external dependency in the design, called strictly for top-up funding (never for P2P transfers, which stay fully internal). Charging a card or bank account takes real time, so this call is asynchronous end-to-end: the gateway confirms settlement later via a webhook rather than in the original request/response cycle.

Reconciliation job

A periodic batch job that recomputes every wallet's balance by summing its ledger_entries and compares that to the cached balance column, alerting on any mismatch. It is the safety net that catches bugs, race conditions, or partial failures the atomic transaction design is supposed to prevent - and it always treats the ledger as correct when reconciling a drift.

4. Detailed architecture diagram

The architecture diagram answers the questions an interviewer actually probes: where does the idempotency key live, what exactly is "atomic" here, what happens to a flagged transaction, and why does the reconciliation job never touch the primary database.

Edge layer Client app API gateway Idempotency store (Redis)key=request_id, TTL ~10min Core transactional region (wallets co-partitioned) Wallet service ×N pods Fraud engine (sync)Redis counters, <5ms PostgreSQL primaryone ACID txn: debit + credit+ 2 ledger_entries rowsboth wallets on same shard Async fraud escalation Escalation queue ML risk scoring Hold / reverseif flagged post-hoc Payment funding (external, top-up only) Payment gateway Webhook receiversettlement confirmed never called for P2P transfers - those stay fully internal Batch / reconciliation tier Read replica Reconciliation job Alert on-callbalance != ledger sum
DecisionChoiceReasoning
Transfer atomicitySingle local DB transaction, not a distributed sagaAs long as both wallets are co-located/co-partitioned in the same database, one ACID transaction covers the debit, credit, and both ledger rows. A saga with compensating actions only becomes necessary once wallets are sharded across separate databases.
Balance storageDenormalized cached column, not summed live from the ledgerSumming potentially thousands of ledger rows on every balance check is too slow for the read path; a periodic reconciliation job keeps the cache honest instead.
Idempotency key storageRedis with a short TTL (chosen), vs a dedicated idempotency table in the same DB transactionRedis is fast and simple and matches the retry window clients actually need; an in-transaction idempotency table gives stronger guarantees (survives a Redis outage) at the cost of coupling every write to the same DB, which is the fallback if Redis-level guarantees prove insufficient.

5. Sequence diagrams for the two critical flows

A sequence diagram is where an interviewer checks whether you actually understand call order, what is synchronous versus fire-and-forget, and exactly where a failure means nothing gets written at all.

5.1 Peer-to-peer transfer, with idempotency and fraud check

Client API gateway Idempotency store Fraud engine Ledger DB 1. POST /transfer {from,to,amt,idem_key} 2. GET idem:key 3. MISS (new request) 4. check velocity(from_wallet) 5. OK, within limits 6. BEGIN; debit+credit+2 ledger rows; COMMIT 7. commit ack 8. SET idem:key = result (TTL, fire-and-forget) 9. 200 OK, transfer confirmed

If step 3 instead finds a HIT, the gateway returns the cached prior result immediately and skips steps 4-8 entirely - a retried request never re-runs the fraud check or re-moves money. On the failure path, if step 5 comes back FAIL, or the balance check inside step 6 finds insufficient funds, the database transaction in step 6 is either never opened or is rolled back before COMMIT: no wallet row and no ledger row is ever written, and the client receives an error response instead of step 9.

5.2 Wallet top-up via external payment gateway (async settlement)

Client Wallet service Payment gateway Ledger DB 1. POST /topup {amount, card_token} 2. charge(card_token, amount) 3. 202 Accepted (pending) 4. 200 OK, top-up pending 5. webhook: settlement.succeeded (async, later) 6. BEGIN; credit wallet + debit clearing acct; 2 ledger rows; COMMIT 7. commit ack 8. push/poll: funds arrived (fire-and-forget)

Real payment rails cannot confirm settlement synchronously, so steps 1-4 deliberately return a "pending" response without waiting - the client is told the top-up was accepted, not that funds have landed. Step 6 keeps the ledger's double-entry invariant even for external money entering the system: the credit to the user's wallet is balanced by a debit against an internal "gateway settlement" clearing account, so debits still equal credits system-wide, and step 8 is the only place the client learns the money actually arrived.

6. Entity-relationship (ER) diagram and schema

The data model has to answer three questions: what proves a transfer's two sides always balance, what makes a retried request safe, and how a wallet's balance can always be reconstructed from history alone.

wallets PK id BIGINT FK user_id BIGINT currency CHAR(3) balance BIGINT (cache) updated_at TIMESTAMP transactions PK id BIGINT type transfer/topup FK from_wallet_id BIGINT NULL FK to_wallet_id BIGINT NULL amount BIGINT status ENUM UQ idempotency_key VARCHAR created_at TIMESTAMP ledger_entries PK id BIGINT FK transaction_id BIGINT FK wallet_id BIGINT entry_type debit/credit amount BIGINT balance_after BIGINT 1N 12 1N one transaction has exactly two ledger_entries; one wallet has many ledger_entries

Key modeling decisions

balance is a cache, the ledger is the truthwallets.balance exists purely for fast reads; it is always derivable by summing that wallet's ledger_entries, and reconciliation enforces this.
idempotency_key is unique on transactionsA DB-level unique constraint gives a second, stronger safety net beyond the Redis idempotency store - a duplicate insert fails fast even if the cache was somehow bypassed.
ledger_entries is append-only, never updatedNo UPDATE or DELETE is ever issued against this table; a correction is a brand-new offsetting entry, which is what keeps the audit trail trustworthy.
from_wallet_id / to_wallet_id are nullableA P2P transfer sets both; a top-up sets only to_wallet_id (the "from" side is the external gateway, not a wallet row).
Storage choiceUse whenWatch out for
Relational (PostgreSQL), ACIDYou need a single multi-row transaction to guarantee the debit, credit, and both ledger rows commit or fail together.Vertical scaling limits eventually require sharding wallets, at which point cross-shard transfers need a saga.
NoSQL (DynamoDB/Cassandra)Extreme horizontal read/write scale is the priority and per-item operations are acceptable.No native multi-row ACID transaction across two wallets - the atomic debit+credit invariant has to be rebuilt at the application layer, which is exactly the guarantee a ledger cannot afford to get wrong.

7. Deep dives interviewers actually probe

Why double-entry bookkeeping instead of just incrementing/decrementing a balance column?

A single mutable balance column has no memory of how it got there - a bug or a race condition can silently corrupt it and nothing detects that. Double-entry means every transaction writes a balanced debit+credit pair, so the ledger is self-auditing: summing all ledger_entries for any wallet always reconstructs its true balance independent of the cached column. Reconciliation can then compare the two and catch drift instead of trusting a number that could have quietly drifted for months.

How do you guarantee atomicity for a transfer touching two different wallets without a distributed transaction?

Keep both wallets' rows in the same database (the same shard), so a single local ACID transaction covers the debit, the credit, and both ledger_entries inserts - if anything fails, the whole transaction rolls back and nothing partial is ever visible. The moment wallets are sharded across separate databases, this stops being free: you either introduce a saga with compensating actions (debit here, then credit there, with an explicit reversal step if the second half fails) or design a partitioning scheme that co-locates wallets that transact with each other frequently.

BEGIN;
  SELECT balance FROM wallets WHERE id = :from_id FOR UPDATE;
  -- application checks balance >= amount, else ROLLBACK
  UPDATE wallets SET balance = balance - :amount WHERE id = :from_id;
  UPDATE wallets SET balance = balance + :amount WHERE id = :to_id;
  INSERT INTO ledger_entries (transaction_id, wallet_id, entry_type, amount, balance_after)
    VALUES (:txn_id, :from_id, 'debit',  :amount, :from_balance_after);
  INSERT INTO ledger_entries (transaction_id, wallet_id, entry_type, amount, balance_after)
    VALUES (:txn_id, :to_id,   'credit', :amount, :to_balance_after);
COMMIT;

How do you make a transfer request safe to retry after a client-side timeout, given the server might have actually succeeded?

The client generates an idempotency key once and sends it with every retry of the same logical request. The API gateway checks that key against a store before doing any work; if the key has been seen before, it returns the original cached result instead of re-executing the transfer. A unique constraint on transactions.idempotency_key backs this up at the database level in case the cache is ever bypassed or evicted early - a duplicate insert simply fails rather than moving money twice.

How does the velocity/fraud check stay fast enough to run inline on every transfer?

The inline check is deliberately cheap: a rules engine reading a handful of cached counters (recent transaction count and total amount per wallet over a rolling window, kept in Redis) rather than invoking a full ML model synchronously. That keeps it well under the latency budget of the surrounding request. Genuinely borderline or high-risk transactions are still allowed through immediately but are also pushed onto a queue for a heavier, asynchronous ML scoring pass; if that later scoring comes back bad, the system places a hold on the wallet or reverses the transaction rather than blocking every legitimate transfer with a slow model in the hot path.

How do you detect and recover if a bug or partial failure ever causes a wallet's cached balance to drift from its ledger total?

A periodic reconciliation job runs against a read replica (never the primary, so it never competes with live traffic) and, for every wallet, recomputes the balance as the sum of its ledger_entries and compares it to the cached balance column. Any mismatch pages an on-call engineer rather than silently correcting itself, because a drift usually means a bug is actively running; when a correction is made, the ledger is always treated as the source of truth and the cached column is the one that gets fixed, never the other way around.

8. Summary: what a strong answer covers

Clarified scope before designingJustified every number with a calculationMade the ledger the source of truth Named idempotency keys explicitlyKept fraud checks fast, escalation asyncCompared single-transaction vs saga honestly
Interview tip When asked to design a digital wallet, the strongest signal is treating the ledger as sacred: every other component - the balance cache, the fraud engine, even the payment gateway integration - is designed so its failure or slowness can never cause the books to stop balancing.
No comments
Leave a Comment