Chapter 12: Design a Digital Wallet

volume2 digital-wallet fintech consistency balance

Status: 🟩 Interview ready
Difficulty: Very Hard
Time to complete: 60 min read + practice


Overview

A digital wallet stores money electronically and lets users transfer funds instantly between each other β€” think Venmo, Cash App, Google Pay balance, or the PayPal balance feature. Unlike a payment system (which moves money via external card networks), a digital wallet manages internal balances: the money lives inside the platform and moves between ledger entries.

Why this matters:

  • Common interview question at fintech companies and companies with peer-to-peer features
  • Forces you to think deeply about distributed transactions, consistency, and failure handling
  • Teaches 2-Phase Commit, Saga pattern, event sourcing, and CQRS β€” foundational distributed systems patterns
  • The hardest constraint: exactly-once transfer (no double debit, no lost money)

Problem Statement

Design a digital wallet service where:

  • Users can store a monetary balance
  • Users can transfer money to each other instantly
  • Transfers are atomic and exactly-once (no double debit, no lost credits)
  • Balances are always accurate (strong consistency)
  • System is highly available (99.99%)

Step 1: Requirements & Scope (5 min)

Functional Requirements

Clarifying questions:

  • Transfer between users? β†’ Yes, peer-to-peer (Venmo-style)
  • Load money in / withdraw money out? β†’ Yes (but assume the pay-in/pay-out layer exists separately)
  • Scale? β†’ 1 million transactions per day
  • Multi-currency? β†’ Yes, handle multiple currencies with conversion
  • Historical balance? β†’ Yes, users can view transaction history
  • Is this a standalone wallet or part of a larger payment platform? β†’ Standalone wallet service

Scope:

  • Transfer money between two user wallets
  • Query wallet balance
  • View transaction history
  • Exactly-once transfer with idempotency
  • Concurrent transfers handled correctly

Non-Functional Requirements

  • Exactly-once transfer: No double debit under any failure scenario
  • Strong consistency: A user’s balance must always reflect all completed transfers
  • High availability: 99.99% β€” wallets must be accessible
  • Durability: No money lost even if servers crash
  • Auditability: Every balance change immutably recorded
  • Low latency: < 500ms for transfer confirmation
  • Read-your-writes: After a transfer, the sender sees their updated balance immediately

Scale Estimates

Transactions per day: 1,000,000
Average TPS: ~12
Peak TPS: ~100

Per transfer: 2 ledger entries (debit + credit)
Ledger entries/day: 2,000,000
Ledger entries/year: ~730 million

Balance snapshot frequency: every 1,000 events per user
Users: 10 million
Active wallets stored in DB: 10 million rows

Step 2: High-Level Design (10 min)

Core Entities

Wallet: Each user has a wallet with a balance per currency
Transfer: Intent to move money (with idempotency key)
Ledger Entry: Immutable record of every balance change

The Distributed Transaction Challenge

The hardest part of a digital wallet is that User A’s wallet and User B’s wallet may live on different database nodes (when data is sharded). Deducting from A and crediting B must be atomic β€” either both happen or neither happens.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       The Distributed Transaction Problem                       β”‚
β”‚                                                                 β”‚
β”‚  User A wallet: DB Node 1 (shard A-M)                          β”‚
β”‚  User B wallet: DB Node 2 (shard N-Z)                          β”‚
β”‚                                                                 β”‚
β”‚  Transfer $50 from A to B:                                      β”‚
β”‚    Step 1: Debit A by $50   β†’ DB Node 1                        β”‚
β”‚    Step 2: Credit B by $50  β†’ DB Node 2                        β”‚
β”‚                                                                 β”‚
β”‚  What if Step 1 succeeds but Step 2 fails?                      β”‚
β”‚    β†’ A lost $50, B never got it β€” MONEY LOST! ❌               β”‚
β”‚                                                                 β”‚
β”‚  What if we crash between Step 1 and Step 2?                    β”‚
β”‚    β†’ Same problem β€” partial execution ❌                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Three Approaches to Distributed Transactions

We evaluate three progressively sophisticated approaches:

