Case study: Payment system & ledger
Design a Stripe-grade payment system: end-to-end Idempotency-Key deduplication, immutable double-entry bookkeeping, transactional outbox sagas, and T+1 settlement reconciliation.
35 min9 steps
By the end you can
- Estimate payment TPS, immutable ledger growth, idempotency store sizing, and external PSP latency bounds
- Prevent double charges across network timeouts and retries using atomic Idempotency-Key state transitions
- Model money movements as append-only double-entry ledger postings in integer minor units
- Coordinate local ledger commits with external PSP calls via the transactional outbox, sagas, and reconciliation
Pin down what you are building
Authorize, capture, refund
- Append-only double-entry ledger; T+1 reconciliation
Zero duplicate/lost payments
- Integer minor units + currency; Idempotency-Key
Work out the numbers
Payments/day
500M
5,800 TPS average; 20,000 peak
Ledger/day
400 GB
500M / 4 rows / 200 B
Idempotency/day
64 GB
500M / 128 B; 24-48h retention assumption
PSP p99
200-1,000 ms
Local DB: 2-5 ms
Locks across PSP
400 TPS
200 connections / 0.5s: avoid
Design the API
POST /v1/payments
- Idempotency-Key scoped to merchant; amountCents, currency
Retries
- Completed: replay; IN_PROGRESS: 409; changed requestHash: 422
Model the double-entry ledger
idempotency_keys / payments
- Merchant + key uniqueness; requestHash, response; versioned payment state
ledger_entries
- txId, accountId, direction, amountCents; balanced debits/credits; append reversals
Draw the high-level architecture
Order the steps when the payment orchestrator handles a `POST /v1/payments` request.
- 1Settlement worker processes the outbox event with the external PSP and reconciles T+1
- 2Store the final response against the idempotency key and return 200 to the gateway
- 3If the key already exists, return the stored response without touching the ledger or PSP
- 4Atomically check-and-insert `(merchantId, Idempotency-Key)` in the idempotency store
- 5Append balanced DEBIT/CREDIT entries to the ledger DB and enqueue the outbox event
Deep dive: 2PC vs Sagas, Outbox, and Hot Accounts
Local transaction
- Ledger transition + outbox row commit together
External PSP
- Idempotent relay; compensate failures; no external 2PC; shard hot accounts
Evaluate alternatives
payment-no-dedup
- lost-acks causes duplicateEffect
Synchronous authorization
- PSP outside DB lock; persist IN_PROGRESS then AUTHORIZED
Queued capture
- Outbox handles capture, settlement, webhooks asynchronously
Run the failure scenarios
Inject
lost-acks: 1.5-1.58s
Observe
Committed response is dropped
Result
Same-key retry replays; no-dedup double-charges
Inject
settler-crash: 600ms
Observe
Capture workers unavailable
Result
Outbox retains jobs; acceptance >99% in fixture
Inject
ledger-failover: 500ms
Observe
Writes fail closed during election
Result
Semi-sync + fencing; no acknowledged loss
Wrap up and self-assess
T+1 reconciliation
- Missing PSP capture: retry/void; missing internal entry: repair/refund
Invariant monitor
- SUM(debits) = SUM(credits); alert on drift
Answer this step to continue.