The 100 problems, solved / Commerce and money / 076 · Design a digital wallet

Design a digital wallet

Accounts, transfers that never lose money, statements and limits.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements and use cases

A wallet holds money for a user and moves it around without ever creating or losing a rupee. The actors are the account holder and the system itself.

  • A holder opens a wallet and tops it up from an external source.
  • A holder transfers money to another wallet; the transfer either happens fully or not at all.
  • A holder reads a balance and lists a statement of past entries, newest first, by page.
  • The system enforces limits: a per-transaction cap and a rolling daily cap.
  • A client that retries a transfer after a timeout must not move the money twice.

Out of scope: external bank rails, currency conversion, fraud scoring and multi-process persistence. Non-functional constraints: the balance invariant must hold under many threads (two transfers touching the same wallets at once), a transfer must be idempotent by client key, and the design must extend to a new limit rule without touching the transfer code.

2. Core entities and relationships

  • Money: an immutable amount in minor units (paise), so arithmetic is exact and a negative amount is impossible to construct.
  • Wallet: an account with an id, a balance and the ledger entries that explain it; owns its lock.
  • LedgerEntry: one immutable line (credit or debit, amount, counterparty, time, transfer id); the balance is the sum of entries.
  • Transfer: the unit of work with a client-supplied idempotency key and a state (pending, completed, failed).
  • LimitPolicy: an interface that says yes or no to a proposed debit; concrete policies for per-transaction and daily caps.
  • WalletService: the orchestrator that locks in order, checks limits, writes both entries and records the transfer by key.
ValuesCorePolicies**Moneypaise: intadd(other)sub(other)LedgerEntrykind: EntryKindamount: Moneytransfer_id: strat: floatWalletid: strbalance: Moneyentries: listlock: Lockapply(entry)WalletServicewallets: dicttransfers: dictpolicies: listtransfer(key, src, dst, amount)statement(id, page)Transferkey: strstate: TransferStateLimitPolicycheck(wallet, amount)DailyCapcap: MoneyPerTransactionCapcap: Money

A wallet composes its ledger entries: an entry has no meaning outside the wallet it belongs to, and deleting a wallet deletes its history. The service only aggregates wallets, because wallets outlive any one service instance in a real system. Policies are injected, so the service never names a concrete rule.

3. Interfaces and key methods

class Money:
    """Immutable amount in paise; constructing a negative amount raises."""
    def __init__(self, paise: int) -> None: ...
    def add(self, other: "Money") -> "Money": ...
    def sub(self, other: "Money") -> "Money": ...   # raises if result would be negative

class LimitPolicy(Protocol):
    def check(self, wallet: "Wallet", amount: Money) -> None:
        """Raise LimitExceeded if the debit must be refused; otherwise return."""

class Wallet:
    def apply(self, entry: "LedgerEntry") -> None:
        """Append one entry and move the balance. Caller must hold self.lock."""
    def debits_since(self, since: float) -> Money:
        """Sum of debits after a timestamp, for rolling caps."""

class WalletService:
    def open(self, wallet_id: str) -> "Wallet": ...
    def top_up(self, key: str, wallet_id: str, amount: Money) -> "Transfer": ...
    def transfer(self, key: str, src: str, dst: str, amount: Money) -> "Transfer":
        """Idempotent by key: a repeated key returns the first result, moving nothing."""
    def statement(self, wallet_id: str, page: int, size: int = 20) -> list["LedgerEntry"]: ...

Invariants: for every wallet, balance equals the sum of its entries; for every completed transfer there is exactly one debit entry and one credit entry carrying the same transfer_id; and transfers[key] is written once and never changes state backwards.

4. Design patterns and the choices behind them

Money as a value object

Amounts vary in size but never in meaning, and the bug that matters most is a float rounding error or a negative balance sneaking in. An immutable value object in integer paise makes both impossible by construction. The rejected alternative, a plain float field on the wallet, is shorter and wrong: 0.1 + 0.2 is not 0.3 in binary.

A ledger, not a mutable number

If the balance is only a number, a statement has to be rebuilt from somewhere else and a disputed transfer cannot be explained. Recording every movement as an append-only domain event (the ledger entry) and deriving the balance from it gives statements for free and makes reconciliation a sum. The cost is memory per entry, which is the point at which a real system pages old entries to storage.

Idempotency keys

Clients retry. The service keeps a map from client key to the finished Transfer and answers a repeated key with the stored result, so a timeout followed by a retry cannot double-debit. This is idempotency inside one process; the key must be reserved before the money moves, or two concurrent retries both slip through.

Ordered locking

A transfer touches two wallets. Taking both locks in id order means two transfers in opposite directions cannot each hold one lock and wait for the other, which is the classic deadlock. One global lock would also be correct, but it serialises every transfer in the system, including ones that share no wallet.

5. Core flow and code

  1. The client calls transfer(key, src, dst, amount).
  2. The service reserves the key under registry_lock; if it already exists, it returns the stored transfer.
  3. It acquires the two wallet locks in sorted id order.
  4. Each LimitPolicy checks the source wallet; a refusal marks the transfer failed and releases the key as failed.
  5. It builds a debit entry and a credit entry with the same transfer id and applies them; the balance invariant holds because Money.sub raises before anything is written.
  6. It marks the transfer completed and returns it.
from __future__ import annotations
import itertools, threading, time
from dataclasses import dataclass, field
from enum import Enum
from typing import Protocol


class LimitExceeded(Exception): ...
class InsufficientFunds(Exception): ...


