Lesson 67 of 105 12 minFlagship

Project Case Study: Designing Stripe’s Ledger System

How does Stripe ensure 100% auditability and data integrity for millions of accounts? A deep dive into Double-Entry Bookkeeping, the Outbox Pattern, and Sharding.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Key Takeaways

  • **Immutable Audit Trail:** Every cent moved must be recorded forever.
  • **Precision:** 100% accuracy (no rounding errors).
  • **Global Support:** Transactions spanning multiple regions.
Recommended Prerequisites
System Design Interview Framework

Premium outcome

From vague architecture answers to staff-level trade-off thinking.

Backend engineers preparing for senior, staff, and architecture rounds.

What you unlock

  • A reusable system design answer framework for ambiguous prompts
  • Clear language for consistency, scaling, and reliability trade-offs
  • Case-study depth across feeds, payments, storage, and messaging systems

Recording transactions between millions of accounts is not a matter of simple arithmetic. In high-scale financial systems, a simple database update like UPDATE accounts SET balance = balance + amount is a dangerous anti-pattern. Doing so destroys the historical record, makes auditing impossible, and leads to catastrophic concurrency issues. Building a global financial ledger requires a system that is 100% auditable, ACID-compliant, and perfectly consistent.

A financial ledger acts as the single source of truth for all money movement. When you transfer funds, make a purchase, or receive a refund, every interaction must be logged with mathematical precision. The system must guarantee that money is never created or destroyed, and that every financial statement can be traced back to its origin through a logical chain of historical postings.


System Requirements

To design a ledger system at Stripe's scale, we must separate requirements into clear functional scope and strict non-functional SLAs.

Functional Requirements

  • Double-Entry Bookkeeping: Every transaction must consist of balanced journal entries containing debits and credits. The total sum of all debits must equal the total sum of all credits for any given transaction.
  • Immutable Audit Trail: Once a transaction is written, it can never be modified or deleted. Any corrections must be done via compensating transactions.
  • Real-time Balance Inquiries: The system must expose endpoints to query the historical and current settled balance of any account.
  • Multi-Currency Support: Support transactions containing multiple currencies while enforcing single-currency consistency per ledger account.
  • Audit and Reconciliation: The system must provide historical logs to verify that the sum of all transactions match the current account balances, allowing external auditors to run reconciliation checks at any time.

Non-Functional Requirements

  • Strict Consistency (ACID): Strong consistency is non-negotiable. Double-spending or balance mismatch anomalies must be physically impossible.
  • High Write Throughput: The system must sustain a continuous write throughput of greater than 5,000 transaction postings per second.
  • Low Latency SLA: P99 write latency for transaction ingestion must be less than 50 milliseconds.
  • High Availability: Target five-nines (99.999%) availability for balances reading, and four-nines (99.99%) availability for payments posting.
  • Disaster Recovery: RPO (Recovery Point Objective) must be exactly zero, meaning no committed transaction can be lost during a regional outage. RTO (Recovery Time Objective) must be less than 1 minute.

API Design and Interface Contracts

The API exposes endpoints for posting transactions and querying current balances. To ensure reliability across network drops, all write APIs require client-generated idempotency keys.

1. Ingest Transaction

  • Endpoint: POST /v1/ledger/transactions
  • Idempotency: Handled via Idempotency-Key HTTP Header.

Request Payload (JSON):

{
  "transaction_id": "tx_88927110-8811",
  "description": "Subscription Renewal Invoice #1029",
  "entries": [
    {
      "account_id": "acc_merchant_8892",
      "direction": "CREDIT",
      "amount_minor": 1500,
      "currency": "USD"
    },
    {
      "account_id": "acc_user_4431",
      "direction": "DEBIT",
      "amount_minor": 1500,
      "currency": "USD"
    }
  ]
}

Response Payload (JSON):

{
  "transaction_id": "tx_88927110-8811",
  "status": "SETTLED",
  "currency": "USD",
  "posted_at": "2026-06-16T12:00:00Z",
  "ledger_version": 142
}

2. Query Account Balance

  • Endpoint: GET /v1/accounts/{account_id}/balance

Response Payload (JSON):

{
  "account_id": "acc_merchant_8892",
  "currency": "USD",
  "settled_balance_minor": 156200,
  "pending_balance_minor": 1200,
  "last_updated_entry_id": "entry_9988221",
  "as_of_timestamp": "2026-06-16T12:05:00Z"
}

High-Level Architecture

The architecture separates the high-throughput write path from the query path. It uses double-entry bookkeeping as its core data structure and avoids direct balance updates.

When a request is received, the Ledger Gateway API acts as the entry point. It validates the request format, verifies the account states, and queries Redis to ensure the idempotency key hasn't been processed before. If the request is a duplicate, the API returns the cached response. If it is new, the request is written to the primary PostgreSQL instance.

