Design a Payments System (Stripe-like)
Design an E-commerce Checkout System covered using a payment provider — reserving inventory, calling out to charge a card, handling the response. This problem is building the payment provider itself: the system of record for where money actually is, that has to stay correct even when the bank or card network on the other end of a call gives an ambiguous or delayed answer. Following the framework from How to Answer a System Design Interview Question.
1. Requirements​
Functional:
- Record and move money between accounts (a merchant, a customer, the platform itself) as the result of charges, refunds, and payouts.
- Integrate with external payment processors and banks, which settle money on their own schedule, not instantly.
Non-functional:
- The ledger must never lose or fabricate money — this is the one requirement with zero tolerance, unlike almost every other system in this course.
- Must handle a fundamentally unreliable external dependency: a call to a bank or card network can time out with the actual outcome genuinely unknown, not just slow.
- Every charge must be idempotent — a client or a network retry must never result in a customer being charged twice.
Scale estimate: assume 10,000 transactions/sec at peak, each requiring an external network call whose latency and reliability the payments system doesn't control at all — the design has to be built around that external unreliability as a first-class constraint, not an edge case.
2. API Design​
POST /charges
body: { amount, currency, source, idempotency_key }
returns: { charge_id, status: pending|succeeded|failed }
GET /charges/{charge_id}
returns: { status, ledger_entries }
3. Data Model​
The core structure is a double-entry ledger, not a simple balance column:
ledger_entries
entry_id VARCHAR PRIMARY KEY
transaction_id VARCHAR -- groups a matched debit + credit pair
account_id VARCHAR
amount DECIMAL -- positive (credit) or negative (debit)
created_at TIMESTAMP
4. High-Level Design​
5. Deep Dive: the double-entry ledger as the source of correctness​
Instead of a simple balance number per account (which a bug or a race condition could silently corrupt with no way to detect it), every single transaction is recorded as two balanced entries: a debit from one account and an equal credit to another, and the ledger's core invariant — every transaction's entries sum to exactly zero — is checked at write time, in the same ACID Transaction that writes both entries together, atomically.
An account's actual balance is never stored directly at all — it's always derived by summing that account's entries, which means a balance can never silently drift from what actually happened: it's mathematically reconstructible from the full transaction history at any time, which is exactly the auditability property a financial system has to have and a simple mutable balance column doesn't.
6. Deep Dive: reconciling an ambiguous external result​
The hardest failure mode in this entire system: a call to an external processor times out, and there is no way to know locally whether the charge actually succeeded on the processor's side or not. Naively retrying risks a double charge; naively assuming failure risks losing a payment that actually went through.
The fix has two parts working together: every charge is sent with the same idempotency key on every attempt, so even if a retry does happen, the processor itself recognizes the duplicate and refuses to charge twice — the safety net, not the primary mechanism. The actual resolution is a reconciliation job: a batch process that periodically queries the processor directly for the true, authoritative status of every charge still sitting in pending, and updates the ledger once a definitive answer is known. This accepts a deliberate window of eventual consistency for the rare ambiguous case, in exchange for never guessing wrong about whether money actually moved.
7. Tradeoffs​
Waiting for reconciliation rather than guessing immediately means a small fraction of charges sit in pending for longer than a typical successful charge — a real, if rare, cost to the customer's and merchant's visibility into a transaction's status. The alternative, assuming success or failure immediately on a timeout, risks exactly the two outcomes this system exists to prevent: a lost payment or a duplicate charge, either of which is a far worse failure than a brief delay.
Never storing a balance directly, from the ledger deep dive above, has the same shape of cost: an account with millions of historical entries makes "what's this account's current balance" an increasingly expensive sum over time, not a constant-time read. The practical fix is a periodic balance snapshot — a materialized running total as of some entry, so a live balance query only needs to sum entries since that snapshot rather than the account's entire history — but that snapshot is itself a cache with the same staleness question every cache raises: how far behind can a snapshot be before it's worth recomputing, a direct instance of the same caching strategy tradeoff this course covers generally, just applied to a derived financial total instead of a web response.
Reconciliation-based resolution vs. guessing on timeout: pros and cons​
Reconciliation
- Never assumes an outcome it doesn't actually know — always confirms with the processor
- Cannot produce a lost payment or a duplicate charge from an ambiguous timeout
- The ledger stays provably correct, auditable against the processor's own records
Guessing on timeout
- Assuming failure risks silently losing a payment that actually succeeded
- Assuming success risks confirming a charge that never actually happened
- Either wrong guess requires manual, after-the-fact correction once discovered
Further Reading​
- Stripe Engineering — Designing robust and predictable APIs with idempotency — Stripe's own account of the idempotency-key pattern this design relies on.
- Martin Fowler — Patterns of Enterprise Application Architecture, Ch. on Accounting Patterns — a foundational reference for double-entry ledger design in software systems.
Saved locally in your browser — visible in the sidebar as you go.