ApproachConsistencyAvailabilityComplexityRecommended?
2-Phase Commit (2PC)StrongLow (blocking)MediumOnly for small scale
Saga PatternEventualHighHighGood for microservices
Event Sourcing + CQRSStrong (per user)HighHighestBest for financial wallets

Basic Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Digital Wallet Platform                 β”‚
β”‚                                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Wallet    β”‚   β”‚   Transfer    β”‚   β”‚    Ledger     β”‚  β”‚
β”‚  β”‚  Service   β”‚   β”‚   Service     β”‚   β”‚    Service    β”‚  β”‚
β”‚  β”‚            β”‚   β”‚ (Orchestrator)β”‚   β”‚ (Append-only) β”‚  β”‚
β”‚  β”‚  - Balance β”‚   β”‚  - Create     β”‚   β”‚  - Events     β”‚  β”‚
β”‚  β”‚    queries β”‚   β”‚    transfers  β”‚   β”‚  - History    β”‚  β”‚
β”‚  β”‚  - Balance β”‚   β”‚  - Idempotent β”‚   β”‚  - Snapshots  β”‚  β”‚
β”‚  β”‚    updates β”‚   β”‚  - State mgmt β”‚   β”‚               β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚              Event Store (Append-only)           β”‚    β”‚
β”‚  β”‚     All wallet events: CREDITED, DEBITED         β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

API Design

Transfer money:

POST /v1/wallet/transfers

Request:
{
  "from_user_id": "user_A",
  "to_user_id": "user_B",
  "amount": 5000,          // In cents
  "currency": "USD",
  "idempotency_key": "550e8400-e29b-41d4-a716-446655440000",
  "note": "Dinner split"
}

Response (202 Accepted):
{
  "transfer_id": "txn_xyz789",
  "status": "PENDING",
  "created_at": "2026-04-13T10:00:00Z"
}

Query balance:

GET /v1/wallet/{user_id}/balance

Response:
{
  "user_id": "user_A",
  "balances": [
    { "currency": "USD", "amount": 15000 },  // $150.00
    { "currency": "EUR", "amount": 8000 }    //  €80.00
  ]
}

Step 3: Deep Dive (25 min)

Approach 1: Distributed Transactions with 2-Phase Commit (2PC)

How it works:

2PC involves a coordinator that orchestrates commit or abort across multiple database nodes (participants).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    2-Phase Commit (2PC)                         β”‚
β”‚                                                                 β”‚
β”‚  Phase 1 β€” Prepare ("Can you commit?")                          β”‚
β”‚                                                                 β”‚
β”‚  Coordinator β†’ DB Node 1: "Prepare debit A $50 β€” can you?"     β”‚
β”‚  Coordinator β†’ DB Node 2: "Prepare credit B $50 β€” can you?"    β”‚
β”‚                                                                 β”‚
β”‚  DB Node 1: Acquires lock, writes to WAL, responds "YES"       β”‚
β”‚  DB Node 2: Acquires lock, writes to WAL, responds "YES"       β”‚
β”‚                                                                 β”‚
β”‚  Phase 2 β€” Commit (or Abort)                                   β”‚
β”‚                                                                 β”‚
β”‚  If ALL responded YES:                                          β”‚
β”‚    Coordinator β†’ DB Node 1: "COMMIT"                           β”‚
β”‚    Coordinator β†’ DB Node 2: "COMMIT"                           β”‚
β”‚    Both apply changes, release locks                           β”‚
β”‚                                                                 β”‚
β”‚  If ANY responded NO:                                           β”‚
β”‚    Coordinator β†’ DB Node 1: "ABORT"                            β”‚
β”‚    Coordinator β†’ DB Node 2: "ABORT"                            β”‚
β”‚    Both rollback, release locks                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Failure modes of 2PC:

Problem 1: Coordinator failure after Phase 1
  - Nodes are locked waiting for Phase 2 decision
  - Resources blocked indefinitely until coordinator recovers
  - This is the "blocking" problem of 2PC

