Design a Payment Gateway
OOP design for a payment gateway covering transaction lifecycle, multi-provider routing with Strategy pattern, idempotency handling, retry logic, refund processing, and fraud detection hooks.
The Problem
Your company processes payments for 500 merchants. The current system calls Stripe directly from a monolithic checkout controller with hardcoded API keys. Last month, Stripe had a 40-minute outage. Every single checkout failed, and customers abandoned $230K worth of carts. Your CTO wants a gateway layer that can route payments through multiple providers, retry on failures, and never accidentally charge a customer twice.
Payment gateways are harder than typical CRUD systems because money is involved. A bug that creates a duplicate charge erodes customer trust overnight. The system must handle partial failures gracefully: what happens when the provider says "timeout" but actually processed the charge? You need idempotency keys to prevent double-charging, a state machine to track transaction lifecycle, and a provider routing strategy that can fail over without human intervention.
Design the core classes for a payment gateway that handles multi-provider routing, transaction lifecycle management, idempotency enforcement, retry with exponential backoff, full and partial refunds, and pre-authorization fraud detection hooks.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to narrow the problem. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "What payment methods does the system support? Just credit cards, or others too?"
Interviewer: "Support credit cards, debit cards, UPI, and digital wallets. The system should make it easy to add new payment methods later."
Four payment methods with different validation rules and provider compatibility. Each method has its own data shape (card number vs. UPI ID vs. wallet token), so a polymorphic hierarchy makes sense.
You: "How many payment providers does the system integrate with? Can a single payment be routed to different providers?"
Interviewer: "Three providers initially: Stripe, PayPal, and Razorpay. Not every provider supports every payment method. The gateway should pick the best provider based on method, amount, and region."
Provider routing is the core design challenge. The selection logic must consider method support, regional availability, and potentially cost or success rate. That is a clear Strategy pattern signal.
You: "What happens when a provider times out? Should the system retry automatically?"
Interviewer: "Yes, retry with exponential backoff up to three attempts. If all retries fail, try a fallback provider before marking the transaction as failed."
Retry plus fallback means the routing strategy is not just "pick one provider." It is a chain: primary provider, retries, then fallback to a secondary provider. The retry logic must be idempotent so retries never create duplicate charges.
You: "How do we prevent duplicate charges? If a customer clicks 'Pay' twice, what happens?"
Interviewer: "Every payment request carries an idempotency key. If the gateway has already processed that key, return the original result instead of processing again. The key is a hash of merchant ID, order ID, and amount."
Idempotency is non-negotiable for financial systems. The gateway must check the key before any provider call and store the result after completion. This is effectively a cache-and-return pattern.
You: "Does the system support refunds? Full and partial?"
Interviewer: "Yes, both. A successful transaction can be fully or partially refunded. Partial refunds reduce the transaction amount. You cannot refund more than the original charge."
Refunds introduce a secondary lifecycle. A transaction goes from SUCCESS to REFUND_REQUESTED to REFUNDED (or PARTIALLY_REFUNDED). The refund amount must be validated against the remaining refundable balance.
You: "Is there any fraud detection before the charge goes through?"
Interviewer: "Yes, run a set of fraud checks before authorizing. Flag suspicious transactions for manual review. The fraud rules should be pluggable so new checks can be added without modifying existing code."
Pluggable fraud checks with independent rules that can flag or block a transaction. That is an Observer or Chain of Responsibility pattern. Each rule evaluates independently, and any rule can veto the payment.
You: "Should the system handle currency conversion, or does each provider handle that?"
Interviewer: "Store amounts as BigDecimal with an explicit currency code. The provider handles actual conversion. The gateway just passes the currency through."
Good. We avoid floating-point rounding issues by using BigDecimal, and delegate currency conversion to providers. Our model just tracks the currency code alongside the amount.
You: "Do merchants need webhooks for payment status updates?"
Interviewer: "Yes, notify the merchant via webhook when a transaction succeeds, fails, or is refunded. But webhook delivery is out of scope for the core class design. Just fire an event that a webhook module can subscribe to."
Event-driven notifications fit the Observer pattern. The gateway fires domain events, and subscribers (webhook module, analytics, audit log) react independently.
Final Requirements
Functional Requirements:
- Process payments through multiple providers (Stripe, PayPal, Razorpay)
- Route payments to the best provider based on payment method, amount, and region
- Enforce idempotency: reject duplicate payment requests using idempotency keys
- Retry failed/timed-out payments with exponential backoff (max 3 attempts)
- Fall back to a secondary provider when the primary exhausts retries
- Support full and partial refunds with amount validation
- Run pluggable fraud detection checks before authorization
- Track transaction lifecycle through a state machine (INITIATED, PROCESSING, SUCCESS, FAILED, TIMEOUT, REFUND_REQUESTED, REFUNDED)
Non-Functional Requirements:
- Thread safety for concurrent payment processing
- Extensibility for new payment methods, providers, and fraud rules
- Auditability: every state transition is logged with timestamps
Out of Scope:
- PCI DSS compliance and card tokenization (delegated to providers)
- Webhook delivery infrastructure
- UI/frontend integration
- Database persistence layer
- Currency conversion logic
- Recurring billing and subscriptions
Example Inputs and Outputs
Scenario 1: Successful credit card payment
- Input: Merchant "ShopifyStore" submits a payment of $99.99 USD via credit card, region US, idempotency key
abc-123 - Expected: Gateway selects Stripe (best for US credit cards), processes the charge, returns a Transaction with state SUCCESS and provider reference ID
- Why: Validates the happy path through provider routing, processing, and state transitions
Scenario 2: Provider timeout with retry and fallback
- Input: Merchant submits $50.00 USD via debit card, region IN. Razorpay (primary for India) times out on all 3 retries
- Expected: Gateway retries Razorpay 3 times with exponential backoff (1s, 2s, 4s delays). All fail. Falls back to Stripe. Stripe succeeds. Transaction state is SUCCESS with provider = Stripe
- Why: Validates retry logic, exponential backoff, and provider fallback chain
Scenario 3: Duplicate payment (idempotency)
- Input: Same idempotency key
abc-123submitted again after Scenario 1 completed - Expected: Gateway detects the key exists, skips provider call entirely, returns the original SUCCESS transaction
- Why: Validates idempotency enforcement prevents double-charging
Scenario 4: Partial refund
- Input: Refund $30.00 of the $99.99 transaction from Scenario 1
- Expected: Gateway validates the refund amount (
$30 <= $99.99remaining), calls Stripe's refund API, updates transaction to PARTIALLY_REFUNDED with $69.99 refundable balance - Why: Validates partial refund logic and amount tracking
Try It Yourself
Try it yourself
Before reading the solution, spend 15-20 minutes sketching your own class diagram. Focus on the transaction state machine and how you would route payments to different providers. Think about where idempotency checks fit in the flow. Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Start by asking: what are the main "things" in this problem? Look for nouns in your requirements: payment, transaction, provider, merchant, payment method, refund, idempotency key, fraud check. Each noun is a candidate entity. Now decide which ones deserve their own class.
A common mistake in payment system design is lumping everything into a single PaymentService god class. That class would handle routing, retries, fraud checks, refunds, and state management. It would be 500 lines long and impossible to test. Good design means each class has a single, clear job.
| Entity | Responsibility | Key attributes |
|---|---|---|
| PaymentGateway | The orchestrator. Receives payment requests, enforces idempotency, delegates to routing and fraud checks. | merchants, idempotencyStore, providerRouter, fraudDetector |
| Transaction | Tracks the lifecycle of a single payment. Owns state transitions and refund balance. | id, amount, currency, state, provider, idempotencyKey, refundedAmount |
| TransactionState | Enum representing lifecycle stages. Guards invalid transitions. | INITIATED, PROCESSING, SUCCESS, FAILED, TIMEOUT, REFUND_REQUESTED, REFUNDED, PARTIALLY_REFUNDED |
| PaymentMethod | Abstract type for different payment instruments. Each subtype carries method-specific data. | type (CREDIT_CARD, DEBIT_CARD, UPI, WALLET) |
| PaymentProvider | Strategy interface for provider integration. Each implementation wraps a specific provider's API. | name, supportedMethods, supportedRegions |
| Merchant | Identifies the business submitting the payment. Carries configuration like default currency and region. | id, name, region, apiKey |
| Refund | Tracks a single refund operation against a transaction. Supports full and partial. | id, transactionId, amount, status, createdAt |
| IdempotencyKey | Value object that uniquely identifies a payment request. Hash of merchant + order + amount. | key, transactionId, createdAt |
| FraudCheck | Strategy interface for pre-authorization fraud rules. Each implementation is an independent check. | name, evaluate(transaction) |
| Money | Value object wrapping BigDecimal amount with currency code. Prevents floating-point math. | amount (BigDecimal), currency (String) |
Notice we separated PaymentGateway from PaymentProvider. The gateway is your system; the provider is an external service. The gateway orchestrates the flow (idempotency, fraud, routing, retries), while each provider just knows how to call one external API. Merging them violates SRP because routing logic and API integration change for different reasons.
Step 2: Define Relationships and Class Design
PaymentGateway (the orchestrator)
This is the entry point for all payment operations. It coordinates the full flow: idempotency check, fraud screening, provider routing, charging, retries, and state transitions.
Deriving state from requirements:
| Requirement | What PaymentGateway must track |
|---|---|
| "Reject duplicate payment requests" | A store of idempotency keys mapped to transactions |
| "Route to the best provider" | A provider router (strategy) |
| "Run fraud checks before authorization" | A fraud detector |
| "Track all transactions" | A transaction store |
This gives us:
PaymentGateway:
idempotencyStore: Map<String, Transaction>
transactionStore: Map<String, Transaction>
providerRouter: ProviderRouter
fraudDetector: FraudDetector
retryConfig: RetryConfig
Deriving methods from needs:
| Need from requirements | Method |
|---|---|
| "Process a payment" | processPayment(request): Transaction |
| "Refund a transaction" | refund(transactionId, amount): Refund |
| "Check idempotency" | private: checkIdempotency(key): Transaction? |
| "Retry with backoff" | private: executeWithRetry(provider, txn): ProviderResponse |
Transaction (the state machine)
The transaction is the central domain object. It owns its lifecycle state and enforces valid transitions. You never set state directly; you call transitionTo() which validates the transition.
Deriving state from requirements:
| Requirement | What Transaction must track |
|---|---|
| "Track lifecycle stages" | Current state (enum) |
| "Support partial refunds" | Total refunded amount |
| "Provider returns a reference" | Provider reference ID |
| "Idempotency per request" | The idempotency key |
Valid state transitions:
I find this state diagram is the single most important artifact in a payment gateway interview. Draw it early. It shows the interviewer you understand the full lifecycle and have thought about edge cases like timeout retries and partial refunds.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.
Related Articles
OOP design for an expense-splitting application covering group management, multiple split strategies (equal, exact, percentage), balance simplification with debt graph minimization, and settlement tracking.
OOP design for an e-commerce platform covering product catalog, shopping cart, order lifecycle, inventory management, payment processing, and seller marketplace with search and filtering.