@dataclass(frozen=True)
class Money:
    paise: int
    def __post_init__(self) -> None:
        if self.paise < 0:
            raise ValueError("negative money")
    def add(self, other: Money) -> Money: return Money(self.paise + other.paise)
    def sub(self, other: Money) -> Money:
        if other.paise > self.paise:
            raise InsufficientFunds()
        return Money(self.paise - other.paise)


class EntryKind(Enum):
    CREDIT = "credit"
    DEBIT = "debit"


class TransferState(Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    FAILED = "failed"


@dataclass(frozen=True)
class LedgerEntry:
    kind: EntryKind
    amount: Money
    counterparty: str
    transfer_id: str
    at: float


@dataclass
class Transfer:
    key: str
    transfer_id: str
    state: TransferState = TransferState.PENDING
    reason: str = ""


class Wallet:
    def __init__(self, wallet_id: str) -> None:
        self.id = wallet_id
        self.balance = Money(0)
        self.entries: list[LedgerEntry] = []
        self.lock = threading.Lock()

    def apply(self, entry: LedgerEntry) -> None:
        if entry.kind is EntryKind.DEBIT:
            self.balance = self.balance.sub(entry.amount)
        else:
            self.balance = self.balance.add(entry.amount)
        self.entries.append(entry)

    def debits_since(self, since: float) -> Money:
        total = 0
        for e in reversed(self.entries):
            if e.at < since:
                break
            if e.kind is EntryKind.DEBIT:
                total += e.amount.paise
        return Money(total)


class LimitPolicy(Protocol):
    def check(self, wallet: Wallet, amount: Money) -> None: ...


@dataclass
class PerTransactionCap:
    cap: Money
    def check(self, wallet: Wallet, amount: Money) -> None:
        if amount.paise > self.cap.paise:
            raise LimitExceeded("per-transaction cap")


@dataclass
class DailyCap:
    cap: Money
    def check(self, wallet: Wallet, amount: Money) -> None:
        spent = wallet.debits_since(time.time() - 86_400).paise
        if spent + amount.paise > self.cap.paise:
            raise LimitExceeded("daily cap")


class WalletService:
    def __init__(self, policies: list[LimitPolicy]) -> None:
        self.wallets: dict[str, Wallet] = {}
        self.transfers: dict[str, Transfer] = {}
        self.policies = policies
        self.registry_lock = threading.Lock()
        self._ids = itertools.count(1)

    def open(self, wallet_id: str) -> Wallet:
        with self.registry_lock:
            return self.wallets.setdefault(wallet_id, Wallet(wallet_id))

    def _reserve(self, key: str) -> tuple[Transfer, bool]:
        with self.registry_lock:
            if key in self.transfers:
                return self.transfers[key], False
            t = Transfer(key=key, transfer_id=f"t{next(self._ids)}")
            self.transfers[key] = t
            return t, True

    def transfer(self, key: str, src: str, dst: str, amount: Money) -> Transfer:
        t, fresh = self._reserve(key)
        if not fresh:
            return t
        a, b = self.wallets[src], self.wallets[dst]
        first, second = sorted((a, b), key=lambda w: w.id)
        with first.lock, second.lock:
            try:
                for p in self.policies:
                    p.check(a, amount)
                now = time.time()
                a.apply(LedgerEntry(EntryKind.DEBIT, amount, dst, t.transfer_id, now))
                b.apply(LedgerEntry(EntryKind.CREDIT, amount, src, t.transfer_id, now))
                t.state = TransferState.COMPLETED
            except (LimitExceeded, InsufficientFunds) as e:
                t.state, t.reason = TransferState.FAILED, type(e).__name__
        return t

    def statement(self, wallet_id: str, page: int, size: int = 20) -> list[LedgerEntry]:
        w = self.wallets[wallet_id]
        with w.lock:
            newest_first = list(reversed(w.entries))
        return newest_first[page * size:(page + 1) * size]

6. Extensions, concurrency and what interviewers probe

  • Top-up and withdrawal are one-sided transfers against a system wallet, so the same code path and the same ledger invariant cover them; add a reserved wallet id rather than a special case.
  • A new limit (say, a monthly cap or a per-counterparty cap) is a new LimitPolicy appended to the list; transfer does not change, which is the open-closed point of the design.
  • Where the atomic step is: reserving the key under registry_lock before touching wallets. If reservation happened after the debit, two retries could both debit. Interviewers often ask what happens when the process dies between the debit and the credit; in one process with both locks held the two apply calls cannot interleave with another transfer, and in a persistent version both entries go in one storage transaction.
  • Why not one lock: it is correct and simple, and the right first answer for a 45-minute round; say that per-wallet locks in id order are the step up and name the deadlock they avoid.
  • Test first: a transfer that exceeds the balance leaves both wallets untouched; a repeated key returns the same transfer id; two threads transferring A to B and B to A a thousand times end with the same total.
  • Common mistake: computing daily spend by scanning the whole ledger on every transfer. The scan from the newest entry stops at the first old one, so it is proportional to today's entries, and a running daily counter with a reset is the next step if that is still too slow.

02Where this question was reported

  • GitHub · awesome-low-level-design, Medium
  • Codemia · Codemia Easy; 'Banking System' Medium
  • LeetCode Discuss · LeetCode 2043 Simple Bank System, Google-tagged (freq 10.6)
  • thita.ai · thita.ai 'Digital Wallet', advanced
  • GitHub · Machine coding 'Ledger company' (Navi)