Chapter 12 · Article 53 of 55
Designing a Digital Wallet System
Design a digital wallet system (similar to PayTM Wallet, Google Pay balance, or Apple Pay Cash) that allows users to store money digitally, transfer funds peer-to-peer, pay merc…
Article outline15 sections on this page+
Problem Statement
Design a digital wallet system (similar to PayTM Wallet, Google Pay balance, or Apple Pay Cash) that allows users to store money digitally, transfer funds peer-to-peer, pay merchants, and maintain a complete transaction history. The system must guarantee financial consistency - no money is created or destroyed during transfers - while handling millions of concurrent transactions with low latency.
The core challenge lies in managing concurrent access to shared mutable state (wallet balances) in a distributed environment without sacrificing correctness or performance.
Requirements
Functional Requirements
- Add Money - Load funds into wallet from linked bank account, debit card, or credit card
- Send Money (P2P Transfer) - Transfer funds from one wallet to another user's wallet
- Pay Merchants - Make payments to registered merchants for goods/services
- Check Balance - View current available and pending balance in real-time
- Transaction History - View paginated list of all past transactions with filters (date, type, status)
- Refunds - Process full or partial refunds for failed/disputed transactions
- Wallet Limits - Enforce daily, monthly, and per-transaction limits based on KYC tier
- KYC Levels - Support tiered access (minimum KYC = low limits, full KYC = higher limits)
Non-Functional Requirements
- Strong Consistency - No double-spending; balance must never go negative unintentionally
- High Availability - 99.99% uptime; wallet operations should not have extended downtime
- Low Latency - Balance checks < 50ms, transfers < 500ms end-to-end
- Audit Trail - Every balance mutation must be traceable with immutable logs
- Security - Encryption at rest and in transit, fraud detection, rate limiting
- Concurrent Transaction Handling - Multiple simultaneous debits from the same wallet must be serialized correctly
Constraints & Assumptions
- Single currency per wallet (multi-currency is an extensibility point)
- Daily limit: ₹10,000 for minimum KYC, ₹1,00,000 for full KYC
- Monthly limit: ₹25,000 for minimum KYC, ₹5,00,000 for full KYC
- Per-transaction limit: ₹5,000 (minimum KYC), ₹50,000 (full KYC)
- Minimum balance: ₹0 (no overdraft facility)
- Transaction fees: 0% for P2P, 1-2% for merchant payments (borne by merchant)
- Maximum wallet balance: ₹10,000 (minimum KYC), ₹1,00,000 (full KYC) as per RBI guidelines
- All amounts stored as integers in smallest currency unit (paise) to avoid floating-point errors
- Idempotency keys required for all write operations
Key Entities & Relationships
| Entity | Description |
|---|---|
| User | Account holder with profile, auth credentials, KYC status |
| Wallet | Money container linked 1:1 with User; holds balance and version |
| Transaction | Immutable record of money movement (Credit, Debit, Transfer, Refund) |
| Balance | Current available amount + held/blocked amount |
| TransactionLog | Append-only audit log of every state change |
| PaymentMethod | Linked bank/card used to load money |
| Merchant | Business entity that accepts wallet payments |
| KYCLevel | Tier (MINIMUM, FULL) determining limits and features |
| Ledger | Double-entry bookkeeping record (every transaction = debit entry + credit entry) |
Relationships:
- User
1:1Wallet - Wallet
1:NTransaction - Transaction
1:2LedgerEntry (debit + credit) - User
1:NPaymentMethod - User
1:1KYCLevel - Transaction
N:1Merchant (for merchant payments)
ASCII Class Diagram
┌─────────────────┐ 1:1 ┌─────────────────────┐
│ User │─────────────────▶│ Wallet │
├─────────────────┤ ├─────────────────────┤
│ userId │ │ walletId │
│ name │ │ userId │
│ email │ │ balance (paise) │
│ phone │ │ heldAmount │
│ kycLevel │ │ version │
│ status │ │ currency │
│ createdAt │ │ status (ACTIVE/ │
└─────────────────┘ │ FROZEN) │
│ │ dailySpent │
│ 1:N │ monthlySpent │
▼ └─────────────────────┘
┌─────────────────┐ │ 1:N
│ PaymentMethod │ ▼
├─────────────────┤ ┌─────────────────────┐
│ methodId │ │ Transaction │
│ userId │ ├─────────────────────┤
│ type (BANK/CARD)│ │ txnId │
│ details │ │ walletId │
│ isDefault │ │ type (CREDIT/DEBIT/ │
└─────────────────┘ │ TRANSFER/ │
│ REFUND) │
│ amount │
│ status (PENDING/ │
│ SUCCESS/FAILED/ │
│ REVERSED) │
│ idempotencyKey │
│ counterpartyId │
│ description │
│ createdAt │
└─────────────────────┘
│ 1:2
▼
┌─────────────────────┐
│ LedgerEntry │
├─────────────────────┤
│ entryId │
│ txnId │
│ accountId │
│ entryType (DR/CR) │
│ amount │
│ balanceAfter │
│ createdAt │
└─────────────────────┘
Types of Locking Mechanisms
This is the most critical design concern for a digital wallet. When multiple requests attempt to modify the same wallet balance concurrently, we need a mechanism to prevent race conditions, lost updates, and double-spending. The choice of locking strategy directly impacts system throughput, consistency, and user experience.
Optimistic Locking
How It Works:
Optimistic locking assumes conflicts are rare. Every wallet row carries a version number (or updated_at timestamp). When a transaction reads the wallet, it notes the current version. At write time, it performs a conditional update: "UPDATE balance WHERE version = X". If another transaction modified the wallet in between, the version won't match, and the update fails - the caller must retry.
Pseudocode Implementation:
function transferMoney(fromWalletId, toWalletId, amount):
MAX_RETRIES = 3
retries = 0
while retries < MAX_RETRIES:
// Read current state
wallet = db.query("SELECT balance, version FROM wallets WHERE id = ?", fromWalletId)
if wallet.balance < amount:
throw InsufficientBalanceError
newBalance = wallet.balance - amount
newVersion = wallet.version + 1
// Conditional update - only succeeds if version unchanged
rowsAffected = db.execute(
"UPDATE wallets SET balance = ?, version = ? WHERE id = ? AND version = ?",
newBalance, newVersion, fromWalletId, wallet.version
)
if rowsAffected == 1:
// Success - proceed with credit to receiver
creditWallet(toWalletId, amount)
return SUCCESS
retries++
sleep(exponentialBackoff(retries))
throw ConflictError("Max retries exceeded")
When to Use:
- Low contention scenarios (most wallets aren't being written to simultaneously)
- Read-heavy workloads where most operations are balance checks
- When you want higher throughput and can tolerate occasional retries
- Suitable for wallets with infrequent transactions (most consumer wallets)
Pros: No lock held during processing, higher concurrency, no deadlock risk Cons: Retries under high contention, starvation possible for hot wallets
Pessimistic Locking
How It Works:
Pessimistic locking assumes conflicts are likely. Before reading or modifying a wallet, the transaction acquires an exclusive lock on the row. Other transactions attempting to access the same wallet block until the lock is released. This guarantees serial execution for a given wallet.
Pseudocode Implementation:
function transferMoney(fromWalletId, toWalletId, amount):
db.beginTransaction()
try:
// Acquire exclusive lock - blocks other transactions
wallet = db.query(
"SELECT balance, version FROM wallets WHERE id = ? FOR UPDATE",
fromWalletId
)
if wallet.balance < amount:
db.rollback()
throw InsufficientBalanceError
// Safe to modify - we hold the lock
db.execute(
"UPDATE wallets SET balance = balance - ? WHERE id = ?",
amount, fromWalletId
)
// Lock receiver wallet and credit
db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", toWalletId)
db.execute(
"UPDATE wallets SET balance = balance + ? WHERE id = ?",
amount, toWalletId
)
// Record ledger entries
insertLedgerEntries(fromWalletId, toWalletId, amount)
db.commit()
return SUCCESS
catch Exception:
db.rollback()
throw
When to Use:
- High contention scenarios (merchant wallets receiving many payments simultaneously)
- Write-heavy workloads where conflicts are frequent
- When correctness is more important than throughput
- Critical financial operations where retries are unacceptable
Pros: Guaranteed consistency, no retries needed, simpler reasoning Cons: Reduced concurrency, potential deadlocks, lock wait timeouts
Deadlock Prevention: Always acquire locks in a consistent order (e.g., by walletId ascending) to prevent circular waits:
function transferWithOrderedLocks(walletA, walletB, amount):
// Always lock lower ID first to prevent deadlocks
firstLock = min(walletA, walletB)
secondLock = max(walletA, walletB)
db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", firstLock)
db.query("SELECT * FROM wallets WHERE id = ? FOR UPDATE", secondLock)
// ... proceed with transfer
Distributed Locking
When the wallet system is distributed across multiple database instances or microservices, database-level locks are insufficient. We need distributed locks that work across nodes.
Redis-Based Locks (SETNX + TTL)
function acquireDistributedLock(walletId, requestId, ttlMs = 5000):
lockKey = "wallet_lock:" + walletId
// SETNX - Set if Not Exists, with expiry
acquired = redis.execute(
"SET", lockKey, requestId, "NX", "PX", ttlMs
)
return acquired == "OK"
function releaseDistributedLock(walletId, requestId):
lockKey = "wallet_lock:" + walletId
// Lua script for atomic check-and-delete (only release our own lock)
script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
redis.eval(script, lockKey, requestId)
function transferWithDistributedLock(fromWalletId, toWalletId, amount):
requestId = generateUUID()
if not acquireDistributedLock(fromWalletId, requestId):
throw LockAcquisitionFailed("Wallet busy, retry later")
try:
// Perform the actual transfer within the lock
wallet = db.query("SELECT balance FROM wallets WHERE id = ?", fromWalletId)
if wallet.balance < amount:
throw InsufficientBalanceError
db.execute("UPDATE wallets SET balance = balance - ?", amount, fromWalletId)
db.execute("UPDATE wallets SET balance = balance + ?", amount, toWalletId)
return SUCCESS
finally:
releaseDistributedLock(fromWalletId, requestId)
ZooKeeper-Based Locks
ZooKeeper provides stronger guarantees through sequential ephemeral nodes:
function acquireZkLock(walletId):
lockPath = "/locks/wallet/" + walletId
// Create sequential ephemeral node
myNode = zk.create(lockPath + "/lock-", EPHEMERAL_SEQUENTIAL)
while true:
children = zk.getChildren(lockPath)
sortedChildren = sort(children)
if myNode == sortedChildren[0]:
return true // We hold the lock
// Watch the node just before ours
previousNode = sortedChildren[indexOf(myNode) - 1]
zk.exists(previousNode, watch=true)
waitForWatch() // Block until predecessor is deleted
Challenges with Distributed Locks
| Challenge | Description | Mitigation |
|---|---|---|
| Clock Skew | TTL-based locks may expire prematurely if clocks drift | Use fencing tokens; Redlock algorithm across multiple Redis nodes |
| Network Partitions | Lock holder may be partitioned from the system | Ephemeral nodes (ZK) auto-expire; TTL on Redis locks |
| Split Brain | Two nodes believe they hold the lock | Fencing tokens validated at the database level |
| Lock Holder Crash | Process dies while holding lock | TTL ensures eventual release; ZK ephemeral nodes auto-delete |
| GC Pauses | Long GC pause causes lock to expire while still in use | Use fencing tokens; keep TTL > max expected pause |
Comparison Table: Locking Mechanisms
| Aspect | Optimistic | Pessimistic | Distributed (Redis) | Distributed (ZK) |
|---|---|---|---|---|
| Mechanism | Version check at write | Exclusive row lock | SETNX + TTL | Sequential ephemeral nodes |
| Performance | High (no blocking) | Medium (blocking) | High | Medium |
| Consistency | Eventual (retry-based) | Strong | Depends on implementation | Strong (with fencing) |
| Scalability | Excellent | Limited by DB connections | Excellent | Good |
| Failure Mode | Retry exhaustion | Deadlocks, timeouts | Lock expiry race | Session expiry |
| Complexity | Low | Low | Medium | High |
| Best For | Consumer wallets (low contention) | Merchant wallets (high contention) | Multi-service architectures | Critical financial ops |
| Deadlock Risk | None | Yes (mitigatable) | None | None |
| Throughput Under Contention | Degrades (retries) | Stable (queued) | High | Medium |
Recommendation for Digital Wallet: Use optimistic locking for consumer wallets (95% of wallets) and pessimistic locking for merchant/hot wallets. Use distributed locks when the transfer spans multiple microservices.
Design Decisions & Trade-offs
1. Double-Entry Bookkeeping vs Single Balance
| Approach | Pros | Cons |
|---|---|---|
| Single Balance | Simple, fast reads | Hard to audit, reconciliation issues |
| Double-Entry | Self-auditing, regulatory compliance, easy reconciliation | More writes, slightly complex |
Decision: Use double-entry bookkeeping. Every transaction creates two ledger entries (debit from one account, credit to another). The sum of all entries for a wallet must equal its current balance. This makes the system self-auditing - if SUM(credits) - SUM(debits) != current_balance, something is wrong.
2. Synchronous vs Asynchronous Transaction Processing
Decision: Synchronous for P2P transfers (user expects immediate confirmation), asynchronous for bank loads and refunds (external systems are slow). Use a saga pattern for multi-step operations with compensating transactions on failure.
3. Locking Strategy for Concurrent Transfers
Decision: Hybrid approach - optimistic locking by default with automatic escalation to pessimistic locking when retry count exceeds threshold. This gives best throughput for normal wallets while protecting hot wallets.
4. Idempotency for Payment Retries
Decision: Every mutation request carries a client-generated idempotencyKey. Before processing, check if this key exists in the idempotency store. If it does, return the cached response. Store entries with a 24-hour TTL.
5. Event Sourcing vs State-Based Balance
Decision: State-based balance (materialized) with event log for audit. Pure event sourcing requires replaying all events to compute balance - too slow for real-time balance checks. Instead, maintain a materialized balance updated atomically with each transaction, plus an append-only event log for reconstruction and auditing.
Design Patterns Used
| Pattern | Application |
|---|---|
| Strategy | Payment method selection - BankTransferStrategy, CardPaymentStrategy, UPIStrategy all implement PaymentMethodStrategy interface |
| Command | Each transaction is a command object (DebitCommand, CreditCommand, TransferCommand) that can be executed, validated, and logged uniformly |
| Observer | Transaction state changes trigger notifications - SMS, push notification, email via observer subscribers |
| State | Transaction lifecycle: INITIATED → PENDING → PROCESSING → SUCCESS/FAILED → REVERSED. Each state defines valid transitions |
| Factory | TransactionFactory.create(type, params) produces the correct transaction subtype with appropriate validation rules |
| Saga | Multi-step transfers use saga orchestrator with compensating actions for rollback |
Transaction Flow
Scenario: User A sends ₹500 to User B
User A API Gateway Wallet Service Database Notification
│ │ │ │ │
│─── Send ₹500 ──▶│ │ │ │
│ (idempKey=X) │ │ │ │
│ │── Validate ─────▶│ │ │
│ │ Auth + Limits │ │ │
│ │ │ │ │
│ │ │── BEGIN TXN ──────▶│ │
│ │ │ │ │
│ │ │── SELECT ... FOR ─▶│ │
│ │ │ UPDATE walletA │ (Lock Acquired) │
│ │ │◀── balance=2000 ──│ │
│ │ │ │ │
│ │ │── Check: 2000≥500 │ │
│ │ │ Sufficient │ │
│ │ │ │ │
│ │ │── UPDATE walletA ─▶│ │
│ │ │ balance = 1500 │ │
│ │ │ │ │
│ │ │── SELECT ... FOR ─▶│ │
│ │ │ UPDATE walletB │ (Lock Acquired) │
│ │ │ │ │
│ │ │── UPDATE walletB ─▶│ │
│ │ │ balance += 500 │ │
│ │ │ │ │
│ │ │── INSERT ledger ──▶│ │
│ │ │ (2 entries) │ │
│ │ │ │ │
│ │ │── COMMIT ─────────▶│ │
│ │ │ │ (Locks Released) │
│ │ │ │ │
│ │◀── Success ──────│ │ │
│◀── 200 OK ──────│ │ │ │
│ (txnId=T1) │ │── Notify ─────────────────────────────▶│
│ │ │ │ │
Edge Cases
| Edge Case | Handling Strategy |
|---|---|
| Concurrent transfers draining same wallet | Pessimistic lock ensures serial execution; second transfer sees updated (lower) balance and may fail with insufficient funds |
| Transfer to self | Validate fromWalletId != toWalletId at API layer; reject with 400 |
| Refund of a refund | Track originalTxnId chain; reject if transaction type is already REFUND |
| Partial failure (debit succeeded, credit failed) | Wrap in DB transaction - atomic commit/rollback. For distributed: saga with compensating credit-back |
| Race condition on balance check | Never check balance in application then update separately. Use UPDATE WHERE balance >= amount for atomic check-and-debit |
| Wallet frozen during KYC review | Check wallet status before any operation; return 403 with reason. Allow incoming credits but block debits |
| Duplicate request (network retry) | Idempotency key lookup returns cached response |
| Amount overflow | Use BIGINT for paise; validate max transaction amount at API layer |
| Negative amount in request | Validate amount > 0 at API layer |
Extensibility Points
- Multi-Currency Support - Add
currencyfield to wallet; use exchange rate service for cross-currency transfers; maintain separate balances per currency - Cashback & Rewards - Separate "cashback balance" with expiry; credit via async event after successful transaction
- Scheduled Payments - Scheduler service picks up future-dated transfers; executes at specified time with retry logic
- Split Payments -
SplitRequestentity divides amount among N wallets; executed as N individual transfers in a saga - Wallet-to-Bank Withdrawal - Async operation: debit wallet immediately, initiate bank transfer via payment gateway, handle settlement callback
- Interest on Balance - Daily batch job calculates interest on average balance; credits wallet monthly
Interview Follow-ups
Q1: How do you prevent double-spending?
Answer: Multiple layers of defense:
- Database-level: Atomic
UPDATE wallets SET balance = balance - amount WHERE id = ? AND balance >= amount- the WHERE clause makes check-and-debit atomic - Application-level: Pessimistic locking (
SELECT FOR UPDATE) serializes concurrent access to the same wallet - Idempotency: Duplicate requests with the same idempotency key return cached results without re-executing
- Ledger reconciliation: Background job verifies
SUM(ledger_entries) == wallet.balanceand flags discrepancies
Q2: What happens if the system crashes between debit and credit?
Answer: If both operations are in the same database transaction, the crash causes an automatic rollback - neither debit nor credit persists. If the system is distributed (wallets in different databases), we use the Saga pattern: the debit is recorded with status PENDING, and a saga orchestrator ensures the credit completes. If the credit fails after retries, a compensating transaction reverses the debit. The saga state is persisted, so recovery after crash resumes from the last checkpoint.
Q3: How would you handle a wallet receiving 10,000 transactions per second (hot merchant wallet)?
Answer: Several strategies:
- Batching: Aggregate incoming credits in a buffer and apply as a single update every 100ms
- Sharding the balance: Split the merchant wallet into N sub-wallets; distribute incoming payments round-robin; total balance = sum of sub-wallets
- Async crediting: Accept payment synchronously (debit sender), queue credit to merchant, process in batches
- Dedicated pessimistic lock path: Hot wallets detected automatically and routed to optimized write path
Q4: How do you handle multi-region deployment? (Hint only)
Hint: Consider single-leader replication for wallet writes with regional read replicas. Think about how you'd handle a user traveling between regions - would you redirect writes to the home region or migrate the wallet?
Q5: How would you implement spending analytics and budgeting features? (Hint only)
Hint: Think about CQRS - separate the read model (analytics) from the write model (transactions). Consider how you'd categorize transactions and compute aggregates without impacting the hot transaction path.
Counter Questions to Ask Interviewer
- Scale: How many active wallets? What's the peak TPS we're designing for?
- Geography: Single region or multi-region? Do we need to handle cross-border transfers?
- Regulatory: Which country's regulations apply? Do we need to implement specific KYC/AML requirements?
- Currency: Single currency or multi-currency? Do we need real-time exchange rates?
- Settlement: Real-time settlement or batch? What's the SLA for bank transfers?
- Existing infrastructure: Are we building on an existing payment gateway or from scratch?
- Fraud: What level of fraud detection is expected? Real-time blocking or post-facto analysis?
- Recovery: What's the RPO/RTO? Can we lose any transactions?
References
- Double-Entry Bookkeeping - Every financial transaction records equal debits and credits; the system is self-balancing. Used by all major financial systems for auditability.
- Distributed Transactions & Two-Phase Commit (2PC) - Protocol for atomic commits across multiple databases. High latency and blocking nature make it unsuitable for high-throughput wallets; prefer Saga pattern.
- Saga Pattern - Sequence of local transactions with compensating actions for rollback. Preferred over 2PC for microservice architectures. Two variants: choreography (event-driven) and orchestration (central coordinator).
- Redlock Algorithm - Martin Kleppmann's analysis of distributed locking with Redis; highlights the need for fencing tokens to prevent unsafe lock usage after expiry.
- Event Sourcing - Storing state changes as immutable events. Useful for audit trails but expensive for real-time balance computation. Hybrid approach (materialized balance + event log) is practical.
- CQRS (Command Query Responsibility Segregation) - Separate read and write models; useful for analytics on transaction data without impacting write throughput.
Word count: ~3700 words