Design Payment System Interview Questions | JiQuest

add

#

Design Payment System

System design deep dive · HLD

Design a Payment Processing System (Stripe-like): full high-level design.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for the payment-creation and refund flows, and an entity-relationship diagram built around a double-entry ledger - with the reasoning an interviewer expects behind every box and arrow.

10MPayments processed / day
24hIdempotency key TTL
2xLedger entries per payment
MerchantPOST /charge Payment APIchecks Idempotency-Key Card networkauth ~300ms 200 OKcharge.succeeded Ledgerdouble-entry post back to merchant

1. Clarify requirements before drawing any box

A payment system lives or dies on one property that most systems don't need: money must never be created, destroyed, or duplicated by a retry, a crash, or a race condition. Every requirement below is written with that constraint in mind.

Functional requirements

Create a paymentCharge a customer's tokenized payment method for an amount, idempotently, and return a status.
Post to the ledgerEvery state change in money movement is recorded as balanced double-entry debit/credit rows.
Refund a paymentFull or partial refund against an existing payment, reversing the original ledger entries.
Notify via webhookMerchants are notified asynchronously of payment.succeeded, payment.failed, refund.succeeded events.

Non-functional requirements

Exactly-once semanticsA client retrying a timed-out request must never cause a double charge - idempotency keys are mandatory.
Strong consistencyLedger writes must be ACID; "eventually consistent money" is not an acceptable trade-off.
Full auditabilityEvery balance must be reconstructable and explainable from an immutable, append-only ledger.
PCI scope minimizationRaw card numbers never touch application servers - only tokens from a PCI-compliant vault/processor.
Explicitly out of scope A recurring-billing/subscription engine, marketplace split payments (Connect-style multi-party payouts), currency conversion/FX, and tax calculation are called out as extensions rather than core requirements, so the core charge-ledger-refund loop stays the focus.

2. Back-of-the-envelope capacity estimation

These numbers decide whether a single-region relational database with strong consistency is realistic (it is, at this scale), how big the idempotency key store needs to be, and how fast the ledger table grows.

MetricAssumptionResulting estimate
Payments created10 million/day~116 TPS average, ~1,200 TPS peak (holiday sales, flash promos)
Ledger entriesDouble-entry: 2 rows per successful payment leg, 2 more per refund~20-24M ledger rows/day
Storage per payment row~600 bytes (amount, currency, token ref, metadata, indexes)10M/day × 5 years × 600B ≈ 11 TB for payments alone
Idempotency key storeKeys retained 24h, ~10M new keys/day~10M concurrent keys × 200 bytes ≈ 2 GB - comfortably fits a Redis cluster
Refund volume~2% of payments are refunded~200K refunds/day, ~2.3 refunds/sec average
Why this matters The peak-to-average ratio (~10x) and the fact that refunds are a small fraction of volume both argue for a strongly consistent, vertically-scalable-then-sharded relational store rather than a distributed NoSQL store that would force the double-entry invariant to be enforced in application code instead of the database.

3. High-level design (HLD)

The HLD names the major components and the one-directional data flow between them, without yet committing to region layout, replica counts, or sharding keys - that belongs in the architecture diagram in the next section.

Merchantserver / SDK API gatewayauth, rate limit Payment serviceidempotent create Ledger servicedouble-entry post Idempotency store Card networkauth / capture / refund Ledger DBACID, sharded Event buspayment.succeeded Webhook dispatcherat-least-once
Stateless servicesFast-path infraDurable / externalAsync / edge

What each box owns

Payment service

Receives the charge request, looks up the Idempotency-Key header in the idempotency store first. On a new key, it calls the card network to authorize/capture, then hands off to the ledger service. On a repeated key (client retry after a timeout), it returns the already-stored response verbatim without contacting the card network again - this single check is what prevents double charges.

Ledger service

Takes a successful (or reversed) payment and writes a balanced set of double-entry rows - a debit somewhere and an equal credit somewhere else - inside a single database transaction. It never exposes a way to write an unbalanced entry; the invariant "sum of all entries for a transaction = 0" is enforced at write time, not checked later.

Idempotency store

A Redis-backed (or DB-backed with a unique index) store keyed on idempotency_key, holding the request fingerprint and the final response for 24 hours. It is consulted synchronously and is on the critical path, but reads/writes are single-key operations, so it stays fast even under retry storms.

Card network, event bus, and webhook dispatcher

