Design a digital wallet
Accounts, transfers that never lose money, statements and limits.
- Value objects and immutability — Objects defined by their value, never changed, safely shared.
- Idempotent operations — Making a repeated call harmless inside a single process too.
- Locks and mutexes — Making a critical section one-at-a-time, and what it costs.
- Domain events — Recording what happened as objects, so other parts can react later.
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.
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
- The client calls
transfer(key, src, dst, amount). - The service reserves the key under
registry_lock; if it already exists, it returns the stored transfer. - It acquires the two wallet locks in sorted id order.
- Each
LimitPolicychecks the source wallet; a refusal marks the transfer failed and releases the key as failed. - It builds a debit entry and a credit entry with the same transfer id and applies them; the balance invariant holds because
Money.subraises before anything is written. - 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
LimitPolicyappended to the list;transferdoes not change, which is the open-closed point of the design. - Where the atomic step is: reserving the key under
registry_lockbefore 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 twoapplycalls 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 spendby 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)