graph TD
    Client[Payment Client] -->|1. Submit Tx| API[Ledger Gateway API]
    API -->|2. Check Idempotency| Redis[(Redis Cache)]
    API -->|3. Write ACID Tx| PostgreSQL[(Primary PostgreSQL DB)]
    
    subgraph PostgreSQL Database
        T1[Transactions Table]
        T2[Entries Table]
        T3[Outbox Events Table]
    end
    
    PostgreSQL -->|4. WAL Change stream| Debezium[Debezium CDC Worker]
    Debezium -->|5. Publish Event| Kafka[Kafka Event Bus]
    Kafka -->|6. Consume Event| LedgerConsumer[Reconciliation Service]
    Kafka -->|7. Update Cache| CachePopulator[Redis Balance Updater]
    CachePopulator --> ReadReplica[(Redis Read Cluster)]
    ClientQuery[Query Client] -->|8. Get Balance| API
    API -->|9. Fast Read| ReadReplica

Double-Entry Flow

Every financial movement requires offsetting debit and credit entries. The sum of credits must match debits to prevent money creation out of thin air.

The ledger service starts a database transaction. It verifies that the user account has sufficient funds by checking the latest balance. If the account is active and has sufficient funds, it writes a transaction header record and then inserts two separate entries. Finally, it writes an event to the outbox table to notify external services like emails or analytics before committing the transaction.

sequenceDiagram
    autonumber
    participant Client
    participant Service as Ledger Service
    participant DB as Ledger DB
    
    Client->>Service: Post Transaction (Debit User 15$, Credit Merchant 15$)
    Service->>DB: Begin ACID Transaction
    Service->>DB: Verify Account Statuses (Not frozen)
    Service->>DB: Write Transaction Header
    Service->>DB: Write Entry 1 (User Account: -$15 DEBIT)
    Service->>DB: Write Entry 2 (Merchant Account: +$15 CREDIT)
    Service->>DB: Write Transactional Outbox Event
    DB-->>Service: Commit Transaction Success
    Service-->>Client: HTTP 201 Created (Settled)

Low-Level Design and Schema

Our data model uses four core tables. The database of choice is PostgreSQL because of its robust ACID transaction boundaries and MVCC engine.

