Design a Digital Wallet

MediumE-Commerce & PaymentsLedgerIdempotencyDistributed TransactionsStrong Consistency

Problem

Design a digital wallet app. Each user has a stored balance in one currency. Users top up their wallet from a linked bank card, send money to other users instantly, and withdraw to their bank account. Users see their balance and a transaction history. A transfer must never create or destroy money, even if a request is retried or a server crashes mid-transfer. The wallet serves 50 million users and processes about 1 million transfers per minute at peak.

  • Is the wallet single-currency, or must transfers convert between currencies?
  • Are peer-to-peer transfers instant and final, or can the recipient decline?
  • Are there daily limits or fraud checks before a transfer completes?
  • Should a social feed of transactions be shown, or only a private history?
  • Can a balance ever go negative, for example after a chargeback on a top-up?
  • How long must transaction history be retained?

Items in bold are existing requirements. The rest are suggestions. Confirm with the interviewer whether they are relevant first.

  • Show each user their current balance.
  • Top up the wallet from a linked card or bank account.
  • Send money to another user instantly.
  • Withdraw balance to a linked bank account.
  • Show a paginated transaction history.
  • Enforce per-user daily transfer limits and basic fraud checks.
  • Notify the recipient when money arrives.

Items in bold are existing requirements. The rest are suggestions. Confirm with the interviewer whether they are relevant first.

  • Money is conserved: total balances change only through top-ups and withdrawals.
  • Peak of 1 million transfers per minute, about 17,000 per second.
  • Transfer latency under 500 ms at p99.
  • Idempotent APIs: a retried transfer request is applied at most once.
  • Ledger storage: 17,000 transfers per second at peak, about 300 million per day on average, each with two 150 byte entries, is roughly 90 GB per day.
  • 99.99% availability; during a partial outage, reject transfers rather than risk inconsistent balances.

The wallet is a double-entry ledger where every transfer is one transaction that debits the sender's account and credits the recipient's account, with a materialized balance per account for fast reads. Accounts are sharded by user id; when sender and recipient live on different shards, the transfer runs as a saga with a reserved hold so money is never double-spent or lost. Top-ups and withdrawals cross the boundary to external payment rails and are posted only after the external side confirms.

Loading diagram...

Send money. The app sends POST /transfers with recipient, amount, and an idempotency key. The wallet service checks limits with the risk service, then asks the ledger to transfer. If both accounts are on the same shard, the ledger runs one local transaction: lock both balance rows in a fixed order, verify the sender has funds, insert a debit and a credit entry, and update both balances. If they are on different shards, the ledger first places a hold on the sender's shard, then credits the recipient's shard, then finalizes the hold as a debit. Every step is keyed by the transfer id, so retries are safe.

Top up. The app sends POST /topups. The funding service charges the linked card through the processor using the top-up id as idempotency key. Only after the processor confirms does the ledger post a debit to the processor clearing account and a credit to the user.

Withdraw. The ledger first moves the amount from the user to a pending withdrawal account, so it cannot be spent twice. The funding service then sends the bank transfer. On confirmation the pending amount moves to the bank clearing account; on failure it is returned to the user.

History and notifications. Every committed ledger transaction is published to the event stream. The history service builds a per-user timeline, and the notification service pushes "you received $20" to the recipient.

Authoritative record of every account and every movement, with materialized balances.

  • PostgreSQL sharded by user id, or a distributed SQL database such as CockroachDB or Spanner if cross-shard transactions should be handled by the database.

Data Model

  • accounts(id, user_id, currency, balance_minor, available_minor, version)
  • transactions(id, idempotency_key UNIQUE, type, status, created_at)
  • entries(id, transaction_id, account_id, amount_minor, direction, created_at)
  • holds(id, transaction_id, account_id, amount_minor, status)

Scalability

At 17,000 transfers per second, sharding by user id across 32 or more shards keeps each shard well under its write limit. A hash of user id spreads load evenly. System accounts that receive many writes, such as the card processor clearing account, are split into many sub-accounts to avoid a hot row.

Tradeoffs

Materialized balances are kept alongside immutable entries because summing history on every read would be slow, while entries keep the balance auditable. Locking both rows in ascending account id order prevents deadlocks between two opposite transfers.

Coordinates a transfer when the two accounts live on different shards, without a distributed lock or two-phase commit.

  • Saga orchestrator with a durable state table, driven by a workflow engine such as Temporal or a simple state machine in the wallet service.

Diagram

Loading diagram...

Scalability

Each saga touches exactly two shards and has no global coordinator bottleneck, so throughput scales with the number of shards.

Tradeoffs

Two-phase commit was rejected because a coordinator failure leaves locks held on both shards. The saga instead uses a hold, which reduces the sender's available balance immediately so the money cannot be spent twice, and every step is idempotent and retried until it completes. Each shard posts balanced entries against its own in-transit account (sender to in-transit on shard A, in-transit to recipient on shard B), so money is conserved at every step and the two in-transit accounts net to zero once the saga finishes. A nonzero in-transit total older than a few minutes signals a stuck saga.

Public API for balances, transfers, top-ups, and withdrawals. It enforces idempotency and orchestrates the other services.

API

  • GET /wallet returns { balance, available, currency }.
  • POST /transfers with Idempotency-Key and { toUserId, amount, note? } returns { transferId, status }.
  • POST /topups and POST /withdrawals with Idempotency-Key and { amount, fundingSourceId }.
  • GET /transactions?cursor= returns history.

Scalability

The service is stateless and scales horizontally. Idempotency keys are stored in the ledger's transactions table, so a retry lands on the same row regardless of which instance handles it.

Tradeoffs

Rejecting a transfer when the ledger shard is unavailable is preferred over queueing it optimistically, because telling a user that money was sent and then reversing it later damages trust more than a clear error.

Bridges the wallet and external money movement: card charges for top-ups and bank transfers for withdrawals.

  • Adapters for card processors and bank transfer rails, with webhook handlers and a daily reconciliation job.

Scalability

External calls are a small fraction of total volume, since most activity is wallet to wallet. Workers are scaled by queue depth.

Tradeoffs

The ledger is credited only after external confirmation for top-ups, and debited before the external call for withdrawals. Both orderings make the wallet the side that waits, so an external failure can never leave a user holding money the platform did not receive.

Read-optimized per-user transaction timeline.

  • Cassandra or DynamoDB, partitioned by user id and sorted by time.

Data Model

  • user_history(user_id, created_at, transaction_id, counterparty, amount_minor, direction, note)

Scalability

History reads vastly outnumber ledger writes, and serving them from a separate store keeps the ledger shards free for transfers.

Tradeoffs

History is eventually consistent with the ledger by a second or two. The balance shown in the app is always read from the ledger, so the brief lag only affects the list of past transactions.

Related questions