LLD Hub
lldstrategychain-of-responsibilityobserver

Payment Gateway Low Level Design — Complete LLD Interview Guide

Design a payment processing system with idempotency, multiple payment methods, fraud detection hooks, and webhook notifications. Core topic at Razorpay, PayU.

12 April 2025·10 min read

Practice this problem

Payment Gateway — get AI-scored feedback on your solution

Solve it →

Payment Gateway Low Level Design is a senior-level problem asked at Razorpay, PayU, Stripe, and PayPal. It requires idempotency for safe retries, a pluggable payment provider strategy, fraud detection hooks, and webhook-based status notifications. This guide covers the complete Payment Gateway LLD with Java code, class diagram, and FAQ.

Why Interviewers Ask Payment Gateway LLD

Payment systems require defensive design that most problems do not. Interviewers want to see:

  • Do you implement idempotency — preventing double-charge on network retry?
  • Can you design a payment provider abstraction — swap Razorpay for Stripe with zero code changes?
  • Do you use Chain of Responsibility for fraud detection, limit checks, and auth?
  • Can you design payment state machine: INITIATED → PROCESSING → SUCCESS / FAILED / REFUNDED?
  • Do you handle webhooks for asynchronous payment confirmation from the provider?

Functional Requirements

  • Initiate a payment — create a payment intent with amount, currency, and method
  • Process payment via card, UPI, net banking, or wallet
  • Idempotent payment processing — same request key never double-charges
  • Refund a successful payment (full or partial)
  • Send webhook notifications to merchants on payment success, failure, or refund
  • Record every payment attempt and provider response for audit
  • Support multiple payment providers (Razorpay, Stripe) with failover

Non-Functional Requirements

  • Payment processing must be idempotent — retries are safe
  • No payment must be lost — at-least-once processing with deduplication
  • Adding a new payment provider must not change existing code (OCP)
  • Fraud checks must run before every payment without coupling to business logic

Core Entities — Payment Gateway LLD Class Design

  • Payment — id, merchantId, amount, currency, method, status, idempotencyKey
  • PaymentMethod — type, card/UPI/wallet details (polymorphic)
  • Refund — id, paymentId, amount, reason, status, initiatedAt
  • PaymentProvider — interface; RazorpayProvider, StripeProvider implement it
  • PaymentHandler — abstract; Chain of Responsibility for fraud, limits, auth
  • WebhookService — dispatches events to merchant-configured webhook URLs
  • PaymentAuditLog — raw provider request/response for every attempt

Text-Based Class Diagram

Payment
+-- id, merchantId: String
+-- amount: BigDecimal, currency: String
+-- method: PaymentMethod
+-- status: PaymentStatus (INITIATED/PROCESSING/SUCCESS/FAILED/REFUNDED)
+-- idempotencyKey: String (unique per merchant)
+-- providerPaymentId: String
+-- createdAt: LocalDateTime

PaymentMethod (abstract)
+-- type: MethodType (CARD/UPI/WALLET/NET_BANKING)
CardPayment    extends PaymentMethod -- cardNumber, expiry, cvv
UpiPayment     extends PaymentMethod -- vpa (virtual payment address)
WalletPayment  extends PaymentMethod -- walletType, walletAccountId

Refund
+-- id, paymentId: String
+-- amount: BigDecimal
+-- reason: String
+-- status: RefundStatus (INITIATED/SUCCESS/FAILED)

PaymentProvider (interface)
+-- charge(payment): ProviderResponse
+-- refund(paymentId, amount): ProviderResponse
+-- getProviderName(): String

RazorpayProvider implements PaymentProvider
StripeProvider   implements PaymentProvider

PaymentHandler (abstract, chain)
+-- setNext(handler): PaymentHandler
+-- handle(payment): PaymentResult

FraudCheckHandler  extends PaymentHandler
LimitCheckHandler  extends PaymentHandler
AuthCheckHandler   extends PaymentHandler

Chain of Responsibility — Pre-Payment Checks

public abstract class PaymentHandler {
    protected PaymentHandler next;

    public PaymentHandler setNext(PaymentHandler next) {
        this.next = next;
        return next; // enables chaining: fraud.setNext(limit).setNext(auth)
    }

    public abstract PaymentResult handle(Payment payment);

    protected PaymentResult proceed(Payment payment) {
        if (next != null) return next.handle(payment);
        return PaymentResult.proceed();
    }
}

public class FraudCheckHandler extends PaymentHandler {
    private final FraudService fraudService;

    @Override
    public PaymentResult handle(Payment payment) {
        FraudScore score = fraudService.evaluate(payment);
        if (score.isHighRisk()) {
            return PaymentResult.reject("Payment flagged as high-risk by fraud engine");
        }
        return proceed(payment);
    }
}

public class LimitCheckHandler extends PaymentHandler {
    private final MerchantService merchantService;

    @Override
    public PaymentResult handle(Payment payment) {
        Merchant merchant = merchantService.findById(payment.getMerchantId());
        if (payment.getAmount().compareTo(merchant.getTransactionLimit()) > 0) {
            return PaymentResult.reject("Amount exceeds merchant transaction limit");
        }
        return proceed(payment);
    }
}

public class AuthCheckHandler extends PaymentHandler {
    @Override
    public PaymentResult handle(Payment payment) {
        if (!payment.getMerchant().isActive()) {
            return PaymentResult.reject("Merchant account is inactive");
        }
        return proceed(payment);
    }
}

Idempotent Payment Processing

public class PaymentService {
    private final PaymentRepository paymentRepo;
    private final PaymentProvider primaryProvider;
    private final PaymentProvider fallbackProvider;
    private final PaymentHandler handlerChain;
    private final WebhookService webhookService;