-- Core Ledger definition table
CREATE TABLE ledgers (
    ledger_id VARCHAR(64) PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    currency VARCHAR(3) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Individual accounts belonging to a ledger
CREATE TABLE accounts (
    account_id VARCHAR(64) PRIMARY KEY,
    ledger_id VARCHAR(64) REFERENCES ledgers(ledger_id),
    name VARCHAR(255) NOT NULL,
    type VARCHAR(50) NOT NULL, -- ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
    status VARCHAR(50) DEFAULT 'ACTIVE', -- ACTIVE, SUSPENDED, FROZEN
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Transaction Headers representing business intent
CREATE TABLE transactions (
    transaction_id VARCHAR(64) PRIMARY KEY,
    description TEXT,
    idempotency_key VARCHAR(256) UNIQUE NOT NULL,
    posted_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Double-Entry record detail table
CREATE TABLE entries (
    entry_id BIGSERIAL PRIMARY KEY,
    transaction_id VARCHAR(64) REFERENCES transactions(transaction_id),
    account_id VARCHAR(64) REFERENCES accounts(account_id),
    direction VARCHAR(6) CHECK (direction IN ('DEBIT', 'CREDIT')),
    amount_minor BIGINT NOT NULL, -- Saved in minor units (cents) to avoid floats
    currency VARCHAR(3) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Outbox table to publish downstream integrations
CREATE TABLE outbox_events (
    event_id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(50) DEFAULT 'PENDING',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Create optimized index for balance queries
CREATE INDEX idx_entries_account_currency ON entries(account_id, currency, amount_minor);
CREATE INDEX idx_outbox_pending_events ON outbox_events(status, created_at);

By referencing the accounts table, the entries table guarantees that all credits and debits correspond to real, registered accounts. The amount_minor field holds integer values representing the currency's smallest denominational unit. This design prevents rounding errors inherent to floating-point representations, ensuring audit accuracy.


Scaling Challenges and Capacity Estimation

Ledger scaling exposes distinct hardware and database constraints when dealing with global transaction flows.

1. Database Connection and Memory Calculations

Assuming 5,000 transaction postings per second, with each transaction writing one transaction record, two entries records, and one outbox event record:

  • Writes Per Second: $5,000 \times 4 = 20,000$ database writes per second.
  • Storage Footprint:
    • Transactions Record: 200 bytes
    • Entries Record: 150 bytes $\times 2$ entries = 300 bytes
    • Outbox Record: 350 bytes
    • Total per transaction: 850 bytes
    • At 5,000 TPS, storage growth is: $5,000 \times 850 \text{ bytes} \approx 4.25 \text{ MB/sec} \approx 367 \text{ GB/day}$.
  • Active Connections Sizing: With a database response time of 10 milliseconds, supporting 5,000 concurrent updates requires: $$\text{Connections} = \text{TPS} \times \text{Latency} = 5,000 \times 0.010 = 50 \text{ active connections}.$$ However, we configure database connection pooling with PgBouncer to multiplex 5,000 microservice clients into a pool of 200 dedicated physical server connections. This protects database CPU resources from connection overhead.

2. Sharding and Hot Account Mitigation

If we shard entries by account_id using consistent hashing, we run into the "hot shard" problem when a major merchant account receives thousands of incoming payments simultaneously. When thousands of clients write to the same account ID, row-level locks on the parent account record serialize all updates, leading to connection exhaustion.

  • Mitigation: We implement journal routing and micro-batching. For hot merchant accounts, incoming credits are written to temporary sub-account buckets (e.g. merchant_account_shard_1, merchant_account_shard_2). A background cron job collapses these sub-accounts into the main ledger account hourly, preventing lock contention on a single row.

Failure Scenarios and Resilience

Designing for failure is critical to preventing financial data loss or duplicated ledger state.

Scenario A: Outbox Relay Worker Crashes

If the Change Data Capture (CDC) worker processing the outbox_events table crashes, events will accumulate in the database, stalling downstream services like notification alerts or search updates.

  • Resilience Configuration:
    • The CDC worker uses a distributed coordinator lease (e.g. Zookeeper or Consul lock).
    • If a worker crashes, the standby instances detect the lease loss after a 5-second timeout, assume leadership, and resume reading the WAL from the last marked offset, guaranteeing at-least-once delivery.

Scenario B: Message Broker (Kafka) Outage

If the Kafka cluster becomes unavailable, the CDC engine cannot publish events, and its internal buffers will saturate.

  • Resilience Configuration:
    • The CDC worker implements a strict retry policy with exponential backoff and jitter.
    • If Kafka remains down for greater than 10 minutes, the worker halts consumption of the database WAL, preventing memory exhaustion, and propagates backpressure to client gateways.

Scenario C: Partial Network Cuts During Writes

If the ledger service loses connection to the database halfway through writing debit and credit entries:

  • Resilience Configuration:
    • Standard PostgreSQL ACID isolation rollback triggers.
    • Because all writes are grouped under a single database transaction transaction context (BEGIN ... COMMIT), any socket drop forces PostgreSQL to abort the transaction, discard the partial entries, and free row-level locks automatically. This prevents partial state updates where only one side of the double-entry equation is committed.

Architectural Trade-offs

Choosing the ledger's underlying engine requires balancing performance, security, and developer overhead.

System Pattern Ingestion Latency Correctness Guarantee Audit Overhead Best Use Case
Relational Database (PostgreSQL) Medium (30-50ms) Absolute (Strict ACID check constraint) Low (Handled by schema constraints) Core ledger of record, payment systems.
Event Sourced Log (Kafka Store) Low (less than 10ms) Eventual (Requires write-side locking) High (Requires rebuilding states) User notification streams, event logs.
NoSQL Key-Value (DynamoDB) Low (10-20ms) Relies on OCC / conditional writes Very High (No multi-row transactions) High-volume click trackers, cart states.

Using a relational database provides robust transaction safety but limits throughput compared to a distributed log. Event sourcing scales writes easily but shifts the burden of reconstructing consistency states onto downstream reader queries, increasing system complexity.


Staff Engineer Perspective

[!CAUTION] Rounding Errors in Floats Never use standard float or double data types for financial values. A transaction of 19.99 represented as a float can introduce minute rounding drift (e.g. 19.9900001 or 19.9899999) that violates double-entry balancing checks. Always store currency values in minor units (cents, minor currency units) using BIGINT or high-precision NUMERIC types.


Verbal Script

Interviewer: "How would you design a ledger system for a payment platform that guarantees 100% data integrity and auditability at scale?"

Candidate: "I would build the ledger using Double-Entry Bookkeeping stored in a highly consistent relational database like PostgreSQL. Rather than updating a balance column directly, I would model the ledger as an append-only stream of debit and credit entries. To record a transaction, the service writes both the transaction details and the entries inside a single ACID database transaction. To ensure integration with downstream systems without introducing dual-write bugs, I would implement the Transactional Outbox pattern. The service writes events to an outbox table, and a log-based CDC tool like Debezium streams these events to Kafka. For scaling, I would partition the database using consistent hashing on the account ID, while using journal sub-routing to handle hot merchant accounts and avoid lock contention."

Interviewer: "What happens if two concurrent requests attempt to debit the same account, and the account does not have enough balance?"

Candidate: "To prevent race conditions where both requests see a positive balance and commit concurrently, we must use pessimistic locking during the check phase. We execute SELECT SUM(amount_minor) FROM entries WHERE account_id = ? FOR UPDATE. This locks the entries associated with the account, forcing concurrent transactions to block until the lock is released. Once the balance is verified, the write phase can proceed. For hot accounts where this locking blocks throughput, we can use partition routing with delayed ledger compaction to scale out writes without sacrificing correctness."

Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.