Problem 2: Network partition
  - Node 1 committed, Node 2 never got the commit message
  - Inconsistent state: A debited, B not credited

Problem 3: Both nodes down
  - If a node crashes between Prepare and Commit,
    on recovery it checks its WAL and waits for coordinator
  - If coordinator is also down, the node is stuck

When 2PC is acceptable:

  • Both shards in the same datacenter (low latency, low partition risk)
  • Single relational DB with distributed transactions (CockroachDB, Google Spanner)
  • Scale is small enough that blocking is not a concern
  • Example: PostgreSQL with cross-shard transactions using a distributed DB

2PC summary:

AttributeValue
ConsistencyStrong
AvailabilityLow (blocking on coordinator failure)
LatencyMultiple round trips
ScalabilityPoor (locks resources across nodes)
Best forSmall scale, same-datacenter deployments

Approach 2: Saga Pattern (Preferred for Microservices)

The idea: Break the distributed transaction into a sequence of local transactions. Each step publishes an event. If any step fails, execute compensating transactions to undo previous steps.

Two flavors of Saga:

Choreography-based Saga (Event-driven, no central coordinator)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          Choreography Saga: A pays B $50                     β”‚
β”‚                                                              β”‚
β”‚  1. Transfer Service creates PENDING transfer                 β”‚
│     Publishes event: TransferInitiated{A→B, $50}             │
β”‚               ↓                                              β”‚
β”‚  2. Wallet Service for A listens:                            β”‚
β”‚     Debit A $50, publishes: MoneyDebited{A, $50, transfer_id}β”‚
β”‚               ↓                                              β”‚
β”‚  3. Wallet Service for B listens:                            β”‚
β”‚     Credit B $50, publishes: MoneyCredited{B, $50, transfer_id}β”‚
β”‚               ↓                                              β”‚
β”‚  4. Transfer Service listens:                                β”‚
β”‚     Marks transfer SUCCESS                                   β”‚
β”‚                                                              β”‚
β”‚  On failure at step 3 (Credit B fails):                      β”‚
β”‚  Publishes: CreditFailed{B, $50, transfer_id}                β”‚
β”‚               ↓                                              β”‚
β”‚  Wallet Service for A listens:                               β”‚
β”‚  Compensating transaction: Credit A back $50                 β”‚
β”‚  (Reversal entry in ledger)                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Pros: No central coordinator, each service is fully independent, highly available.
Cons: Hard to debug (events scattered across services), no easy global view of saga state, complex error paths.

Orchestration-based Saga (Central orchestrator manages steps)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          Orchestration Saga: A pays B $50                    β”‚
β”‚                                                              β”‚
β”‚          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
β”‚          β”‚      Transfer Saga Orchestrator      β”‚            β”‚
β”‚          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
β”‚                          β”‚                                   β”‚
β”‚    Step 1: Debit A       β”‚        Step 2: Credit B           β”‚
β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” ←──────── β”‚ ──────────→ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚    β”‚Wallet A β”‚           β”‚             β”‚Wallet B β”‚          β”‚
β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β”‚             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚         OK               β”‚                  OK              β”‚
β”‚    ──────────→           β”‚             ←──────────          β”‚
β”‚                          β”‚                                   β”‚
β”‚    Step 3: Mark SUCCESS  β”‚                                   β”‚
β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ←──── β”‚                                   β”‚
β”‚    β”‚ Transfer DB β”‚       β”‚                                   β”‚
β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚                                   β”‚
β”‚                                                              β”‚
β”‚    If Step 2 (Credit B) fails:                               β”‚
β”‚    Orchestrator calls COMPENSATE on Step 1:                  β”‚
β”‚      β†’ Credit A back $50 (compensating transaction)         β”‚
β”‚      β†’ Mark transfer FAILED                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Pros: Central visibility of saga state, easier to debug, explicit compensation logic.
Cons: Orchestrator is a single point of logic (not of failure if persisted), can become a bottleneck.

Compensating transactions β€” key concept:

In Saga, you cannot "rollback" across service boundaries.
Instead, you write a compensating transaction:
  - Debit A succeeded β†’ Compensating: Credit A back
  - Credit B failed β†’ No compensation needed (it never applied)

