Interview Setup
Interview Prompt
Design a digital wallet where users top up, send P2P transfers, pay merchants, and withdraw to bank, maintaining balances that are always correct and never negative.
Clarifying Questions (ask before designing)
| Question | Why it matters |
|---|---|
| Strong consistency on balance or eventual with reconciliation? |
|
| Same-shard vs cross-shard P2P transfers? |
|
| What happens on duplicate transfer request (network retry)? | Idempotency-Key header + UNIQUE constraint prevents double debit at 50M txns/day. |
| KYC level gates on transaction limits? |
|
Scope
In scope
- Double-entry ledger
- Balance consistency (serializable)
- Top-up, P2P, merchant payment, withdrawal
- Idempotency and fraud velocity checks
- KYC integration boundaries
Out of scope (state explicitly)
- Fraud ML model training
- Building a payment service provider
- Merchant onboarding portal
- Institutional banking ledger: chart of accounts, trial balance, and 7-year audit requirements
Functional Requirements
Consumer wallet UX: top-up, P2P transfer, and balance display. For institutional double-entry ledger and compliance depth, see Banking Ledger System.
Start by confirming which wallet flows are in scope: top-up, P2P, merchant pay, withdrawal. Ask about KYC limits and whether cross-shard transfers need a saga for this round.
- Wallet balance: Maintain a monetary balance per user
- Top-up: Add funds via bank transfer, credit card, or external payment
- Send money (P2P): Transfer funds between wallet users instantly
- Pay merchants: Pay at checkout using wallet balance
- Transaction history: View all credits, debits, and transfers with details
- Withdraw: Cash out wallet balance to bank account
- Multi-currency: Hold balances in multiple currencies with conversion
- Rewards/cashback: Credit rewards directly to wallet
- Spending limits: Configurable daily/monthly transaction limits
Non-Functional Requirements
Your interviewer will care most about ACID balance consistency and idempotent money movement. State the invariant early: a wallet balance must never go negative, and duplicate requests must return the same ledger entry.
- Strong Consistency (ACID): Balance must NEVER go negative; no double-spend
- Effectively-once posting: At-least-once delivery with idempotency keys: duplicate requests return the same ledger entry
- Low Latency: P2P transfer completes in < 500 ms
- High Availability: 99.999%: wallet is the user's money
- Auditability: Every balance change has an immutable audit trail (double-entry ledger)
- Security: PCI DSS, encryption at rest/transit, fraud detection
- Scale: 100M+ wallets, 50M+ transactions/day
Capacity Estimations
Run this math before you shard the ledger. Transactions per day and double-entry volume tell you PostgreSQL write pressure; peak QPS drives SERIALIZABLE contention planning.
| Metric | Calculation | Value |
|---|---|---|
| Total wallets | Given | 100M |
| Active wallets (monthly) | Given | 30M |
| Transactions / day | Given (assumption documented in value) | 50M |
| Transactions / sec (peak) | Derived from daily volume ÷ 86400 (+ peak factor) | ~600 (peak 5K) |
| Ledger entries / day | 50M txns x 2 (double-entry) | 100M (double-entry) |
| Ledger storage / year | Given | ~18 TB |
| Redis keys (active) | Given | ~65M |
Architecture Diagram
In the room: lead with correctness over speed: a double-charge is worse than 500 ms latency, making idempotency the headline pattern.
Walk your interviewer through the money-movement flows first. A digital wallet is a consumer-facing balance product: users top up, send P2P transfers, pay merchants, and withdraw. The ledger mechanics overlap with the double-entry accounting principles in Banking Ledger System and multi-currency mechanics in Multi-Currency Payment, but here the interview focus is product flows: idempotency, KYC limits, cross-shard P2P sagas, and fraud velocity, rather than institutional bookkeeping like trial balance or regulatory close. For distributed transaction guarantees, review Distributed Transactions: 2PC vs Saga.
Component Deep Dives
Double-Entry Ledger: The Foundation
Next we walk through each box on the diagram. I start with double-entry ledger invariants because every flow (top-up, P2P, and withdrawal) must balance debits and credits.
Double-entry is non-negotiable for money systems; explain why SUM(entries) = 0 is your self-check before your interviewer asks about reconciliation.
Every money movement creates balanced debit and credit entries. This is the same invariant explored in Banking Ledger System, but scoped to user wallets rather than a full chart of accounts. The wallet service owns user-facing balances; platform fee and external-bank accounts are internal ledger accounts.
Every transaction creates TWO ledger entries: Debit from one account, Credit to another. Sum of all entries = 0. P2P Transfer: Alice sends $50 to Bob Entry 1: Alice's wallet DEBIT -$50 Entry 2: Bob's wallet CREDIT +$50 Sum: 0 (Balanced) Top-up: Alice adds $100 from bank Entry 1: Alice's wallet CREDIT +$100 Entry 2: External_bank DEBIT -$100 Sum: 0 (Balanced) Merchant Payment: Alice pays $25 to Merchant M Entry 1: Alice's wallet DEBIT -$25 Entry 2: Merchant M's wallet CREDIT +$24.25 Entry 3: Platform fee account CREDIT +$0.75 (3% commission) Sum: 0 (Balanced)
Why double-entry? Self-validating (SUM=0), complete audit trail, regulatory requirement, reconciliation.
Atomic Balance Update: Preventing Double-Spend
P2P transfers need SERIALIZABLE isolation or SELECT FOR UPDATE, because interviewers will ask how you prevent double-spend under concurrent requests.
Critical: Two concurrent requests to spend Alice's last $50
Solution: PostgreSQL serializable transaction + row lock:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM wallets WHERE user_id = 'alice' FOR UPDATE;
IF balance >= 50 THEN
UPDATE wallets SET balance = balance - 50 WHERE user_id = 'alice';
INSERT INTO ledger_entries (wallet_id, type, amount, ...) VALUES (...);
ELSE
RAISE 'Insufficient funds';
END IF;
COMMIT;Idempotent Transactions
Idempotency keys are non-negotiable at 50M transactions/day: walk through Redis fast-path, UNIQUE constraint, and FOR UPDATE as three defense layers.
User clicks "Send $50" but network timeout. Client retries.
Every API call includes idempotency_key (generated client-side per action):
POST /api/v1/wallet/transfer
Idempotency-Key: "txn-uuid-abc-123"
{ "to_user_id": "bob", "amount": 50.00 }
Server:
1. Check: SELECT * FROM transactions WHERE idempotency_key = 'txn-uuid-abc-123'
2. If not exists -> process transfer -> store result with idempotency_key
3. Redis: SET idempotency:{key} {result} EX 86400Three layers of defense: (1) Redis fast-path < 1ms, (2) PostgreSQL UNIQUE constraint, (3) Application-level FOR UPDATE within transaction.
Multi-Currency Wallet
User can hold balances in multiple currencies (USD, EUR, GBP, INR). wallet_balances table has ONE row per (user_id, currency) pair. Cross-currency transfers use FX rate service cached in Redis with 30s TTL.
Settlement and Reconciliation
Daily batch process: Net position calculation, bank settlement via ACH/SEPA, reconciliation matching bank confirmations against ledger, merchant settlement weekly/daily.
Event Bus Design (Kafka)
Topic: txn-events
Partitions: 256
Partition key: wallet_id (preserves per-wallet transaction ordering)
Retention: 30 days (compliance + replay)
Replication factor: 3, min.insync.replicas: 2
Producer: Transactional outbox (same DB txn as ledger INSERT; never direct publish)
Event: { txn_id, wallet_id, type, amount, currency, counterparty_id, balance_after, timestamp }
Consumer groups:
1. notification: email/SMS/push on every balance change
2. fraud-detection: velocity, geo-anomaly, mule network scoring
3. analytics: ClickHouse txn volume trends
4. kyc-aml: sanctions screening on large transfers
5. settlement: daily batch net positions to bank gateway
Sync path: double-entry ledger INSERT + outbox row in single ACID txn < 100ms
Async path: outbox poller publishes to Kafka; consumers at-least-once + idempotent
DLQ: txn-events-dlq; alert if outbox unpublished rows > 1000API Design
Digital Wallet REST APIs
Endpoints for top-up, P2P money transfer, withdrawal, and multi-currency balance queries with mandatory idempotency key headers.
POST /api/v1/wallet/top-up
Idempotency-Key: "topup-uuid"
{ "amount": 100.00, "currency": "USD", "source": "bank_transfer", "source_ref": "bank-txn-id" }
Response: 200 OK
{ "transaction_id": "txn-uuid", "new_balance": 150.00, "status": "pending" }
POST /api/v1/wallet/transfer
Idempotency-Key: "transfer-uuid"
{ "to_user_id": "bob-uuid", "amount": 50.00, "currency": "USD", "note": "Lunch" }
Response: 200 OK
{ "transaction_id": "txn-uuid", "new_balance": 100.00 }
POST /api/v1/wallet/withdraw
Idempotency-Key: "withdraw-uuid"
{ "amount": 200.00, "currency": "USD", "bank_account_id": "ba-uuid" }
Response: 200 OK
{ "transaction_id": "txn-uuid", "new_balance": 75.00, "status": "pending" }
GET /api/v1/wallet/balance
Response: 200 OK
{ "balances": [{"currency": "USD", "available": 75.00, "pending": 0.00}] }Common Error Responses
400 Bad Request: invalid input, missing required fields, or malformed JSON payload 401 Unauthorized: missing or invalid authentication token or API key 403 Forbidden: authenticated caller lacks required permissions for this resource 404 Not Found: requested resource ID does not exist 409 Conflict: duplicate write or version conflict, retry with a unique idempotency key 422 Unprocessable Entity: syntactically valid request failed semantic business validation 429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header 500 Internal Error: unexpected server failure, retry safely with an idempotency key 503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff 402 Payment Required: account balance or payment method has insufficient funds 502 Bad Gateway: payment gateway provider timeout, poll transaction status endpoint
Data Model
PostgreSQL: Source of Truth
CREATE TABLE wallets (
wallet_id UUID PRIMARY KEY,
user_id UUID UNIQUE NOT NULL,
status ENUM('active','frozen','closed','pending_kyc') NOT NULL DEFAULT 'active',
kyc_level ENUM('none','basic','verified','enhanced') DEFAULT 'none',
daily_limit DECIMAL(10,2) DEFAULT 5000.00,
monthly_limit DECIMAL(12,2) DEFAULT 50000.00,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE wallet_balances (
wallet_id UUID NOT NULL REFERENCES wallets,
currency CHAR(3) NOT NULL,
balance DECIMAL(15,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
pending_in DECIMAL(15,2) DEFAULT 0,
pending_out DECIMAL(15,2) DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (wallet_id, currency)
);
CREATE TABLE ledger_entries (
entry_id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL,
wallet_id UUID NOT NULL,
currency CHAR(3) NOT NULL,
entry_type ENUM('debit','credit') NOT NULL,
amount DECIMAL(15,2) NOT NULL,
balance_after DECIMAL(15,2) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
INDEX idx_wallet (wallet_id, created_at DESC),
INDEX idx_transaction (transaction_id)
) PARTITION BY RANGE (created_at);
CREATE TABLE transactions (
transaction_id UUID PRIMARY KEY,
idempotency_key VARCHAR(64) UNIQUE,
type ENUM('topup','transfer','payment','withdrawal','reward','refund') NOT NULL,
from_wallet_id UUID,
to_wallet_id UUID,
amount DECIMAL(15,2) NOT NULL,
currency CHAR(3) NOT NULL,
status ENUM('pending','completed','failed','reversed') NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE outbox (
outbox_id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
INDEX idx_unpublished (published, created_at) WHERE published = FALSE
);Redis
balance:{user_id}:{currency} → DECIMAL (cached balance), TTL 60s
idempotency:{key} → JSON (cached response), TTL 86400s
daily_spent:{user_id} → DECIMAL (INCRBY on each spend), TTL resets at midnight
fraud:velocity:{user_id} → ZSET (timestamps of recent txns), TTL 300s
fraud:device:{user_id} → SET of known device fingerprintsFault Tolerance
| Concern | Solution |
|---|---|
| Double-spend |
|
| Duplicate transaction | Idempotency key with UNIQUE constraint + Redis fast-path check |
| Ledger inconsistency |
|
| DB failover |
|
| Kafka publish failure | Transactional outbox pattern: event stored in DB atomically with txn |
| Cross-shard P2P transfer |
|
Partial Failure in P2P Transfer
SAME-SHARD (Alice and Bob on same PostgreSQL shard):
PostgreSQL transaction wraps BOTH operations:
BEGIN; UPDATE alice balance; INSERT ledger; UPDATE bob balance; INSERT ledger; COMMIT;
If any step fails → entire transaction rolls back.
CROSS-SHARD (Alice on shard 1, Bob on shard 2):
Cannot use single DB transaction across shards. Use saga pattern:
Step 1: Debit Alice (shard 1)
BEGIN; UPDATE alice balance -= 50; INSERT ledger; INSERT txn status='debit_done'; COMMIT;
Step 2: Credit Bob (shard 2)
BEGIN; UPDATE bob balance += 50; INSERT ledger; UPDATE txn status='completed'; COMMIT;
If Step 2 fails:
Compensating transaction on shard 1:
BEGIN; UPDATE alice balance += 50; INSERT reversal ledger; UPDATE txn status='reversed'; COMMIT;Transactional Outbox vs Direct Kafka Publish
Direct Kafka publish (naive approach):
1. BEGIN; UPDATE balance; INSERT ledger; COMMIT;
2. kafka.send("txn-events", event) // Failure point without transaction atomicity
✗ No atomicity between DB commit and event publish.
Transactional Outbox (Recommended):
1. BEGIN;
UPDATE balance; INSERT ledger; INSERT INTO outbox (event_payload);
COMMIT; // all-or-nothing, including the outbox row
2. Background poller: SELECT * FROM outbox WHERE published = FALSE ORDER BY created_at;
3. For each: kafka.send(event) → on success: UPDATE outbox SET published = TRUE;
✓ Atomicity: outbox row committed with the transaction
✓ At-least-once delivery: poller retries until published = TRUEAdditional Considerations
Interview Walkthrough
- 25-minute cut
Skip arch50/arch75 depth unless staff.
- Correctness over speed: idempotency is headline (5 min)
- Consumer wallet flows vs institutional banking ledger scope (6 min)
- P2P transfer as cross-shard saga with compensations (5 min)
- Three-layer idempotency: Redis, UNIQUE key, FOR UPDATE (5 min)
- KYC tier limits enforced at transfer time (4 min)
- Lead with correctness over speed: a double-charge is worse than 500 ms latency, and idempotency is the most critical pattern.
- Clarify scope boundaries: this article covers consumer wallet flows (top-up, P2P, merchant pay), whereas Banking Ledger System covers institutional ledger needs such as chart of accounts, trial balance, and 7-year audit logs.
- Walk through P2P transfer as a cross-shard saga: debit sender → credit suspense → debit suspense → credit recipient with compensating rollback.
- Explain three-layer idempotency: Redis fast-path check, PostgreSQL UNIQUE on idempotency_key, application-level FOR UPDATE lock.
- Cover KYC tier limits enforced at transfer time: Level 1 ($500/day) through Level 3 ($50K/day) based on verification depth.
- Mention transactional outbox for Kafka events: never publish balance-change notifications outside the DB transaction.
- Discuss real-time fraud tier (velocity, geo-anomaly) blocking transfers before ledger write, with batch graph analysis nightly.
- Common pitfall: updating balance with read-modify-write without row locking, concurrent withdrawals on a $500 balance could both succeed at $400 each.
Regulatory Compliance (KYC / AML / CTR)
- KYC: Level 1 (email and phone: $500/day), Level 2 (government ID: $5K/day), Level 3 (address and income: $50K/day)
- AML: Transaction monitoring, SAR filing, sanctions screening (OFAC/EU), PEP screening
- CTR: Required for cash transactions > $10,000/day
- Data retention: 7 years minimum (BSA requirement)
- Money transmitter license required per US state; e-money license for EU (EMD2)
Fraud Detection: Real-Time + Batch
Tier 1 (Real-time): Rule-based checks: velocity, amount anomaly, geo-anomaly, device fingerprint, recipient risk, time anomaly. Redis-backed with sliding window counters.
Tier 2 (Batch): ML model + graph analysis: network analysis (mule accounts), circular transfer detection, behavioral profiling. Spark + Kafka + ClickHouse.
Engineering Trade-offs
Why PostgreSQL (Not DynamoDB/Cassandra) for Wallets
Wallet ledgers trade ACID guarantees against horizontal scale, balancing PostgreSQL sharding against eventual balance snapshots.
PostgreSQL (Recommended): - ACID with serializable isolation for critical paths - CHECK(balance >= 0): database rejects any update that would violate this - Multi-row atomic: debit Alice + credit Bob + 2 ledger entries + outbox = 1 commit - Rich query support for compliance and reconciliation - Partitioning: ledger_entries partitioned by month for fast queries DynamoDB: - Has transactions (up to 100 items in TransactWriteItems) - No CHECK constraints → application must enforce balance >= 0 - No JOINs → reconciliation queries become application-level nightmares Cassandra (Critically Inappropriate): - No multi-row transactions → CANNOT atomically debit + credit - Eventual consistency → two reads might show different balances → Financial regulators would reject this architecture
Sharding Strategy
Shard key: user_id (hash-based). With 64 shards, ~98.5% of P2P transfers are cross-shard, so the saga pattern is the common case. Saga adds ~50ms latency: well within 500ms SLA.
Idempotency: The Most Critical Pattern for Wallets
At 50M txns/day x 2% retry rate = 1M retries/day. Without idempotency: 1M potential double-charges per day. Three layers: Redis fast-path, PostgreSQL UNIQUE, application FOR UPDATE.
Review
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.