Engineering Atlas › Low-Level Design

Code a payment gateway with pluggable providers

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: card data storage and PCI scope, 3-D Secure, currencies and FX, payouts to merchants, fraud scoring and the double-entry ledger. Name each; the ledger in particular belongs to the system design lesson.

Entities and relationships

Relationships: the gateway owns intents and idempotency records. An intent is composed of its attempts and refers to its processor by name, because processor configuration can change while intents live for days.

Keep refunds on the intent rather than in a separate aggregate: "total refunds never exceed capture" needs both numbers under one lock.

Class diagram

classDiagram
    class PaymentGateway {
      -intents: Map~IntentId, PaymentIntent~
      -idempotency: Map~Key, IdempotencyRecord~
      +create(key, merchant, amount) IntentId
      +authorize(id) Status
      +capture(id, amount)
      +void(id)
      +refund(id, amount)
      +resolve(id, outcome)
    }
    class PaymentIntent {
      +id: IntentId
      +merchant: string
      +amount: Money
      +status: Status
      +processor: string
      +authorizedAt: int
      +captured: Money
      +refunded: Money
      -attempts: Attempt[]
      +markAuthorized(processor, at)
      +capture(amount, now)
      +refund(amount)
    }
    class Attempt {
      +processor: string
      +at: int
      +outcome: Outcome
    }
    class IdempotencyRecord {
      +key: string
      +fingerprint: string
      +intentId: IntentId
    }
    class Processor {
      <<interface>>
      +name() string
      +authorize(intentId, amount) Outcome
    }
    class RoutingStrategy {
      <<interface>>
      +route(intent) Processor[]
    }
    class Clock {
      <<interface>>
      +now() int
    }
    PaymentGateway "1" *-- "many" PaymentIntent
    PaymentGateway "1" *-- "many" IdempotencyRecord
    PaymentIntent "1" *-- "many" Attempt
    PaymentGateway --> RoutingStrategy
    PaymentGateway --> Clock
    RoutingStrategy --> Processor
    AcquirerAAdapter ..|> Processor
    AcquirerBAdapter ..|> Processor

Key flows

Authorization with failover. The attempt is recorded before each call, so a crash mid-call leaves evidence, and failover happens only on an outcome that proves the processor did nothing.

sequenceDiagram
    participant M as Merchant
    participant G as PaymentGateway
    participant I as PaymentIntent
    participant R as RoutingStrategy
    participant A as Processor A
    participant B as Processor B
    M->>G: authorize(pi_7)
    G->>I: lock, status is created?
    G->>R: route(pi_7)
    R-->>G: [A, B]
    G->>I: record attempt A
    G->>A: authorize(pi_7, 4200)
    A-->>G: unavailable (connection refused)
    G->>I: record attempt B
    G->>B: authorize(pi_7, 4200)
    B-->>G: approved
    G->>I: markAuthorized(B, now), unlock
    G-->>M: authorized
    Note over G,B: A timeout from B would stop here as unknown

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

create returns the same intent for the same key and parameters and changes nothing on replay. authorize calls at most one processor that might have acted, and returns authorized, declined, failed (no processor acted) or unknown (one might have). capture succeeds once, only when authorized and not expired, for 1 up to the authorized amount. The sum of refunds never exceeds the captured amount. Any rejected call leaves the intent unchanged.

Layer 2The mechanism

In a database, intents are rows with a status column, and every transition is a conditional update: UPDATE intent SET status = 'captured', captured = $2 WHERE id = $1 AND status = 'authorized' AND authorized_at > $3, checking that one row changed. Idempotency is a table with a unique index on (merchant_id, key); inserting first and reading on conflict turns the race between two retries into a database decision. Attempts are written before the network call ("record, then call"), so after a crash a sweeper finds attempts with no outcome and reconciles them. Underneath the processor's HTTP API, card authorization travels as ISO 8583 messages: an authorization request has message type 0100 and the response 0110, with field 39 carrying the response code (00 for approved). The adapter's job is to turn that code space into four outcomes and to forward your intent id as the processor's idempotency key or merchant reference.

Layer 3The machine

The local work is microseconds: a row lock, a conditional update, a commit whose write-ahead log fsync costs roughly 0.1 to 1 ms on an SSD. The processor call is the expensive part: a TLS connection to the acquirer, then a hop to the card network and the issuer, typically a few hundred milliseconds and sometimes several seconds. That gap shapes the code. Holding a row lock across that call is fine for one intent but not for a shared counter, so per-merchant aggregates update asynchronously. Timeouts must be shorter than the merchant's own timeout, or the merchant retries while your first attempt is still in flight; a common layout is a 30 to 60 second processor timeout under a longer merchant-facing one, with the unknown state absorbing the difference. A TCP connection reset after the request bytes left the socket buffer tells you nothing about whether the processor acted, which is why "connection refused before sending" and "reset after sending" map to different outcomes.

Why it's built this way

  1. Networks lose responses, so merchants retry, so creation needs an idempotency key and transitions need state checks.
  2. A lost response can hide a successful authorization, so a timeout is unknown, not failed, and must not trigger failover.
  3. Unknown outcomes must be resolved from evidence, so attempts are recorded before each call and reconciled later.
  4. Processors fail independently and cost different amounts, so they sit behind adapters and a routing strategy, and failover is limited to provably safe outcomes.
  5. Rounding and race errors in money are both expensive and visible, so amounts are integers and every check-then-act runs under one lock or one conditional update.

Extensibility

Leadership lens

Check yourself

  1. Processor A times out on an authorization. Why is failing over to processor B wrong?

    Show answer

    A may have authorized the card before the response was lost. Calling B could authorize a second time, so the intent goes to unknown and is reconciled with A instead.

  2. A merchant reuses an idempotency key with a different amount. Why reject instead of returning the original intent?

    Show answer

    The merchant believes it is charging the new amount. Returning the old intent hides the bug and charges the wrong amount; a conflict error surfaces it.

  3. An intent was authorized for 4,200 and captured for 3,000. What is the refund limit, and why?

    Show answer

    3,000, because only the captured amount moved; the remaining authorization was released.

  4. Why record each attempt before calling the processor rather than after?

    Show answer

    A crash during the call would otherwise leave no trace that the processor was contacted, and the intent could be retried elsewhere. The pre-recorded attempt is what reconciliation looks for.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →