System design guide
Design a payment system
Payment systems invert the usual priorities: traffic is modest, but a lost message is a lost payment and a duplicate is a double charge. The interview is about correctness under retries and partial failure, not about scaling to millions of requests a second.
1. Clarify the requirements
Ask: Are we a merchant integrating with a payment processor, or the processor itself? Card payments only, or also bank transfers and wallets? One currency or many? Refunds and payouts in scope?
A typical scope: an e-commerce company's internal payment platform that accepts card payments through one or more external payment service providers (PSPs), and pays out to sellers.
Functional requirements
- Create a payment for an order; authorise and capture the card via a PSP.
- Refund (full or partial).
- Record every money movement in a ledger; report balances per merchant/seller.
- Notify order systems of payment status changes.
Non-functional requirements
- Correctness: never double-charge, never lose a payment; every cent accounted for.
- Auditability: an immutable history of every state change.
- Availability during sales peaks, with graceful handling of PSP outages.
- Security and compliance: card data handled in a PCI-scoped zone or not at all (tokenised by the PSP).
2. Estimates
| Quantity | Assumption | Result |
|---|---|---|
| Payments | 10M/day | ~115/s average; ~10× on peak sale days ≈ 1,000-2,000/s |
| Ledger entries | ~4 entries per payment (auth, capture, fee, payout) | ~40M rows/day, ~15B rows/year |
| Storage | ~500 bytes per entry | ~20 GB/day, ~7 TB/year |
A relational database handles this write rate comfortably. The conclusion to state: choose a strongly consistent, transactional store and spend design effort on correctness rather than throughput.
3. API and idempotency
POST /v1/payments
Idempotency-Key: 7f3c...-uuid
{ "order_id": "o_123", "amount": 4999, "currency": "USD", "payment_method": "pm_tok_..." }
→ 201 { "payment_id": "pay_...", "status": "authorized" }
POST /v1/payments/{id}/capture Idempotency-Key: ...
POST /v1/payments/{id}/refunds Idempotency-Key: ... { "amount": 1000 }
On each request: insert the idempotency key with status in_progress (unique constraint). If the insert fails because the key exists, return the stored response (or 409 if still in progress). After processing, store the response with the key. Keys expire after a retention window, such as 24 hours.
4. High-level architecture
Payment service. Each payment is a state machine: CREATED → AUTHORIZING → AUTHORIZED → CAPTURING → CAPTURED, with FAILED, REFUNDED, and UNKNOWN branches. Before calling the PSP it persists the intent to call; after the call it records the result. If the process crashes in between, a recovery worker finds payments stuck in AUTHORIZING and asks the PSP for their status (PSPs accept your own idempotency key or reference, so a retried call is safe).
Transactional outbox. The state change, ledger entries, and an outbox row are written in one database transaction. A relay publishes outbox rows to the event bus and merchant webhooks. This avoids the classic bug where the database commits but the event is lost (or the reverse).
5. Data model: the ledger
payments payment_id, order_id, amount, currency, status, psp, psp_reference,
idempotency_key, version, created_at, updated_at
ledger_entries entry_id, txn_id, account_id, direction (debit|credit), amount,
currency, created_at -- append-only; never updated
accounts account_id, type (customer_funds | merchant_payable | fees | psp_receivable)
outbox id, aggregate_id, event_type, payload, published_at
A capture of $49.99 with a $1.75 fee might post: debit psp_receivable 49.99; credit merchant_payable 48.24; credit fees 1.75. Every transaction's entries sum to zero, and balances are sums over entries. Store amounts as integers in minor units (cents), never floats.
6. Reliability and scaling
- Retries everywhere, idempotency everywhere. Client → API (idempotency key), service → PSP (PSP idempotency key or merchant reference), relay → consumers (consumers de-duplicate by event ID).
- Timeouts are "unknown", not "failed". If the PSP call times out, the money may have moved. Mark the payment
UNKNOWNand resolve it by querying the PSP or waiting for its webhook; never blindly retry with a new key. - Reconciliation. Daily (or more often), compare the PSP's settlement report line by line with the ledger. Mismatches go to an exceptions queue for automated fixes or human review. This is the safety net that catches everything else.
- PSP failover. Route through a second PSP when the primary's error rate spikes, but only for new payments; in-flight ones must finish where they started.
- Scaling. Partition payments and ledger by merchant or account when a single primary is no longer enough; keep each transaction within one partition. Read-heavy reporting goes to replicas or a warehouse.
7. Trade-offs to call out
- Consistency over availability for the ledger: it's better to reject a payment than to record it wrongly. See consistency models and CAP.
- Synchronous vs asynchronous capture: capturing immediately is simpler; capturing on shipment reduces refunds but adds state.
- Build vs buy: most companies integrate a PSP and build only orchestration, ledger, and reconciliation.
- Webhooks vs polling for PSP status: webhooks are timely but can be delayed or duplicated; use both, with idempotent handling.
8. Common follow-up questions
- How do you handle multi-currency? Store amount and currency on every entry; record FX conversions as explicit ledger transactions with the rate used.
- How do refunds interact with payouts already sent? Post a refund transaction that may push the merchant balance negative; recover it from future payouts.
- How do you prevent fraud? A risk-scoring step before authorisation (rules plus a model), with 3-D Secure challenges for risky payments.
- How do you test it? Use the PSP's sandbox, inject faults (timeouts after success, duplicate webhooks), and assert the ledger always balances.
- How do you notify merchants reliably? Signed webhooks with retries and backoff from the outbox, plus an events API they can poll; it's the same reliability pattern as a notification system.
FAQ
What is an idempotency key?
A unique value the client sends with a request (for example a UUID per checkout attempt). The server stores the key with the result of the first request; any retry with the same key returns the stored result instead of charging again.
Why use a double-entry ledger?
Every money movement is recorded as balanced debit and credit entries that sum to zero. That makes errors detectable (the books don't balance), gives a full audit trail, and lets you derive any balance from immutable entries instead of mutating a balance column.
Can you get exactly-once payments?
Not over an unreliable network by delivery alone. You get the effect of exactly-once by combining at-least-once delivery (retries) with idempotent processing (idempotency keys at your API and with the payment processor), plus reconciliation to catch anything that slipped through.