Skip to main content

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​

System Design Lab

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.

System Design Lab

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.

System Design Lab

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​

Share this lesson

Saved locally in your browser — visible in the sidebar as you go.