Compile Ready
All low level design problems
Low Level Design/Intermediate/Real-world Systems

Design a Digital Wallet

Accounts, balances, transfers, and a double-entry ledger with idempotent, atomic transactions.

Intermediate 50m interview 17m read Medium frequency Popularity 83
State Command Strategy Repository PayPal Amazon Flipkart Uber

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

Rendering diagram…
The service is the aggregate boundary for money movement. Wallets own accounts, transactions own their ledger entries, and the service coordinates idempotency, locking, policy, and observers.

Sequence Diagram

Rendering diagram…
Idempotency is checked before the command mutates money. The transfer command posts only after both account balances and both ledger entries are updated under ordered locks.

Entity Identification

WalletService

Facade and transaction boundary. Owns wallets, accounts, idempotency keys, ledger storage, account locking order, policy checks, and post-commit notifications.

walletsaccountstransactionsByIdempotencyKeyledgercurrencyPolicyobservers

Wallet

User-facing container that groups one user's accounts. It validates that added accounts belong to the wallet and returns defensive account maps.

idownerIdaccounts

Account

Currency-specific balance holder. It exposes a safe balance read and package-level locked credit/debit operations used only by the service.

idwalletIdcurrencybalanceInCentslock

Transaction

Lifecycle record for a credit, debit, or transfer. It stores idempotency key, type, amount, endpoints, status, timestamps, and attached ledger entries.

ididempotencyKeytypeamountInCentsstatusledgerEntries

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.

INITIATEDPENDINGPOSTEDFAILEDREVERSED

LedgerEntry

Immutable journal row. A posted wallet movement has one debit row and one credit row, tied by the same transaction id.

idtransactionIdaccountIdtypeamountInCentscurrencycreatedAt

TransactionCommand

Command interface for credit, debit, and transfer requests. Each concrete command captures request parameters and calls the service apply method.

idempotencyKeyexecute(service)

CurrencyPolicy

Strategy interface for currency compatibility. The default policy allows only same-currency transfers and exact account-currency matches.

validateSinglevalidateTransfer

TransactionObserver

Observer seam for receipts, notifications, analytics, or fraud hooks after a transaction posts.

onTransactionPosted(transaction)

Design Patterns Used

State

TransactionStatus owns legal transitions. The transaction lifecycle is explicit and invalid jumps fail fast instead of being hidden in service conditionals.

Command

CreditCommand, DebitCommand, and TransferCommand encapsulate request parameters and execution. This makes retries, logging, authorization, and queuing easier to add around the same command interface.

Observer

TransactionObserver lets the service notify receipts, analytics, or fraud listeners after a posted transaction without coupling core money movement to side effects.

Strategy

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

  1. 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.

  2. 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;
        }
    }
  3. 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);
  4. 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();
        }
    }
  5. 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;
    }
  6. 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

Loading…

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.

StepActionTransaction statusAlice balanceBob balanceLedger
1Receive transfer-001INITIATED100002500no entries
2Pass idempotency check and lock Alice then BobPENDING100002500no entries
3Debit Alice and credit BobPENDING85004000not appended yet
4Append double-entry rowsPOSTED85004000DEBIT Alice 1500, CREDIT Bob 1500
5Retry transfer-001POSTED85004000same 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

OperationTimeSpaceNote
createWallet / createAccountO(1)O(1)Hash map insert plus a small wallet-account association.
credit / debitO(1)O(1)Idempotency lookup, one account lock, two ledger entries.
transferO(1)O(1)Two account locks acquired in deterministic order and two ledger entries appended.
balanceO(1)O(1)Reads the cached account balance under the account lock.
ledgerEntriesO(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. 1.Why does the transfer acquire account locks in deterministic id order?

  2. 2.What does the idempotency key protect against?

  3. 3.Which pair of ledger entries represents a wallet-to-wallet transfer?

  4. 4.Why is **TransactionStatus** better than a free-form status string?

  5. 5.Why should observers run after POSTED instead of before balance mutation?

Practice Variants

Add pending holds

Advanced

Support authorization holds where available balance decreases before posted balance. Add HELD and RELEASED states and verify idempotent capture.

Cross-currency transfer

Advanced

Implement an FX CurrencyPolicy that stores the quote id and creates balanced ledger rows in both currencies.

Persistent wallet repositories

Intermediate

Replace 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 ConsistencyMartin Kleppmann
  • BookPatterns of Enterprise Application Architecture — Unit of WorkMartin Fowler
  • DocsStripe API Docs — Idempotent Requests