System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
A payment system takes money from a customer, records where it went, and eventually pays it out to a merchant. Most of the interview designs in this course can tolerate a little sloppiness: a like count that's briefly off or a post that shows up two seconds late hurts nobody. A payment system can't. If the network drops in the middle of checkout, the customer must be charged once, not zero times and not twice, and every cent that moves has to be accounted for afterwards.
The primer touches on transactions under databases but has no worked payment design. This chapter builds one: idempotency keys so retries are safe, a state machine for the payment lifecycle, integration with a payment service provider (PSP), a double-entry ledger, and a daily reconciliation job that checks your records against the outside world.
It builds on the primer's database and consistency patterns chapters, and on the extra chapters on distributed transactions and reliability patterns.
These numbers are assumptions for a large online marketplace, not figures from any real company:
None of this needs a sharded NoSQL store. A replicated relational database (PostgreSQL or MySQL with a synchronous standby) handles a few thousand transactional writes a second, and a distributed SQL database such as CockroachDB or Spanner is an option if you outgrow one primary. What you need from the store is transactions and durability, and relational databases are good at both. See estimation for more on this kind of arithmetic.
POST /api/v1/payments
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json
{
"order_id": "ord_892341",
"amount": 4999,
"currency": "USD",
"payment_method_token": "tok_visa_4242",
"customer_id": "cust_104"
}
HTTP/1.1 201 Created
Content-Type: application/json
{
"payment_id": "pay_582019",
"status": "AUTHORIZED",
"amount": 4999,
"currency": "USD",
"created_at": 1735689600
}
amount is an integer in the currency's smallest unit, so 4999 means $49.99. Never store money as a floating-point number: 0.1 + 0.2 isn't exactly 0.3 in binary floating point, and those errors add up across millions of rows. Note that the smallest unit differs by currency: the Japanese yen has no minor unit, and some currencies use three decimal places.
The response can also come back with a status like REQUIRES_ACTION when the card issuer wants the customer to complete an extra check (3-D Secure), and the client then finishes that step before the payment moves on.
Here's the failure every payment API has to handle. The customer clicks "Pay". The payment service charges the card through the PSP. Then the connection drops before the response reaches the browser. The customer sees a spinner and clicks "Pay" again, or the client retries on its own. Without protection, the card is charged twice.
The fix is an idempotency key: a unique ID the client generates once per checkout attempt and sends with every retry of that attempt. The server remembers which keys it has seen and what it answered. Stripe popularized this pattern, and there's an IETF Internet-Draft that standardizes the Idempotency-Key header.
The first request with a given key:
Idempotency-Key header.IN_PROGRESS. A unique constraint on the key makes this the lock: only one request can win the insert.COMPLETED, storing the response body next to it.201 Created to the client.A retry with the same key:
COMPLETED, it returns the stored response body. The PSP is not called again.IN_PROGRESS, the first request is still running. The service returns 409 Conflict and the client retries a little later.The claim step in SQL:
INSERT INTO idempotency_keys (key, customer_id, request_hash, status, created_at)
VALUES ('9b1deb4d-...', 'cust_104', 'sha256:...', 'IN_PROGRESS', NOW())
ON CONFLICT (key) DO NOTHING;
If that statement inserts one row, this request owns the key. If it inserts zero, someone else got there first.
A few details make the difference between a toy and a working design:
(customer_id, key) or the key is namespaced per account.A payment moves through a fixed set of states, and the service only allows the transitions the business rules permit. Each transition is a database update guarded by the current state (UPDATE ... SET status = 'CAPTURED' WHERE id = ? AND status = 'AUTHORIZED'), so two workers can't both capture the same payment.
Keep VOIDED, EXPIRED and FAILED apart. They mean different things to the merchant, to support staff and to the ledger: a failed payment never held money, a voided one released a hold on purpose, and an expired one usually points to a bug or a stuck fulfilment process.
Many merchants split a card payment into two steps:
Merchants that ship physical goods often capture when the order ships, so they never collect money for something they can't send. Digital goods are usually authorized and captured in one call.
The difference matters when an order is cancelled:
The simple way to track money is one mutable balance column:
UPDATE accounts SET balance = balance - 50 WHERE user_id = 104;
That works until someone asks why the balance changed. The column only remembers the latest number. You can't tell whether the $50 went to an order, a refund, a bug or an attacker, and you can't rebuild yesterday's balance.
Payment systems use double-entry bookkeeping instead, the method Italian merchants were using by the 14th century and that Luca Pacioli described in 1494. Its rules:
What debit and credit do to a balance depends on the account type. For asset accounts (money you hold or are owed), a debit increases the balance and a credit decreases it. For liability accounts (money you owe others) and revenue accounts, it's the other way round: a credit increases them.
Alice buys $50 of shoes from merchant Bob on our marketplace. The marketplace keeps a 3% commission ($1.50) and owes Bob the remaining $48.50. These entries are from the marketplace's books, and to keep the example small they ignore the PSP's own fee.
| Transaction | Account | Type | Debit | Credit | What happened |
|---|---|---|---|---|---|
tx_001 | psp_receivable | asset | $50.00 | Alice's card captured; the PSP owes us $50 | |
tx_001 | merchant_payable:bob | liability | $48.50 | We now owe Bob his share | |
tx_001 | commission_revenue | revenue | $1.50 | Our 3% commission | |
tx_002 | cash:bank | asset | $50.00 | The PSP settles into our bank account | |
tx_002 | psp_receivable | asset | $50.00 | The PSP no longer owes us | |
tx_003 | merchant_payable:bob | liability | $48.50 | We pay Bob, so we owe him nothing | |
tx_003 | cash:bank | asset | $48.50 | The payout leaves our bank account |
Each transaction balances on its own: $50.00 = $48.50 + $1.50 for tx_001, $50.00 = $50.00 for tx_002, $48.50 = $48.50 for tx_003. Across the table, total debits = total credits = $148.50.
After all three, psp_receivable and merchant_payable:bob are back to zero, cash:bank is up $1.50, and commission_revenue shows $1.50 earned. That's the right answer: the only money the marketplace kept is its commission. Alice's card doesn't appear at all, because her card account lives in her bank's books, not ours.
A scheduled job checks that every transaction balances and that derived balances match the entries. A mismatch of even one cent should page someone, because it means a bug wrote an unbalanced transaction.
In the database, a ledger is usually two tables: transactions (one row per business event, with an idempotency reference back to the payment) and entries (one row per debit or credit, pointing at its transaction and account). The application role can INSERT into them but has no UPDATE or DELETE grants.
An interactive version of this diagram lets you pan, zoom and trace the request paths.
The components:
Writing the payment state and the ledger entries is the one place where two stores must agree. If they're in the same database, one transaction covers both. If the ledger is a separate service, write the state change and an outbox row in one transaction and have a worker deliver the outbox to the ledger, which deduplicates by transaction ID. The distributed transactions chapter covers the outbox pattern.
If every internal write is transactional and every request is idempotent, why check anything afterwards? Because the system talks to banks, card networks and PSPs over the network, and their view of events can differ from yours:
AUTHORIZED.PSPs and banks publish settlement reports: files (CSV, or banking formats such as ISO 20022 camt.053 and the older SWIFT MT940) listing every transaction that settled in a period, usually daily. Reconciliation compares three sources: the PSP's report, your payments database and your ledger.
The job, often run as a batch processing pipeline:
Every exception gets a ticket and an owner. A rising exception rate is one of the best early warnings that something in the payment path is broken.
Any system that stores, processes or transmits card numbers falls under PCI DSS, and the more of your infrastructure touches card data, the bigger and more expensive the yearly assessment. The standard approach is to make sure your servers never see a card number:
iframe served by the PSP.tok_visa_4242.The token is useless outside your account with that PSP, so a leaked token can't be used to charge the card elsewhere. For repeat purchases, you ask the PSP to save the card and store the reusable payment method ID it gives back.
This doesn't take you out of PCI scope entirely: you still have to secure the page that loads the iframe. But it shrinks your obligations to the shortest self-assessment questionnaire (SAQ A for fully outsourced card entry) instead of a full audit of your data centers.
A balance column only holds the current number. If it changes from $500 to $400, nothing tells you whether the $100 was a purchase, a subscription, a bug or a theft, and you can't recompute what the balance was last Tuesday.
With double-entry bookkeeping every movement is an append-only transaction whose debits equal its credits, so money always comes from one account and goes to another. The balance is the sum of the entries, so it can be recomputed and audited at any time, and a mistake is fixed with a visible reversing entry instead of a silent edit. A transaction that doesn't balance points straight at a bug.
The first request inserted its key with status IN_PROGRESS, called the PSP, and on success stored the response and set the status to COMPLETED. The client never saw that response, so it retries with the same key. The insert fails on the unique constraint, the service reads the existing row, sees COMPLETED, and returns the stored response without calling the PSP. If the retry arrives while the first request is still running, it sees IN_PROGRESS and gets a 409, then retries later. The service also passes an idempotency key to the PSP, so its own retries to the PSP can't double-charge either.
Authorization asks the issuer to approve the payment and hold the amount on the card. No money moves to the merchant. Capture tells the PSP to collect the held amount, which then goes through clearing and settlement and reaches the merchant days later.
Separating them lets a merchant authorize at checkout and capture only when the order ships. If the item turns out to be out of stock, the merchant voids the authorization: nothing was collected, so there's no refund to process and usually no processing fee. If too much time passes without a capture, the authorization expires and the hold is released.
When Alice pays, the marketplace receives money that belongs to Bob, so it owes him $48.50. Money you owe is a liability, and liabilities increase with a credit. When the marketplace pays Bob out, it debits that liability back to zero. Alice's card account is an account at her bank, so it lives in her bank's books. The marketplace only records its own side: what the PSP owes it, what lands in its bank account, what it owes merchants, and what it earned.
Most likely the capture succeeded at the PSP but the response timed out and the webhook was lost or failed. The PSP's report is the evidence that money moved. The job should move the payment to CAPTURED and post the ledger entries through the same code path a webhook would use, so the logic isn't duplicated, and log the event so the team can find out why the webhook didn't land.
Previous: Design a real-time gaming leaderboard · Next: Design a collaborative document editor (like Google Docs or Figma)