The card network (Visa/Mastercard rails via an acquiring processor) is the external source of truth for whether money actually moved between banks. Once the payment service and ledger service agree the local state changed, an event is published so the webhook dispatcher can notify the merchant asynchronously, with retries and backoff, without making the merchant's uptime a dependency of the charge API's response time.

4. Detailed architecture diagram

The architecture diagram takes every HLD box and answers "how is this actually deployed, and why not active-active?" - the single most interviewer-tested decision in a payments design.

Edge layer TLS-terminating LB API gateway + authn Fraud / risk checksynchronous, pre-auth Rate limiterper-API-key Primary region: us-east-1 (single writer) Payment svc ×10 pods Ledger svc ×6 pods Idempotency Redis (3 shards) Token vault / HSM Standby region: us-west-2 (passive, warm) Payment svc ×2 pods Ledger svc ×2 pods Sync replica of ledger DBpromoted on region failoverRPO ≈ 0, RTO minutes Ledger storage tier Shard: acct A-M Shard: acct N-Z sharded by account_id, ACID within a shard Async webhook pipeline Kafka topic Dispatch workers exponential backoff, signed payloads Reconciliation Nightly settlement batch job diffs ledger vs card-network report
DecisionChoiceReasoning
Region topologyActive-passive, single writer regionMoney movement needs a single consistent order of operations per account; active-active would risk split-brain double-processing during a network partition, which is worse than a few minutes of failover downtime.
Ledger sharding keyHash of account_idNearly every ledger read/write pattern ("show me this account's balance/history") is scoped to one account, so this key keeps double-entry transactions single-shard and ACID without cross-shard coordination.
Fraud/risk check placementSynchronous, before card network callBlocking on risk scoring adds latency but is cheaper than authorizing a fraudulent charge that then has to be manually reversed and disputed.
Webhook deliveryAsync via Kafka + retry workers, never inlineA slow or down merchant endpoint must never hold open the charge API's HTTP connection or delay the ledger commit.

5. Sequence diagrams for the two critical flows

A sequence diagram is where an interviewer checks whether the idempotency key is actually consulted before any external call, and whether a refund correctly reverses rather than deletes ledger history.

5.1 Create payment with an idempotency key

Merchant Payment svc Idempotency store Card network Ledger 1. POST /charges, Idempotency-Key: k1 2. GET k1 3. not found (new key) 4. authorize + capture $amt 5. approved, auth_id 6. insert balanced debit/credit rows (txn) 7. committed 8. SET k1 → response (TTL 24h) 9. 200 OK {payment.succeeded}

If the merchant's network call times out after step 4 and it retries the exact same request with the same key, step 3 instead returns the cached response from step 8, and steps 4-7 never happen again - the card is never charged twice for one logical intent, even though the HTTP request was sent twice.

5.2 Refund a payment

Merchant Payment svc Card network Ledger Event bus 1. POST /refunds {payment_id, amount} 2. load payment + prior ledger rows 3. refund auth_id, amount 4. refund accepted 5. insert reversing debit/credit rows 6. committed 7. publish refund.succeeded (fire-and-forget) 8. 200 OK {refund.succeeded}

Step 5 never edits or deletes the original charge's ledger rows from step 6 of the create flow - it inserts new rows that debit and credit the opposite accounts, so the ledger stays append-only and the full history of "what happened and when" is preserved for audit and dispute purposes.

6. Entity-relationship (ER) diagram and schema

The data model has to answer three questions: how is a retried request detected before it reaches the card network, how is the double-entry invariant enforced by the schema rather than by hope, and how does a refund relate back to its original payment.

payments PK id BIGINT UQ idempotency_key VARCHAR FK account_id BIGINT amount BIGINT (minor units) currency CHAR(3) status ENUM card_token VARCHAR created_at TIMESTAMP ledger_entries PK id BIGINT FK payment_id BIGINT FK account_id BIGINT entry_type ENUM(debit,credit) amount BIGINT created_at TIMESTAMP accounts PK id BIGINT owner_type ENUM(customer,merchant,platform) currency CHAR(3) balance_cached BIGINT refunds PK id BIGINT FK payment_id BIGINT amount BIGINT reason, status VARCHAR/ENUM 1N N1 N1 one payment has 2+ ledger rows (double-entry); one account has many ledger rows; a refund always references its original payment, never stands alone

Key modeling decisions

