Opening: Why We Need to Abstract Payment Gateways
Payment processing is one of those integrations that seems simple until you scale. You start with Stripe, then expand to India and need Razorpay, then you're operating in markets where local gateways dominate. The problem isn't learning each API—it's that abstracting payment gateways properly prevents your codebase from becoming a maze of if-else checks and duplicate retry logic. When I worked on an e-commerce platform that grew from one market to five, I learned the hard way that without a solid abstraction layer, every new gateway means touching payment logic in dozens of places.
This article walks through a production pattern I've used to integrate Stripe, Razorpay, and regional gateways using three design patterns working in concert: Adapter, Factory, and Strategy. We'll see how each solves a specific problem and how they fit together to keep your payment code maintainable as you scale.
The Adapter Pattern: One Interface for Every Gateway
Each payment provider has its own quirks. Stripe uses publishableKey and secretKey, Razorpay uses keyId and keySecret, and a local gateway might use something entirely different. Rather than scatter these differences throughout your codebase, the Adapter pattern gives you a single interface that every gateway implements.
Here's the core abstraction:
interface PaymentGateway {
initiate(params: PaymentInitiateRequest): Promise<PaymentInitiateResponse>;
capture(transactionId: string, amount: number): Promise<PaymentCaptureResponse>;
refund(transactionId: string, amount: number): Promise<PaymentRefundResponse>;
verify(transactionId: string): Promise<PaymentVerifyResponse>;
}
interface PaymentInitiateRequest {
orderId: string;
amount: number;
currency: string;
customerId: string;
metadata?: Record<string, any>;
}
interface PaymentInitiateResponse {
transactionId: string;
redirectUrl?: string;
clientSecret?: string;
status: "pending" | "ready";
}
interface PaymentCaptureResponse {
transactionId: string;
status: "success" | "failed";
amount: number;
processedAt: Date;
}
interface PaymentRefundResponse {
refundId: string;
status: "success" | "failed";
amount: number;
}
interface PaymentVerifyResponse {
transactionId: string;
status: "completed" | "failed" | "pending";
amount: number;
paidAt: Date;
}Now the Stripe adapter normalizes Stripe's response into your types:
class StripeAdapter implements PaymentGateway {
private stripe: any;
constructor(secretKey: string) {
this.stripe = require("stripe")(secretKey);
}
async initiate(params: PaymentInitiateRequest): Promise<PaymentInitiateResponse> {
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: params.currency.toLowerCase(),
product_data: { name: `Order ${params.orderId}` },
unit_amount: Math.round(params.amount * 100),
},
quantity: 1,
},
],
mode: "payment",
success_url: `https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `https://yoursite.com/cancel`,
metadata: { orderId: params.orderId, customerId: params.customerId },
});
return {
transactionId: session.id,
redirectUrl: session.url,
status: "ready",
};
}
async capture(transactionId: string, amount: number): Promise<PaymentCaptureResponse> {
// Stripe auto-captures in most flows; this would verify the payment intent
const paymentIntent = await this.stripe.paymentIntents.retrieve(transactionId);
return {
transactionId: paymentIntent.id,
status: paymentIntent.status === "succeeded" ? "success" : "failed",
amount: paymentIntent.amount / 100,
processedAt: new Date(paymentIntent.created * 1000),
};
}
async refund(transactionId: string, amount: number): Promise<PaymentRefundResponse> {
const refund = await this.stripe.refunds.create({
payment_intent: transactionId,
amount: Math.round(amount * 100),
});
return {
refundId: refund.id,
status: refund.status === "succeeded" ? "success" : "failed",
amount: refund.amount / 100,
};
}
async verify(transactionId: string): Promise<PaymentVerifyResponse> {
const paymentIntent = await this.stripe.paymentIntents.retrieve(transactionId);
return {
transactionId: paymentIntent.id,
status: paymentIntent.status === "succeeded" ? "completed" : "pending",
amount: paymentIntent.amount / 100,
paidAt: new Date(paymentIntent.created * 1000),
};
}
}
class RazorpayAdapter implements PaymentGateway {
private razorpay: any;
constructor(keyId: string, keySecret: string) {
this.razorpay = require("razorpay")({
key_id: keyId,
key_secret: keySecret,
});
}
async initiate(params: PaymentInitiateRequest): Promise<PaymentInitiateResponse> {
const order = await this.razorpay.orders.create({
amount: Math.round(params.amount * 100),
currency: params.currency,
receipt: params.orderId,
notes: { customerId: params.customerId, ...params.metadata },
});
return {
transactionId: order.id,
clientSecret: order.id, // Razorpay uses order ID as the identifier
status: "ready",
};
}
async capture(transactionId: string, amount: number): Promise<PaymentCaptureResponse> {
// Razorpay auto-captures; retrieve payment details
const payments = await this.razorpay.payments.all({ limit: 1 });
const payment = payments.items[0];
return {
transactionId: payment.id,
status: payment.status === "captured" ? "success" : "failed",
amount: payment.amount / 100,
processedAt: new Date(payment.created_at * 1000),
};
}
async refund(transactionId: string, amount: number): Promise<PaymentRefundResponse> {
const refund = await this.razorpay.payments.refund(transactionId, {
amount: Math.round(amount * 100),
});
return {
refundId: refund.id,
status: refund.status === "processed" ? "success" : "failed",
amount: refund.amount / 100,
};
}
async verify(transactionId: string): Promise<PaymentVerifyResponse> {
const payment = await this.razorpay.payments.fetch(transactionId);
return {
transactionId: payment.id,
status: payment.status === "captured" ? "completed" : "pending",
amount: payment.amount / 100,
paidAt: new Date(payment.created_at * 1000),
};
}
}The beauty of the Adapter is that your business logic never knows it's talking to Stripe or Razorpay. It always calls the same interface, and the adapter translates between your domain language and the provider's API.
The Factory Pattern: Selecting the Right Gateway
You can't hard-code which gateway to use—it depends on where your customer is, what currency they're paying in, or which gateways your merchant account supports. The Factory pattern makes this decision for you based on configuration.
interface GatewayConfig {
provider: "stripe" | "razorpay" | "local";
credentials: Record<string, string>;
supported_currencies: string[];
supported_countries: string[];
priority: number;
}
class PaymentGatewayFactory {
private gateways: Map<string, GatewayConfig> = new Map();
register(name: string, config: GatewayConfig): void {
this.gateways.set(name, config);
}
private instantiate(config: GatewayConfig): PaymentGateway {
switch (config.provider) {
case "stripe":
return new StripeAdapter(config.credentials.secretKey);
case "razorpay":
return new RazorpayAdapter(config.credentials.keyId, config.credentials.keySecret);
case "local":
return new LocalGatewayAdapter(config.credentials.apiUrl, config.credentials.apiKey);
default:
throw new Error(`Unknown provider: ${config.provider}`);
}
}
selectGateway(
amount: number,
currency: string,
country: string,
merchantId: string
): PaymentGateway {
const validGateways = Array.from(this.gateways.values())
.filter(
(config) =>
config.supported_currencies.includes(currency) &&
config.supported_countries.includes(country)
)
.sort((a, b) => b.priority - a.priority);
if (validGateways.length === 0) {
throw new Error(
`No payment gateway available for ${currency} in ${country}`
);
}
return this.instantiate(validGateways[0]);
}
}
// Usage during app startup
const factory = new PaymentGatewayFactory();
factory.register("stripe-primary", {
provider: "stripe",
credentials: {
secretKey: process.env.STRIPE_SECRET_KEY,
},
supported_currencies: ["USD", "EUR", "GBP"],
supported_countries: ["US", "GB", "DE", "FR"],
priority: 100,
});
factory.register("razorpay-india", {
provider: "razorpay",
credentials: {
keyId: process.env.RAZORPAY_KEY_ID,
keySecret: process.env.RAZORPAY_KEY_SECRET,
},
supported_currencies: ["INR"],
supported_countries: ["IN"],
priority: 100,
});
factory.register("local-gateway", {
provider: "local",
credentials: {
apiUrl: process.env.LOCAL_GATEWAY_URL,
apiKey: process.env.LOCAL_GATEWAY_KEY,
},
supported_currencies: ["LKR"],
supported_countries: ["LK"],
priority: 100,
});Now when you need a gateway, the factory decides for you based on the transaction's context. No if-else chains scattered throughout your codebase.
The Strategy Pattern: Fallback and Retry Logic
Real payments fail. Networks timeout, rate limits hit, providers go down. The Strategy pattern lets you define multiple payment strategies and switch between them when one fails.
interface PaymentStrategy {
execute(request: PaymentInitiateRequest): Promise<PaymentInitiateResponse>;
}
class PrimaryGatewayStrategy implements PaymentStrategy {
constructor(
private factory: PaymentGatewayFactory,
private logger: any
) {}
async execute(request: PaymentInitiateRequest): Promise<PaymentInitiateResponse> {
try {
const gateway = this.factory.selectGateway(
request.amount,
request.currency,
request.country, // assume country is in request
request.merchantId // assume merchant ID is in request
);
return await gateway.initiate(request);
} catch (error) {
this.logger.error("Primary gateway failed", { error, request });
throw error;
}
}
}
class FallbackGatewayStrategy implements PaymentStrategy {
constructor(
private primary: PaymentStrategy,
private fallback: PaymentStrategy,
private logger: any
) {}
async execute(request: PaymentInitiateRequest): Promise<PaymentInitiateResponse> {
try {
return await this.primary.execute(request);
} catch (primaryError) {
this.logger.warn("Primary strategy failed, trying fallback", {
error: primaryError,
});
try {
return await this.fallback.execute(request);
} catch (fallbackError) {
this.logger.error("Both primary and fallback strategies failed", {
primaryError,
fallbackError,
});
throw new Error("All payment strategies exhausted");
}
}
}
}
class RetryStrategy implements PaymentStrategy {
constructor(
private strategy: PaymentStrategy,
private maxRetries: number = 3,
private logger: any
) {}
async execute(request: PaymentInitiateRequest): Promise<PaymentInitiateResponse> {
let lastError: any;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await this.strategy.execute(request);
} catch (error) {
lastError = error;
this.logger.warn(`Payment attempt ${attempt} failed`, {
error,
attempt,
maxRetries: this.maxRetries,
});
if (attempt < this.maxRetries) {
const delay = Math.pow(2, attempt - 1) * 1000; // Exponential backoff
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
}
// Compose strategies
const primary = new PrimaryGatewayStrategy(factory, logger);
const withFallback = new FallbackGatewayStrategy(primary, fallbackStrategy, logger);
const withRetry = new RetryStrategy(withFallback, 3, logger);
// Usage
const response = await withRetry.execute(paymentRequest);This composition approach means you can stack behavior cleanly. Retry wraps Fallback, which wraps Primary. Each strategy does one thing well.
Uniform Webhooks: Normalizing External Events
Payment providers send webhooks in their own formats. Stripe sends charge.succeeded, Razorpay sends payment.authorized. You need to normalize these into your own domain events so your order processing logic stays decoupled from provider quirks.
enum PaymentEventType {
PAYMENT_COMPLETED = "payment.completed",
PAYMENT_FAILED = "payment.failed",
PAYMENT_REFUNDED = "payment.refunded",
PAYMENT_DISPUTED = "payment.disputed",
}
interface NormalizedPaymentEvent {
type: PaymentEventType;
transactionId: string;
orderId: string;
amount: number;
currency: string;
providerId: string;
timestamp: Date;
metadata?: Record<string, any>;
}
interface WebhookParser {
parseAndVerify(rawWebhook: any, signature: string): Promise<NormalizedPaymentEvent>;
}
class StripeWebhookParser implements WebhookParser {
constructor(private webhookSecret: string) {}
async parseAndVerify(
rawBody: any,
signature: string
): Promise<NormalizedPaymentEvent> {
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
let event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
} catch (error) {
throw new Error("Invalid Stripe webhook signature");
}
switch (event.type) {
case "charge.succeeded":
return {
type: PaymentEventType.PAYMENT_COMPLETED,
transactionId: event.data.object.payment_intent,
orderId: event.data.object.metadata.orderId,
amount: event.data.object.amount / 100,
currency: event.data.object.currency,
providerId: "stripe",
timestamp: new Date(event.created * 1000),
};
case "charge.failed":
return {
type: PaymentEventType.PAYMENT_FAILED,
transactionId: event.data.object.payment_intent,
orderId: event.data.object.metadata.orderId,
amount: event.data.object.amount / 100,
currency: event.data.object.currency,
providerId: "stripe",
timestamp: new Date(event.created * 1000),
metadata: { failureReason: event.data.object.failure_message },
};
case "charge.refunded":
return {
type: PaymentEventType.PAYMENT_REFUNDED,
transactionId: event.data.object.payment_intent,
orderId: event.data.object.metadata.orderId,
amount: event.data.object.amount_refunded / 100,
currency: event.data.object.currency,
providerId: "stripe",
timestamp: new Date(event.created * 1000),
};
default:
throw new Error(`Unsupported event type: ${event.type}`);
}
}
}
class RazorpayWebhookParser implements WebhookParser {
constructor(private webhookSecret: string) {}
async parseAndVerify(
rawBody: any,
signature: string
): Promise<NormalizedPaymentEvent> {
const crypto = require("crypto");
const expectedSignature = crypto
.createHmac("sha256", this.webhookSecret)
.update(rawBody)
.digest("hex");
if (expectedSignature !== signature) {
throw new Error("Invalid Razorpay webhook signature");
}
const event = JSON.parse(rawBody);
switch (event.event) {
case "payment.authorized":
return {
type: PaymentEventType.PAYMENT_COMPLETED,
transactionId: event.payload.payment.entity.id,
orderId: event.payload.payment.entity.notes.orderId,
amount: event.payload.payment.entity.amount / 100,
currency: event.payload.payment.entity.currency,
providerId: "razorpay",
timestamp: new Date(event.payload.payment.entity.created_at * 1000),
};
case "payment.failed":
return {
type: PaymentEventType.PAYMENT_FAILED,
transactionId: event.payload.payment.entity.id,
orderId: event.payload.payment.entity.notes.orderId,
amount: event.payload.payment.entity.amount / 100,
currency: event.payload.payment.entity.currency,
providerId: "razorpay",
timestamp: new Date(event.payload.payment.entity.created_at * 1000),
metadata: { failureReason: event.payload.payment.entity.error_description },
};
case "refund.processed":
return {
type: PaymentEventType.PAYMENT_REFUNDED,
transactionId: event.payload.refund.entity.payment_id,
orderId: event.payload.refund.entity.notes.orderId,
amount: event.payload.refund.entity.amount / 100,
currency: "INR",
providerId: "razorpay",
timestamp: new Date(event.payload.refund.entity.created_at * 1000),
};
default:
throw new Error(`Unsupported event type: ${event.event}`);
}
}
}
// Webhook endpoint (NestJS example)
@Post("/webhooks/payment")
async handlePaymentWebhook(
@Headers("x-signature") signature: string,
@Req() req: any
) {
const provider = req.headers["x-payment-provider"] || "stripe";
let parser: WebhookParser;
if (provider === "stripe") {
parser = new StripeWebhookParser(process.env.STRIPE_WEBHOOK_SECRET);
} else if (provider === "razorpay") {
parser = new RazorpayWebhookParser(process.env.RAZORPAY_WEBHOOK_SECRET);
}
const normalizedEvent = await parser.parseAndVerify(req.rawBody, signature);
// Now process using your own domain logic
await this.paymentService.handlePaymentEvent(normalizedEvent);
return { acknowledged: true };
}This approach means your order service, notification service, and accounting system all consume the same normalized event shape. Swap a provider, swap the parser—the rest of your system doesn't care.
Common Mistakes
- Leaking provider logic into business code. If you find yourself checking the gateway name or writing provider-specific code outside the adapter, you've missed an abstraction. Push it back into the adapter.
- Over-simplifying the interface. I've seen developers create a
PaymentGatewayinterface with onlycharge(). Real gateways needcapture,refund,verify, and oftenauthorizeas separate operations. Start rich; slim down if needed. - Not idempotency-proofing your integrations. Network failures mean you might call a gateway twice for the same order. Webhooks can fire twice. Store request IDs, use idempotency keys provided by the provider, and design your handlers to be safe when called multiple times.
- Trusting webhooks alone. Webhooks can be delayed or lost. Always implement polling as a backstop. Verify payment status independently before shipping goods or unlocking access.
- Forgetting PCI compliance. Even though you're using a payment gateway, data still flows through your system. Encrypt sensitive data, never log payment details, and regularly audit your infrastructure.
Key Takeaways
- The Adapter pattern shields your business logic from provider API differences. Each gateway implements a single, consistent interface.
- The Factory pattern makes gateway selection automatic based on currency, country, and merchant configuration. Remove hard-coded provider names from your codebase.
- The Strategy pattern lets you stack fallback and retry behavior without duplicating code. Compose strategies to handle failures gracefully.






Comments
Loading comments…