System Design Problem

Design a Digital Wallet System

Commonly Asked By:PayPalBlockGoogleApple

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)

QuestionWhy it matters
Strong consistency on balance or eventual with reconciliation?
  • Money paths require serializable transactions
  • eventual balance is unacceptable for regulators.
Same-shard vs cross-shard P2P transfers?
  • Cross-shard needs saga
  • same-shard is single PostgreSQL transaction.
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?
  • Unverified wallets capped at $500/day
  • enhanced KYC unlocks $50K/month.

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.

MetricCalculationValue
Total walletsGiven100M
Active wallets (monthly)Given30M
Transactions / dayGiven (assumption documented in value)50M
Transactions / sec (peak)Derived from daily volume ÷ 86400 (+ peak factor)~600 (peak 5K)
Ledger entries / day50M txns x 2 (double-entry)100M (double-entry)
Ledger storage / yearGiven~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.

Loading...

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 86400

Three 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 > 1000

API Design

Digital Wallet REST APIs

Endpoints for top-up, P2P money transfer, withdrawal, and multi-currency balance queries with mandatory idempotency key headers.

HTTP
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

SQL
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 fingerprints

Fault Tolerance

ConcernSolution
Double-spend
  • SELECT FOR UPDATE row lock
  • CHECK(balance >= 0) constraint as safety net
Duplicate transactionIdempotency key with UNIQUE constraint + Redis fast-path check
Ledger inconsistency
  • Double-entry: SUM(all entries) must = 0
  • hourly automated reconciliation
DB failover
  • Synchronous replication to standby
  • zero data loss on failover
Kafka publish failureTransactional outbox pattern: event stored in DB atomically with txn
Cross-shard P2P transfer
  • Saga with compensating transaction (debit → credit
  • if credit fails → reverse debit)

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 = TRUE

Additional 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

Help Us Improve

How helpful was this walkthrough?

Click a star to rate. We actively use this feedback to refine and update our system design content.

Placeholder
Optional but highly appreciated!

Discussion

Share your thoughts, ask questions, or help others.

Loading comments...