System design template

Payment system architecture.

The only system on this list where being approximately right is indistinguishable from being wrong, and where the provider, not you, holds the truth.

Download for macOS
v0.1.33 · .dmg · Apple Silicon & Intel
Signed & notarized by Apple · opens without a Gatekeeper warning
sha256 698955a0187bc039f4c74f5d05a9f10fbb27376a45788a0a241d1326b73873c7
Download for Windows instead
$curl -fsSL https://lucidtrain.com/install.sh | sh
Payment system architecture. 12 components across 5 tiers.
Payment system architecture. Rendered by the same ELK layout engine the app runs: the agent emits components, tiers and edges, and the engine places them, so the boxes cannot overlap.

The components

Every row below is read from the graph that produced the diagram above, so the two cannot disagree.

ComponentTierWhy it is there
CheckoutClientSupporting component
API gatewayEdgeSupporting component
Payment intentApplicationIdempotency key required on every write
LedgerApplicationDouble entry, append only
Risk & fraudApplicationSupporting component
ReconciliationApplicationCompares our ledger to the provider daily
Webhook handlerApplicationProvider is the source of truth for capture
PostgresDataSerializable for ledger writes
OutboxDataTransactional, so events cannot be lost
KafkaDataSupporting component
Payment providerExternalSupporting component
Banking railsExternalSupporting component

Design decisions worth arguing about

A diagram shows what was chosen. It does not show what it cost, and that is usually the part that matters in a review or an interview.

Idempotency keys are not optional

Networks retry, users double-click, and clients time out on requests the server actually completed. Without a caller-supplied idempotency key stored alongside the result, every one of those becomes a duplicate charge. The design cost is real: you have to store keys, decide how long to keep them, and define what happens when the same key arrives with a different payload, which should be an error rather than a silent overwrite.

Double entry is a constraint, not an accounting style

Recording every movement as balanced debits and credits on an append-only ledger means the books can be checked by summing them, and a bug that loses money shows up as an imbalance rather than as a number that is quietly wrong. It costs more rows and more discipline: no updates, no deletes, and corrections expressed as new compensating entries rather than edits to history.

The provider is the source of truth for capture

You can record an intent, but you cannot know a payment succeeded until the provider says so, and the provider says so by webhook, which can arrive late, out of order, or twice. Treating your own optimistic state as authoritative is how systems end up shipping goods for payments that later fail. The consequence is that webhook handling is a first-class part of the design rather than an afterthought, and it needs the same idempotency treatment as the API.

A transactional outbox instead of dual writes

Writing to the database and then publishing to a broker is two operations with no shared transaction, so a crash between them loses the event permanently. Writing the event into an outbox table inside the same transaction as the ledger entry, and relaying it separately, makes the publish exactly as durable as the write. The price is a relay component to run and monitor, plus at-least-once delivery downstream, which pushes idempotency onto every consumer.

Reconciliation is a feature, not a safety net

Even with all of the above, your ledger and the provider's will diverge, through partial failures, disputes, refunds processed out of band. A daily job comparing the two and flagging differences is the only mechanism that actually catches this, and the important design decision is what it does when it finds something: alerting a human is correct, auto-correcting is usually not.

How it changes with scale

Payment volume is small next to most systems on this list, so throughput is rarely the constraint. What grows painfully is the ledger, because it is append-only and never deleted, and the reconciliation window, because comparing two growing datasets daily gets slower. Partitioning the ledger by period is the usual answer and it should be planned before it is needed, since migrating an append-only table under load is unpleasant.

Where it breaks first

Webhook delivery gaps. Providers retry, but not forever, and a handler that is down or returning errors during that window loses the notification permanently. The system then believes payments are pending that have long since succeeded. This is exactly what reconciliation is for, which is why a design that treats it as optional fails silently and slowly rather than loudly and fast.

Draw this yourself

Open the Diagram tab and describe the system. The agent emits a semantic graph rather than coordinates, so you can edit the components and the layout re-solves instead of drifting.

shell
$ Diagram a payment system: payment intent service with idempotency keys, risk scoring, a double-entry ledger on Postgres, a transactional outbox publishing to Kafka, webhook handling from the payment provider, and a daily reconciliation job.

When the shape is right, Implement in code turns the canvas into a markdown specification, every component, every relationship and the notes, and starts a real turn in the Code tab with it.

FAQ

Questions about this design

Why serializable isolation for ledger writes?

Because the invariants are about sums across rows, not about single rows, and weaker isolation levels permit anomalies where two concurrent transactions each read a consistent balance and both write, producing a total neither of them saw. The throughput cost is acceptable at payment volumes and is much cheaper than reasoning about the anomalies.

Should the ledger and the application share a database?

Sharing makes the outbox pattern trivial, since the event and the ledger entry commit together. Splitting them buys isolation at the cost of needing a distributed transaction or a saga, which is a large amount of complexity to take on for a system whose defining requirement is correctness.

How do you handle refunds and chargebacks?

As new ledger entries that reverse the original, never as modifications to it. History has to remain intact because it is what reconciliation and any subsequent dispute are checked against.

Do you need PCI compliance for this?

Not if card details never touch your servers, which is the reason for the presigned or hosted-field pattern where the client talks to the provider directly and you only ever see a token. Handling raw card numbers changes the scope of the entire system, not just this diagram.

More templates