Design a Digital Wallet
Accounts, balances, transfers, and a double-entry ledger with idempotent, atomic transactions.
Problem Statement
Design a digital wallet like Paytm or PayPal where users hold wallet accounts, top up money, withdraw money, and transfer money to other users. The core design must protect monetary correctness: every balance mutation is backed by a double-entry ledger, every client retry is guarded by an idempotency key, and every transaction moves through a clear state machine.
The interview focus is not a payment gateway integration or a REST API. It is the domain model and service boundary that make wallet money safe under concurrency, especially when two transfers try to debit the same account at the same time.
Business context
Digital Wallet is a strong mid-level LLD round because it combines familiar product flows with serious invariants. A candidate must model wallets and accounts, choose where balances live, record immutable ledger entries, prevent duplicate retries, and explain why a transfer cannot leave money half-moved. Companies like PayPal, Amazon, Flipkart, and Uber use this style of question to test whether you can move from CRUD objects to correctness-first design.
Functional Requirements
Create a wallet for a user and attach one or more currency-specific accounts.
Credit an account from an external source such as bank top-up or card load.
Debit an account to an external sink such as withdrawal or merchant payment.
Transfer money between two accounts in the same currency.
Record every successful money movement as balanced debit and credit ledger entries.
Track each transaction through statuses such as INITIATED, PENDING, POSTED, FAILED, and REVERSED.
Accept an idempotency key so a retried credit, debit, or transfer returns the original transaction.
Expose balances, transaction status, and the immutable ledger history for audit/debugging.
Non-Functional Requirements
Monetary correctness
A posted transaction must be balanced: total debits equal total credits. For transfers, the source debit and destination credit happen in one critical section.
Thread safety
Concurrent debits and transfers on the same account must serialize. The implementation locks accounts in deterministic id order to avoid deadlocks.
Idempotency
Network retries must not duplicate money movement. The service stores the first result for each idempotency key and returns it for repeats.
Auditability
Balances are fast reads, but the ledger is the source of truth for explaining how a balance was reached.
Extensibility
Currency rules, notification behavior, and transaction commands should be replaceable without editing the transfer algorithm.
Requirement Clarification
QCan one wallet hold multiple currencies?
Yes. Model each currency as a separate Account under the wallet. The base transfer only allows same-currency account pairs.
QDo we integrate with banks, cards, or UPI directly?
No. Treat outside money as an EXTERNAL ledger counterparty. Real payment rails become adapters around credit/debit commands.
QShould failed transactions appear in history?
Yes. They are useful for idempotent retries and support investigations, but they do not create ledger entries or mutate balances.
QAre balances computed from ledger entries on every read?
Not in the interview implementation. Accounts keep a cached balance for fast reads, and the ledger provides the immutable audit trail.
QWhat does atomic transfer mean in an in-memory LLD answer?
The source debit, destination credit, ledger append, and status transition are guarded by ordered account locks plus one idempotency boundary.
UML Class Diagram
Sequence Diagram
Entity Identification
WalletService
Facade and transaction boundary. Owns wallets, accounts, idempotency keys, ledger storage, account locking order, policy checks, and post-commit notifications.
Wallet
User-facing container that groups one user's accounts. It validates that added accounts belong to the wallet and returns defensive account maps.
Account
Currency-specific balance holder. It exposes a safe balance read and package-level locked credit/debit operations used only by the service.
Transaction
Lifecycle record for a credit, debit, or transfer. It stores idempotency key, type, amount, endpoints, status, timestamps, and attached ledger entries.
TransactionStatus
State machine for transaction lifecycle. Each enum state decides which next states are legal, so invalid jumps such as INITIATED to POSTED are rejected.
LedgerEntry
Immutable journal row. A posted wallet movement has one debit row and one credit row, tied by the same transaction id.
TransactionCommand
Command interface for credit, debit, and transfer requests. Each concrete command captures request parameters and calls the service apply method.
CurrencyPolicy
Strategy interface for currency compatibility. The default policy allows only same-currency transfers and exact account-currency matches.
TransactionObserver
Observer seam for receipts, notifications, analytics, or fraud hooks after a transaction posts.
Design Patterns Used
TransactionStatus owns legal transitions. The transaction lifecycle is explicit and invalid jumps fail fast instead of being hidden in service conditionals.
CreditCommand, DebitCommand, and TransferCommand encapsulate request parameters and execution. This makes retries, logging, authorization, and queuing easier to add around the same command interface.
TransactionObserver lets the service notify receipts, analytics, or fraud listeners after a posted transaction without coupling core money movement to side effects.
CurrencyPolicy makes currency compatibility pluggable. The base policy rejects cross-currency movement; a future FX policy can quote and convert without rewriting transfer locking.
Step-by-Step Design
1Start from money invariants, not screens
The central invariant is no lost money. Every posted transaction must have balanced ledger entries, and transfer must debit one account and credit the other while both accounts are locked.
2Represent transaction lifecycle as a state machine
Do not use free-form strings for status. Put legal transitions beside the status values so a transaction cannot skip from INITIATED directly to POSTED.
enum TransactionStatus { INITIATED { boolean canMoveTo(TransactionStatus next) { return next == PENDING || next == FAILED; } }, PENDING { boolean canMoveTo(TransactionStatus next) { return next == POSTED || next == FAILED; } }, POSTED { boolean canMoveTo(TransactionStatus next) { return next == REVERSED; } }, FAILED, REVERSED; boolean canMoveTo(TransactionStatus next) { return false; } }3Use double-entry ledger rows for every posted movement
Balances are convenient cached state; ledger entries explain the truth. A top-up debits EXTERNAL and credits the account. A transfer debits the source and credits the destination.
List<LedgerEntry> entries = Arrays.asList( LedgerEntry.debit(transaction.id(), source.id(), amountInCents, currency), LedgerEntry.credit(transaction.id(), destination.id(), amountInCents, currency) ); transaction.attachLedgerEntries(entries);4Make transfer atomic with deterministic account locks
Always acquire the lower account id lock first and release in reverse order. This serializes competing transfers on shared accounts and avoids deadlock when A pays B while B pays A.
private void lockInIdOrder(Account first, Account second) { if (first.id().compareTo(second.id()) <= 0) { first.lock(); second.lock(); } else { second.lock(); first.lock(); } }5Put idempotency around the whole command
The idempotency map is checked before any command mutates balances. A duplicate key returns the original Transaction, including a failed transaction, instead of running the debit again.
synchronized (transactionsByIdempotencyKey) { Transaction existing = transactionsByIdempotencyKey.get(idempotencyKey); if (existing != null) { return existing; } Transaction created = action.get(); transactionsByIdempotencyKey.put(idempotencyKey, created); return created; }6Separate policy and side effects from posting
CurrencyPolicy decides whether accounts are compatible before locks mutate balances. TransactionObserver runs after POSTED, so notification failures do not corrupt money movement.
Complete Java Implementation
Explanation of Every Class
Wallet
Groups accounts for one owner. It is intentionally small: identity, owner id, synchronized account registration, and defensive account views.
Account
Holds one currency balance and a ReentrantLock. Package-level creditLocked and debitLocked keep balance mutation available only to the service transaction boundary.
Transaction
Stores request metadata, idempotency key, endpoints, timestamps, status, and attached ledger entries. TransactionStatus inside the same file enforces lifecycle transitions.
LedgerEntry
Immutable debit/credit row tied to a transaction id. The service creates entries in pairs, giving every posted movement an audit-friendly double-entry record.
TransactionCommand
Defines the command seam and concrete CreditCommand, DebitCommand, and TransferCommand. Each object captures parameters and delegates execution to the service.
WalletService
The correctness boundary. It owns idempotency, deterministic locking, double-entry ledger append, status transitions, currency policy, and observer notifications.
Main
Small demo that creates two wallets, transfers money once, retries with the same idempotency key, and prints unchanged balances plus a two-row ledger.
Dry Run
Sample input
Accounts: Alice INR balance 10000, Bob INR balance 2500. Action: transfer 1500 INR from Alice to Bob using idempotency key transfer-001, then retry the same request.
| Step | Action | Transaction status | Alice balance | Bob balance | Ledger |
|---|---|---|---|---|---|
| 1 | Receive transfer-001 | INITIATED | 10000 | 2500 | no entries |
| 2 | Pass idempotency check and lock Alice then Bob | PENDING | 10000 | 2500 | no entries |
| 3 | Debit Alice and credit Bob | PENDING | 8500 | 4000 | not appended yet |
| 4 | Append double-entry rows | POSTED | 8500 | 4000 | DEBIT Alice 1500, CREDIT Bob 1500 |
| 5 | Retry transfer-001 | POSTED | 8500 | 4000 | same two entries returned |
The retry in step 5 proves idempotency: the service returns the first transaction and does not debit Alice again. At no point is there a posted transfer without both ledger rows.
Complexity Analysis
| Operation | Time | Space | Note |
|---|---|---|---|
| createWallet / createAccount | O(1) | O(1) | Hash map insert plus a small wallet-account association. |
| credit / debit | O(1) | O(1) | Idempotency lookup, one account lock, two ledger entries. |
| transfer | O(1) | O(1) | Two account locks acquired in deterministic order and two ledger entries appended. |
| balance | O(1) | O(1) | Reads the cached account balance under the account lock. |
| ledgerEntries | O(N) | O(N) | Returns a defensive copy of N in-memory ledger rows. |
The in-memory design optimizes the interview hot path with O(1) maps and account locks. In production, the same boundaries map to database transactions, row locks, unique idempotency keys, and append-only ledger tables.
Extensibility
Foreign exchange transfers
Replace SameCurrencyPolicy with an FX-aware CurrencyPolicy that quotes rates and creates four ledger entries: source debit, source FX credit, destination FX debit, destination credit.
New transaction type
Add a new TransactionCommand implementation such as RefundCommand or HoldCommand and route it through the same idempotency and state machine boundary.
Receipts and notifications
Attach more TransactionObserver implementations for email, SMS, analytics, or fraud review without modifying balance mutation logic.
Persistent repositories
Move maps and lists behind repositories with unique indexes on idempotency key and ledger entry id. The service remains the orchestration boundary.
Account holds
Introduce an available balance separate from posted balance and a HOLD status before debit settlement. The same state pattern scales to this lifecycle.
Alternative Designs
Ledger-derived balances only
Remove cached balances and compute every balance by summing ledger entries for the account.
Tradeoffs
Very audit-friendly and hard to corrupt, but too slow for high-frequency balance reads unless backed by projections or materialized views.
Single global wallet lock
Synchronize every credit, debit, and transfer on one service-wide monitor.
Tradeoffs
Simpler to reason about, but it serializes unrelated accounts and becomes a throughput bottleneck quickly.
Database-first transaction boundary
Use SQL rows for accounts, ledger entries, and idempotency. A transfer becomes one database transaction with account rows locked by ordered id.
Tradeoffs
Closer to production and durable across processes, but heavier for an LLD whiteboard unless the interviewer asks for multi-instance behavior.
Common Mistakes
- ×
Updating source and destination balances in separate methods without one atomic transfer boundary.
- ×
Storing only the latest balance and skipping ledger entries, which destroys auditability.
- ×
Treating idempotency as a client concern instead of enforcing it server-side.
- ×
Letting transaction status be any string, so impossible lifecycle transitions sneak in.
- ×
Acquiring account locks in request order, which can deadlock when two users pay each other simultaneously.
- ×
Running observers before the transaction is POSTED, causing side effects for failed or partial work.
- ×
Creating one ledger row for a transfer instead of balanced debit and credit rows.
Follow-up Interview Questions
QHow do you make this safe across multiple application instances?
Move accounts, idempotency records, transactions, and ledger entries to a database. Use a unique constraint on idempotency key and row-level locks on account rows in deterministic order.
QHow would you support cross-currency transfer?
Make CurrencyPolicy quote FX and create balanced ledger entries in both currencies. Persist the rate used on the transaction for audit and dispute handling.
QWhat happens if a debit fails for insufficient funds?
The transaction moves from PENDING to FAILED, no ledger entries are attached, and the idempotency key maps to that failed transaction so a retry returns the same result.
QWhy not notify users inside the account lock before releasing it?
Notifications are slow and unreliable side effects. The service posts money first, releases locks, then notifies observers so external failures do not hold critical locks.
QWhere would fraud checks fit?
Synchronous allow/deny checks can run as a policy before PENDING. Asynchronous monitoring can be an observer after POSTED or a separate review workflow for holds.
Production Considerations
Durability and atomic commit
Use a database transaction to update account balances, insert ledger entries, insert/update transaction state, and reserve the idempotency key together.
Numeric precision
Store integer minor units such as cents or paise. Avoid floating point for money and persist currency on every amount.
Idempotency retention
Keep idempotency records long enough for client retry windows. Include request fingerprinting so the same key cannot be reused for different parameters.
Reconciliation
Run periodic jobs that sum ledger entries and compare them to cached balances. Alert on any mismatch before users see inconsistent money.
Observability and compliance
Emit metrics for failed debits, duplicate idempotency hits, transfer latency, and ledger imbalance. Keep immutable audit logs for regulatory review.
What Interviewers Look For
Does the candidate state the money invariants before naming classes?
Is transfer atomic and deadlock-safe under concurrent requests?
Are idempotency keys enforced inside the service boundary?
Is the ledger double-entry and immutable rather than an afterthought?
Does transaction status follow a lifecycle instead of ad hoc booleans?
Are policies and observers separated from core balance mutation?
Quiz
0/5 answered
1.Why does the transfer acquire account locks in deterministic id order?
2.What does the idempotency key protect against?
3.Which pair of ledger entries represents a wallet-to-wallet transfer?
4.Why is **TransactionStatus** better than a free-form status string?
5.Why should observers run after POSTED instead of before balance mutation?
Practice Variants
Add pending holds
AdvancedSupport authorization holds where available balance decreases before posted balance. Add HELD and RELEASED states and verify idempotent capture.
Cross-currency transfer
AdvancedImplement an FX CurrencyPolicy that stores the quote id and creates balanced ledger rows in both currencies.
Persistent wallet repositories
IntermediateReplace in-memory maps with repository interfaces and describe the SQL constraints needed for idempotency and ledger immutability.
Flashcards
Cheat Sheet
Entities: WalletService, Wallet, Account, Transaction, TransactionStatus, LedgerEntry, TransactionCommand, CurrencyPolicy, TransactionObserver.
Patterns: State for transaction lifecycle, Command for money operations, Observer for receipts/analytics, Strategy for currency rules.
Flows: credit = EXTERNAL debit + account credit; debit = account debit + EXTERNAL credit; transfer = source debit + destination credit.
Invariants: positive amount, same currency, idempotency key unique, two ledger rows per posted movement, transfer locks both accounts atomically.
Concurrency: account-level locks in sorted id order prevent lost updates and deadlocks.
Production: enforce idempotency with a unique key, persist ledger append-only, reconcile cached balances, store integer minor units.
References
- BookDesigning Data-Intensive Applications — Transactions and Consistency — Martin Kleppmann
- BookPatterns of Enterprise Application Architecture — Unit of Work — Martin Fowler
- DocsStripe API Docs — Idempotent Requests