    public Payment initiatePayment(PaymentRequest req) {
        // Idempotency check
        Optional<Payment> existing = paymentRepo.findByIdempotencyKey(
            req.getMerchantId(), req.getIdempotencyKey());
        if (existing.isPresent()) {
            return existing.get(); // Return the original result — safe to retry
        }

        Payment payment = new Payment(UUID.randomUUID().toString(),
            req.getMerchantId(), req.getAmount(), req.getCurrency(),
            req.getMethod(), PaymentStatus.INITIATED, req.getIdempotencyKey());
        paymentRepo.save(payment);
        return payment;
    }

    public Payment processPayment(String paymentId) {
        Payment payment = paymentRepo.findById(paymentId);
        if (payment.getStatus() != PaymentStatus.INITIATED)
            return payment; // Idempotent — already processed

        // Run pre-payment checks
        PaymentResult checkResult = handlerChain.handle(payment);
        if (!checkResult.isAllowed()) {
            payment.setStatus(PaymentStatus.FAILED);
            payment.setFailureReason(checkResult.getReason());
            paymentRepo.save(payment);
            webhookService.dispatch(payment, WebhookEvent.PAYMENT_FAILED);
            return payment;
        }

        payment.setStatus(PaymentStatus.PROCESSING);
        paymentRepo.save(payment);

        // Try primary provider, fall back if unavailable
        ProviderResponse response;
        try {
            response = primaryProvider.charge(payment);
        } catch (ProviderUnavailableException e) {
            response = fallbackProvider.charge(payment);
        }

        payment.setStatus(response.isSuccess() ? PaymentStatus.SUCCESS : PaymentStatus.FAILED);
        payment.setProviderPaymentId(response.getProviderPaymentId());
        paymentRepo.save(payment);

        WebhookEvent event = response.isSuccess() ? WebhookEvent.PAYMENT_SUCCESS : WebhookEvent.PAYMENT_FAILED;
        webhookService.dispatch(payment, event);
        return payment;
    }

    public Refund refundPayment(String paymentId, BigDecimal amount, String reason) {
        Payment payment = paymentRepo.findById(paymentId);
        if (payment.getStatus() != PaymentStatus.SUCCESS)
            throw new InvalidPaymentStateException("Only successful payments can be refunded");
        if (amount.compareTo(payment.getAmount()) > 0)
            throw new InvalidRefundAmountException("Refund cannot exceed original payment amount");

        ProviderResponse response = primaryProvider.refund(payment.getProviderPaymentId(), amount);
        Refund refund = new Refund(UUID.randomUUID().toString(), paymentId, amount, reason,
            response.isSuccess() ? RefundStatus.SUCCESS : RefundStatus.FAILED);
        refundRepo.save(refund);

        if (response.isSuccess()) {
            payment.setStatus(PaymentStatus.REFUNDED);
            paymentRepo.save(payment);
            webhookService.dispatch(payment, WebhookEvent.PAYMENT_REFUNDED);
        }
        return refund;
    }
}

Key Design Decisions

  • Idempotency key indexed on (merchantId, key): The same merchant can reuse key names across different merchants without collision. The composite index is unique per merchant, not globally. This matches how Stripe and Razorpay implement idempotency.
  • Chain of Responsibility for pre-payment checks: Fraud, limit, and auth checks are independent and ordered. Each handler either rejects (short-circuits) or passes to the next. Adding a new check (e.g., velocity check) is a new handler — no changes to existing code.
  • Provider abstraction with fallback: PaymentService does not know whether it is calling Razorpay or Stripe. On primary provider failure, it transparently falls back. Switching providers or adding new ones is a new implementation class only.
  • Webhook dispatch after state save: Always persist payment state before firing webhooks. If the webhook call fails, the payment state is still correct. A retry job can re- dispatch failed webhooks from the saved state.

Common Follow-Up Questions

  • "What happens if the payment succeeds but the webhook fails?" — Use at-least-once webhook delivery. Store WebhookEvent in a delivery table with PENDING status. A background job retries PENDING events with exponential backoff. The merchant's endpoint must be idempotent.
  • "How do you handle partial refunds?" — Track total refunded amount on Payment. Each refund adds to this total. Reject refund if (existingRefunds + newRefund) exceeds the original amount. Payment stays in PARTIALLY_REFUNDED state until fully refunded.
  • "How do you prevent replay attacks on webhooks?" — Include a timestamp and HMAC signature in the webhook payload. The merchant verifies the signature using the shared secret and rejects requests older than 5 minutes.

FAQ — Payment Gateway Low Level Design

What is idempotency in payment systems?

Idempotency means processing the same request multiple times produces the same result without side effects. In payments, the client sends an idempotency key with each request. If the server already processed a request with that key, it returns the cached result instead of charging again. This makes network retries safe.

What design patterns are used in Payment Gateway LLD?

The primary patterns are Chain of Responsibility (fraud, limit, auth checks),Strategy (PaymentProvider — Razorpay, Stripe), State Machine(payment lifecycle), and Observer (webhook events on status changes).

How do you handle payment provider failover?

Wrap the primary provider call in a try-catch for ProviderUnavailableException. On failure, delegate to the fallback provider with the same Payment object. Log which provider processed each payment in the audit trail. For production, use a circuit breaker (Resilience4j) to avoid hammering a failing provider.

How do you design a refund flow?

Validate that the payment is in SUCCESS state and the refund amount does not exceed the original. Create a Refund entity in INITIATED state. Call the provider's refund API with the original provider payment ID. On success, update Refund to SUCCESS and Payment to REFUNDED. Fire a PAYMENT_REFUNDED webhook to the merchant.

Ready to practice?

Submit your solution and get AI-scored feedback on OOP, SOLID principles, design patterns, and code quality.

Solve Payment Gateway