Compensating transactions are:
  - NEW ledger entries (not rollbacks)
  - Idempotent (safe to retry)
  - Must always succeed (can't fail a compensation!)

Saga summary:

AttributeValue
ConsistencyEventual (brief window of inconsistency during saga)
AvailabilityHigh (no distributed locks)
LatencyLow (local transactions only)
ScalabilityExcellent
Best forMicroservices across different databases

Core idea: Never store the current balance directly. Instead, store every event that affected the balance (append-only). Balance = replay of all events. Use CQRS to separate writes (events) from reads (materialized balance view).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Event Sourcing for Wallet                       β”‚
β”‚                                                              β”‚
β”‚  Traditional approach (WRONG for financial):                 β”‚
β”‚  UPDATE wallets SET balance = balance - 50 WHERE user='A'    β”‚
β”‚  β†’ Overwrites history, hard to audit                         β”‚
β”‚                                                              β”‚
β”‚  Event Sourcing approach (CORRECT):                          β”‚
β”‚  INSERT INTO events (user_id, type, amount, transfer_id)     β”‚
β”‚             VALUES ('A', 'DEBITED', -50, 'txn_xyz')          β”‚
β”‚             VALUES ('B', 'CREDITED', +50, 'txn_xyz')         β”‚
β”‚  β†’ Never update, only append                                 β”‚
β”‚  β†’ Balance = SUM of all events for that user                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Event store schema:

CREATE TABLE wallet_events (
    event_id      BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id       VARCHAR(255) NOT NULL,
    event_type    ENUM('CREDITED', 'DEBITED') NOT NULL,
    amount        BIGINT NOT NULL,          -- Always positive
    direction     ENUM('IN', 'OUT') NOT NULL,
    transfer_id   VARCHAR(255) NOT NULL,
    currency      CHAR(3) NOT NULL,
    created_at    TIMESTAMP DEFAULT NOW(),
    -- NO updated_at, NO deletes
    INDEX idx_user_id (user_id, created_at)
);

Computing balance from events:

-- Balance for user A in USD:
SELECT SUM(CASE WHEN direction = 'IN' THEN amount
                WHEN direction = 'OUT' THEN -amount END)
  FROM wallet_events
 WHERE user_id = 'A'
   AND currency = 'USD';

Problem: Computing balance by replaying ALL events is O(n) and gets slower as history grows.

Solution: Snapshots

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Snapshot Pattern                            β”‚
β”‚                                                              β”‚
β”‚  Every N events (e.g., 1000), create a balance snapshot:    β”‚
β”‚                                                              β”‚
β”‚  snapshot_at_event_5000: { balance: 15000, currency: USD }  β”‚
β”‚                                                              β”‚
β”‚  Current balance = snapshot_balance + SUM(events after 5000)β”‚
β”‚                                                              β”‚
β”‚  Instead of replaying 10,000 events,                        β”‚
β”‚  replay only the last ~1000 since the snapshot.             β”‚
β”‚                                                              β”‚
β”‚  Snapshot table:                                             β”‚
β”‚  CREATE TABLE wallet_snapshots (                             β”‚
β”‚      user_id       VARCHAR(255),                            β”‚
β”‚      currency      CHAR(3),                                 β”‚
β”‚      balance       BIGINT,                                  β”‚
β”‚      last_event_id BIGINT,  -- Snapshot covers events ≀ thisβ”‚
β”‚      created_at    TIMESTAMP,                               β”‚
β”‚      PRIMARY KEY (user_id, currency, last_event_id)         β”‚
β”‚  );                                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

CQRS β€” Command Query Responsibility Segregation:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   CQRS Architecture                          β”‚
β”‚                                                              β”‚
β”‚  COMMAND SIDE (Writes)                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                        β”‚
β”‚  β”‚  Transfer API    β”‚  ← POST /transfers                     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                        β”‚
β”‚           β”‚ Validate + write events                          β”‚
β”‚           ↓                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                        β”‚
β”‚  β”‚   Event Store    β”‚  (append-only, single source of truth) β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                        β”‚
β”‚           β”‚ Publish events                                   β”‚
β”‚           ↓                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                        β”‚
β”‚  β”‚ Event Processor  β”‚  (async, builds read models)           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                        β”‚
β”‚           β”‚                                                  β”‚
β”‚  QUERY SIDE (Reads)                                          β”‚
β”‚           ↓                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                        β”‚
β”‚  β”‚  Balance View DB β”‚  ← Materialized current balances       β”‚
β”‚  β”‚ (pre-computed)   β”‚  ← Updated by event processor         β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                        β”‚
β”‚           ↑                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                        β”‚
β”‚  β”‚  Balance API     β”‚  ← GET /wallet/balance                 β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why CQRS for wallets:

  • Write path: append event to immutable store (fast, simple)
  • Read path: query pre-computed balance from materialized view (fast O(1) lookup)
  • Event processor rebuilds read model if it gets corrupted β€” replay all events
  • Read and write paths can scale independently

Event Sourcing + CQRS summary:

AttributeValue
ConsistencyStrong per-user (events ordered), eventual across users
AvailabilityHigh (writes go to append-only store)
AuditabilityPerfect (full history always available)
ComplexityHighest
ScalabilityExcellent (shard event store by user_id)
Best forFinancial wallets requiring audit trail

Deep Dive: Distributed Locking for Concurrent Transfers

Problem: Two concurrent transfers from User A’s wallet:

Transfer 1: A β†’ B $50 (A has $100)
Transfer 2: A β†’ C $80 (A has $100)

Both read balance: $100
Both check: $100 > $50 βœ… and $100 > $80 βœ…
Both debit A:
  A's balance: $100 - $50 = $50 (Transfer 1)
  A's balance: $100 - $80 = $20 (Transfer 2)
  Actual A balance: $20 (double spent $130 from a $100 wallet!) ❌

Solution 1: Database-level locking (SELECT FOR UPDATE)

BEGIN;
SELECT balance FROM wallets WHERE user_id = 'A' FOR UPDATE;
-- Only this transaction holds the lock
-- Update, check, debit
UPDATE wallets SET balance = balance - 50 WHERE user_id = 'A';
COMMIT;
-- Lock released, next transaction proceeds

Solution 2: Optimistic locking (version number)

-- Read with version
SELECT balance, version FROM wallets WHERE user_id = 'A';
-- balance=100, version=5
 
-- Update only if version hasn't changed
UPDATE wallets SET balance = 50, version = 6
 WHERE user_id = 'A' AND version = 5;  -- Compare-and-swap
 
-- If 0 rows updated: conflict detected, retry

Solution 3: Redis distributed lock (for cross-service locking)

-- Acquire lock on user A's wallet
SET lock:wallet:A <random_value> NX EX 5
-- NX: only set if not exists (atomic)
-- EX 5: auto-release in 5 seconds (prevent deadlock)

-- If acquired: proceed with transfer
-- If not acquired: retry after backoff

-- Release lock (only if we own it)
if redis.get("lock:wallet:A") == random_value:
    redis.delete("lock:wallet:A")

Recommendation: Use database-level locking (SELECT FOR UPDATE) for single-node wallets. Use Redis distributed lock or optimistic locking for cross-service scenarios. With event sourcing, use optimistic concurrency (version check on event_id) to prevent concurrent writes.


Deep Dive: Consistency Guarantees

Read-your-writes (critical for UX):

Problem:
  User A transfers $50 β†’ write goes to primary DB
  User A checks balance β†’ read goes to replica (lag!)
  User A sees OLD balance β†’ confusing UX

Solution: Route each user's reads to the primary after writes
  - After a write, client stores "last_write_timestamp"
  - Read requests include "min_freshness": last_write_timestamp
  - Read routed to primary if replica lag > threshold
  - Or: always route same user's requests to same node (sticky reads)

Monotonic reads:

Problem:
  User B receives $50 transfer
  Request 1 β†’ replica 1: balance = $150 (has the credit)
  Request 2 β†’ replica 2: balance = $100 (hasn't caught up yet)
  Balance goes backward!

Solution: Route reads to same replica per session
  Or: use primary for balance queries

Complete Transfer Flow (Event Sourcing approach)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Transfer $50 from User A to User B                       β”‚
β”‚                                                                  β”‚
β”‚  1. POST /v1/wallet/transfers                                     β”‚
β”‚     {from: A, to: B, amount: 50, idempotency_key: uuid}          β”‚
β”‚               ↓                                                  β”‚
β”‚  2. Transfer Service: Check idempotency key (already done?)       β”‚
β”‚     β†’ New β†’ Continue                                             β”‚
β”‚               ↓                                                  β”‚
β”‚  3. Transfer Service: Check A's balance β‰₯ $50                    β”‚
β”‚     (Query materialized balance view β€” fast O(1))                β”‚
β”‚               ↓                                                  β”‚
β”‚  4. Transfer Service: Write to Event Store (ATOMIC):             β”‚
β”‚     a. INSERT event: A DEBITED $50, transfer_id=xyz              β”‚
β”‚     b. INSERT event: B CREDITED $50, transfer_id=xyz             β”‚
β”‚     c. INSERT transfer record: status=SUCCESS                    β”‚
β”‚     (All in ONE database transaction on the event store)         β”‚
β”‚               ↓                                                  β”‚
β”‚  5. Transfer Service: Store idempotency_key with result          β”‚
β”‚               ↓                                                  β”‚
β”‚  6. Return 200: { transfer_id: xyz, status: SUCCESS }            β”‚
β”‚               ↓                                                  β”‚
β”‚  7. (Async) Event Processor reads new events:                    β”‚
β”‚     β†’ Updates materialized balance view:                         β”‚
β”‚        A's balance: 10000 - 5000 = 5000                          β”‚
β”‚        B's balance: 8000 + 5000 = 13000                          β”‚
β”‚               ↓                                                  β”‚
β”‚  8. (Async) Notification Service: Notify A and B via push/email  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key insight: Steps 4a, 4b, 4c happen in ONE atomic DB transaction on the event store. Because both events are on the same DB (even if users are on different shards, the event store can co-locate them by transfer_id), this is a LOCAL transaction β€” no distributed transaction needed.


Design Summary

Final Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   Digital Wallet Platform                     β”‚
β”‚                                                               β”‚
β”‚  Client ──→ API Gateway ──→ Transfer Service                  β”‚
β”‚                                  β”‚                            β”‚
β”‚                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
β”‚                    ↓             ↓              ↓             β”‚
β”‚              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚              β”‚Idempotencyβ”‚ β”‚ Balance  β”‚  β”‚  Event Store β”‚    β”‚
β”‚              β”‚  Cache   β”‚  β”‚ Check DB β”‚  β”‚ (append-only)β”‚    β”‚
β”‚              β”‚ (Redis)  β”‚  β”‚(read mdl)β”‚  β”‚              β”‚    β”‚
β”‚              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                  β”‚            β”‚
β”‚                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”     β”‚
β”‚                                    β”‚  Event Processor   β”‚     β”‚
β”‚                                    β”‚  (async consumer)  β”‚     β”‚
β”‚                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                                                  β”‚            β”‚
β”‚                             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”     β”‚
β”‚                             β”‚  Materialized Balance View β”‚     β”‚
β”‚                             β”‚  (Pre-computed per user)   β”‚     β”‚
β”‚                             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Decisions Summary

DecisionChoiceReasoning
Transaction approachEvent sourcing + CQRSAuditability, replay, no distributed lock needed
Avoiding 2PCLocal event store transactionBoth A’s debit and B’s credit in one DB transaction
ConcurrencyOptimistic locking on event_idNo blocking locks, retry on conflict
Read performanceMaterialized balance viewO(1) balance query, not O(n) event replay
Performance at scaleSnapshots every 1,000 eventsBounds replay cost without losing history
Distributed lockingRedis SET NX (only if needed)Fast, auto-expiring, prevents double debit
IdempotencyUUID per transfer, stored in cachePrevents double transfer on retry
ConsistencyRead-your-writes via primary routingUser always sees own latest balance

Interview Questions & Answers

Q: Why is 2PC not recommended for a digital wallet at scale?
A: 2PC is a blocking protocol. After Phase 1 (Prepare), all database nodes hold locks and wait for Phase 2 (Commit/Abort). If the coordinator crashes after Phase 1, all nodes are blocked indefinitely until the coordinator recovers. In a high-scale system, this blocks thousands of concurrent transfers. Also, 2PC has multiple network round-trips per transaction, adding latency. For a wallet at scale, prefer event sourcing where both debit and credit events are written in a single local DB transaction, completely avoiding cross-shard coordination.

Q: Explain Saga pattern and compensating transactions.
A: A Saga breaks a distributed transaction into a sequence of local transactions, each publishing an event on success. If any step fails, the saga executes compensating transactions on all previously completed steps. For a wallet transfer: Step 1 debits A (local transaction), Step 2 credits B (local transaction). If Step 2 fails, a compensating transaction credits A back. Compensating transactions are new ledger entries (not rollbacks) and must always succeed. The Saga pattern avoids distributed locks but introduces brief eventual consistency.

Q: What is event sourcing and why is it ideal for financial systems?
A: Event sourcing stores every state change as an immutable event in an append-only log, rather than storing current state. Balance = sum/replay of all events. Ideal for financial systems because: (1) complete audit trail by design β€” every cent is always traceable, (2) time travel β€” reconstruct state at any past point by replaying events up to that time, (3) no destructive updates β€” regulatory compliance requirement, (4) event replay allows rebuilding any derived view (balance, history, analytics) if it gets corrupted. The trade-off is increased complexity and the need for snapshots to keep query performance acceptable.

Q: What is CQRS and how does it pair with event sourcing?
A: CQRS (Command Query Responsibility Segregation) separates the write model (commands that append events) from the read model (queries that read pre-computed views). With event sourcing: the command side appends events to the event store; an async event processor builds and updates materialized views (e.g., current balance table) from those events; the query side reads from the fast materialized view without touching the event store. Benefits: write and read paths scale independently, read queries are O(1) not O(n), event store is never burdened with ad-hoc read queries.

Q: How do you ensure strong consistency with event sourcing in a digital wallet?
A: Strong consistency per-user is achieved by using optimistic concurrency: each event has a sequence number, and a new event is only accepted if its expected_sequence = last_event_sequence + 1 (compare-and-swap). This serializes events per user. Cross-user consistency (e.g., A’s debit and B’s credit are atomic) is achieved by writing both events in a single local DB transaction keyed by transfer_id β€” not a distributed transaction. Read-your-writes is ensured by routing the sender’s balance query to the primary DB or using a consistency token.


Key Takeaways

  1. Exactly-once transfer requires idempotency keys (UUID per transfer, reused on retry) β€” same principle as payments.
  2. 2PC is blocking β€” if the coordinator fails after Prepare, all nodes are locked. Avoid at scale.
  3. Saga pattern avoids distributed locks by using local transactions and compensating transactions for rollback, but introduces eventual consistency.
  4. Event sourcing is the gold standard for financial wallets β€” append-only log, full audit trail, time travel, no destructive updates.
  5. CQRS pairs with event sourcing: command side writes events (slow-to-write, durable), query side reads pre-computed materialized views (fast O(1) reads).
  6. Snapshots make event sourcing practical β€” without them, replaying 10 years of events per balance query is infeasible.
  7. Distributed lock (Redis SET NX) is the escape hatch for concurrent transfers when optimistic locking would cause too many retries.


Practice this design! Know all three transaction approaches and their trade-offs. Be ready to:

  1. Draw the event sourcing + CQRS architecture
  2. Explain why 2PC blocks and when to avoid it
  3. Walk through a Saga with a compensating transaction
  4. Explain snapshot pattern for event sourcing performance
  5. Compare this design to the payment system design and articulate the key differences

Last Updated: 2026-04-13
Status: Very common in fintech interviews β€” Must know!