idempotency_key is a unique indexThe database itself rejects a concurrent duplicate insert; the application does not have to win a race condition with a check-then-insert.
ledger_entries is append-onlyNo UPDATE or DELETE is ever issued against it; corrections are new rows that reverse the old ones, preserving a full audit trail.
amount is an integer in minor unitsCents, not floating point dollars, avoids rounding-error bugs that are unacceptable when the unit is real money.
balance_cached is a read optimization, not the truthThe real balance is SUM(ledger_entries.amount) for an account; the cached column is refreshed by the same transaction that inserts entries, and can always be recomputed from source.
Storage choiceUse whenWatch out for
Relational (Postgres), ACID transactionsYou need the debit and credit of a double-entry post to succeed or fail together, with a unique constraint on idempotency_key.Sharding must be chosen so that a single payment's entries don't span shards, or you need a distributed transaction.
NoSQL (DynamoDB/Cassandra)Almost never for the ledger itself - the lack of multi-row ACID transactions makes enforcing "debit and credit both commit" an application-level problem prone to partial-write bugs.Can still be reasonable for read-only denormalized views (e.g. a per-account transaction history feed) built from the ledger via CDC.

7. Deep dives interviewers actually probe

How does the idempotency key actually prevent a double charge?

The key is written to the idempotency store (or a unique DB column) inside the same transaction boundary as the initial "processing" state, before the card network is ever called. A concurrent or retried request with the same key either finds the row already in "processing" (and is told to wait/poll) or finds it "completed" (and gets the cached response immediately) - the card network call itself only ever happens once per key, by construction, not by a best-effort check.

-- Enforced by the schema, not application logic
CREATE UNIQUE INDEX ux_payments_idem_key ON payments(idempotency_key);
-- INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING id;
-- if 0 rows returned: this key already exists, fetch and return the existing row

Why a double-entry ledger instead of a single balance column?

A single mutable balance column can only ever tell you the current number, not how it got there, and a bug that increments the wrong row silently corrupts money with no trace. Double-entry gives a mechanical, schema-enforceable invariant - the sum of every entry tied to one logical transaction must equal zero - which turns most classes of accounting bugs into a failed assertion at write time instead of a silent discrepancy discovered during a customer dispute months later.

What happens if the card network approves the charge but the ledger write then fails?

The card network call and the ledger write cannot be wrapped in one distributed transaction, so the design treats the card network as external and idempotent-by-auth-id: if the ledger insert fails (deadlock, connection drop), the payment service retries the ledger write using the same idempotency key and the same auth_id it already has from the card network, rather than re-authorizing. The payment sits in a "pending_ledger" status until the ledger write succeeds, and a background reconciliation job also catches and repairs any payment that gets stuck in that status past a timeout.

How do you shard the ledger without breaking the double-entry invariant across accounts?

Sharding by account_id means a payment that moves money between a customer account and a platform/merchant account could, in principle, touch two different shards. The practical fix used by most payment systems is to keep a lightweight "platform revenue" account replicated into every shard as a local reference point, so the two entries of a given payment's core debit/credit pair land in the same shard as the customer or merchant account that owns them, and only asynchronous, eventually-consistent rollups (not the atomic entry pair) cross shard boundaries.

Why active-passive across regions instead of active-active?

Active-active would mean two regions could each accept and process a charge for the same idempotency key during a network partition, and reconciling two independently-committed ledger histories after the fact is close to impossible to do safely for real money. Active-passive with a synchronous (or tightly-bounded async) replica accepts a few minutes of unavailability during failover in exchange for a guarantee that there is always exactly one writer deciding the order of operations for any given account.

How do you catch bugs that silently break the ledger over time?

A nightly reconciliation batch job sums every account's ledger_entries and compares the result against the card network's settlement report and against the cached balance column; any mismatch pages the on-call engineer rather than being caught only when a customer complains. This is treated as a first-class part of the design, not an afterthought, precisely because the ledger schema prevents intra-transaction bugs but not cross-system drift against the external card network.

8. Summary: what a strong answer covers

Idempotency key as the first line of defenseDouble-entry as a schema-enforced invariantSharded by account_id, not payment_id Active-passive, not active-activeWebhooks fully asyncNightly reconciliation against the card network
Interview tip When asked to design a payment system, the strongest signal is treating consistency as non-negotiable rather than a knob to tune: every latency-saving trick (caching, async webhooks, read replicas) is applied everywhere except the write path that actually moves money.
No comments
Leave a Comment