Command Palette

Search for a command to run...

PHASE 4Intermediate ~8 min· topic 13 of 18Level 3

Problem 4.13 — Payment System (LLD)

In one line

Where correctness is non-negotiable: payment states, idempotency keys, double-charge prevention, and the integration-seam problem.

0/18 · 0%

Think of it like this

Paying at a shop with a card machine. If the machine glitches and you press pay twice, you should be charged once, not twice. This 'exactly once' guarantee is the single hardest rule in payment systems.

Key ideas

  1. 01

    Payment states: INITIATED → PROCESSING → SUCCEEDED / FAILED / REFUNDED; side transitions for disputed.

  2. 02

    Idempotency: every /charge carries an idempotencyKey; same key → same result, never a double charge.

  3. 03

    Gateway abstraction: PaymentGateway interface + StripeGateway/RazorpayGateway adapters — the adapter pattern at the heart.

  4. 04

    Money is cents (long), never double — rounding is not a bug, it's a class of bugs.

  5. 05

    Double-charge prevention in DB: unique index on (orderId, idempotencyKey) with INSERT-OR-IGNORE semantics.

  6. 06

    Refund: reversal must be traceable (refund records reference the original payment txn).

  7. 07

    Webhook handling: gateway notifies outcome; webhook processing must be idempotent too.

  8. 08

    Sequence: validate order → reserve → gateway charge → mark succeeded → trigger fulfillment ONCE.

Java / Spring map

  • →

    @Transactional with unique key on transaction table; a PaymentGateway port with test fakes.

Code & diagrams

DoubleChargeSequencediagram

The exact race this design defeats: a network retry that looks identical to a real second click.

Rendering diagram…
IdempotentCharge.javajava

The core guarantee boiled down.

public class PaymentService {
  private final PaymentRepository repo;
  private final PaymentGateway gateway;

  public PaymentResult charge(String orderId, String idempotencyKey, long amountCents) {
    // 1) idempotency: exactly one outcome per key — DB unique constraint enforces it
    if (repo.findByKey(idempotencyKey).isPresent())
      return repo.findByKey(idempotencyKey).get().result();

    Payment tx = new Payment(orderId, idempotencyKey, amountCents, Payment.Status.PROCESSING);
    try {
      gateway.charge(tx).whenComplete((ok, err) -> {
        tx.succeed(ok ? Payment.Status.SUCCEEDED : Payment.Status.FAILED);
        repo.save(tx);            // serialized append — never overwrites the first result
      });
    } catch (GatewayUnavailable e) {
      tx.failWith(e); repo.save(tx);       // recorded failure — retry uses a NEW key
    }
    return PaymentResult.pending(tx.id());
  }
}

Explain without notes

01

Trace a duplicated charge request (proxy retry) end-to-end and show where the duplicate dies.

Practice

01

Add the webhook path: gateway POSTs success — make the callback idempotent and race-safe with the retry path.

Trade-offs

  • ↔

    Strong, auditable idempotency costs a DB round-trip; async callback adds consistency lag. Both are the price of money.

Run it in production

Completion checklist

  • I can recite the money rules (cents, idempotency key, unique index, webhook idempotent).

Back to phase