The problem: scattered payment logic
You've built a SaaS platform that processes payments. Your domain logic lives in clean, testable classes. Then you integrate Stripe. Suddenly, your core business logic is importing stripe-node, calling paymentIntents.create(), and handling Stripe-specific error codes. Six months later, a customer asks if you can support PayPal. Now your codebase is tangled with two vendor SDKs. A year later, you're stuck—migrating payment providers means rewriting half your application.
The Adapter pattern: Design Patterns lessons from production solves this by creating a single interface that multiple payment providers fit into. Your domain never knows which provider it's using. When you swap providers, only the adapter changes.
Wrap each payment provider behind one interface
The first step is defining what a payment provider must do for your system. Create an interface that represents the operations you actually need, not the operations the vendor offers.
interface PaymentGateway {
charge(request: ChargeRequest): Promise<ChargeResult>;
refund(transactionId: string, amount: Money): Promise<RefundResult>;
retrieveTransaction(id: string): Promise<Transaction>;
}
interface ChargeRequest {
customerId: string;
amount: Money;
description: string;
idempotencyKey: string;
}
interface ChargeResult {
transactionId: string;
status: 'pending' | 'succeeded' | 'failed';
amountCharged: Money;
processedAt: Date;
}
interface Money {
amount: number;
currency: string;
}
interface RefundResult {
refundId: string;
status: 'pending' | 'completed' | 'failed';
}
interface Transaction {
id: string;
customerId: string;
amount: Money;
status: 'succeeded' | 'failed' | 'pending';
createdAt: Date;
}
Notice what's missing: no Stripe types, no PayPal types, no vendor-specific fields. This interface speaks your domain's language. Now build an adapter for Stripe.
import Stripe from 'stripe';
class StripeAdapter implements PaymentGateway {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' });
}
async charge(request: ChargeRequest): Promise<ChargeResult> {
try {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: request.amount.amount,
currency: request.amount.currency.toLowerCase(),
customer: request.customerId,
description: request.description,
idempotency_key: request.idempotencyKey,
});
return {
transactionId: paymentIntent.id,
status: this.mapStripeStatus(paymentIntent.status),
amountCharged: request.amount,
processedAt: new Date(paymentIntent.created * 1000),
};
} catch (error) {
throw this.mapStripeError(error);
}
}
async refund(transactionId: string, amount: Money): Promise<RefundResult> {
try {
const refund = await this.stripe.refunds.create({
payment_intent: transactionId,
amount: amount.amount,
});
return {
refundId: refund.id,
status: this.mapStripeRefundStatus(refund.status),
};
} catch (error) {
throw this.mapStripeError(error);
}
}
async retrieveTransaction(id: string): Promise<Transaction> {
const paymentIntent = await this.stripe.paymentIntents.retrieve(id);
return {
id: paymentIntent.id,
customerId: paymentIntent.customer as string,
amount: {
amount: paymentIntent.amount,
currency: paymentIntent.currency,
},
status: this.mapStripeStatus(paymentIntent.status),
createdAt: new Date(paymentIntent.created * 1000),
};
}
private mapStripeStatus(status: string): 'succeeded' | 'failed' | 'pending' {
switch (status) {
case 'succeeded':
return 'succeeded';
case 'processing':
return 'pending';
case 'requires_payment_method':
case 'requires_confirmation':
return 'pending';
default:
return 'failed';
}
}
private mapStripeRefundStatus(status: string): 'pending' | 'completed' | 'failed' {
if (status === 'succeeded') return 'completed';
if (status === 'failed') return 'failed';
return 'pending';
}
private mapStripeError(error: any): Error {
if (error instanceof Stripe.errors.CardError) {
return new PaymentDeclinedError(error.message);
}
if (error instanceof Stripe.errors.RateLimitError) {
return new PaymentProviderUnavailableError('Rate limited');
}
if (error instanceof Stripe.errors.AuthenticationError) {
return new PaymentConfigurationError('Invalid API key');
}
return new PaymentProviderError(error.message);
}
}
The adapter owns all the translation logic. Stripe's field names, statuses, and error codes stay inside this class. Your domain never sees them.
Your domain never imports vendor SDKs
This is the critical benefit. Your business logic only depends on the PaymentGateway interface. It has no idea which provider is underneath.
class OrderService {
constructor(private paymentGateway: PaymentGateway) {}
async processOrder(order: Order, customerId: string): Promise<void> {
const chargeRequest: ChargeRequest = {
customerId,
amount: order.totalPrice,
description: `Order ${order.id}`,
idempotencyKey: `order-${order.id}`,
};
const result = await this.paymentGateway.charge(chargeRequest);
if (result.status === 'succeeded') {
order.markAsPaid(result.transactionId);
} else if (result.status === 'pending') {
order.markAsPending(result.transactionId);
} else {
throw new PaymentFailedError('Charge failed');
}
}
}
OrderService doesn't import Stripe. It doesn't know what a paymentIntent is. It works with your domain concepts: orders, amounts, success or failure. When you test this class, you inject a mock adapter. When you deploy, you inject the real one.
Swap providers with a new adapter
Suppose you decide to add PayPal. You implement the same interface.
import paypalCheckoutServerSDK from '@paypal/checkout-server-sdk';
class PayPalAdapter implements PaymentGateway {
private client: paypalCheckoutServerSDK.core.PayPalHttpClient;
constructor(clientId: string, clientSecret: string) {
const environment = new paypalCheckoutServerSDK.core.SandboxEnvironment(
clientId,
clientSecret
);
this.client = new paypalCheckoutServerSDK.core.PayPalHttpClient(environment);
}
async charge(request: ChargeRequest): Promise<ChargeResult> {
try {
const order = {
intent: 'CAPTURE',
purchase_units: [
{
amount: {
currency_code: request.amount.currency,
value: (request.amount.amount / 100).toString(),
},
description: request.description,
},
],
};
const createRequest = new paypalCheckoutServerSDK.orders.OrdersCreateRequest();
createRequest.headers['prefer'] = 'return=representation';
createRequest.body = order;
const response = await this.client.execute(createRequest);
return {
transactionId: response.result.id,
status: this.mapPayPalStatus(response.result.status),
amountCharged: request.amount,
processedAt: new Date(),
};
} catch (error) {
throw this.mapPayPalError(error);
}
}
async refund(transactionId: string, amount: Money): Promise<RefundResult> {
const captureId = await this.getCaptureIdFromOrder(transactionId);
const refundRequest = new paypalCheckoutServerSDK.orders.OrdersRefundRequest(
transactionId
);
refundRequest.body = {
amount: {
currency_code: amount.currency,
value: (amount.amount / 100).toString(),
},
};
const response = await this.client.execute(refundRequest);
return {
refundId: response.result.id,
status: 'completed',
};
}
async retrieveTransaction(id: string): Promise<Transaction> {
const detailsRequest = new paypalCheckoutServerSDK.orders.OrdersGetRequest(id);
const response = await this.client.execute(detailsRequest);
return {
id: response.result.id,
customerId: response.result.payer.email_address,
amount: {
amount: Math.round(
parseFloat(response.result.purchase_units[0].amount.value) * 100
),
currency: response.result.purchase_units[0].amount.currency_code,
},
status: this.mapPayPalStatus(response.result.status),
createdAt: new Date(response.result.create_time),
};
}
private mapPayPalStatus(status: string): 'succeeded' | 'failed' | 'pending' {
switch (status) {
case 'COMPLETED':
return 'succeeded';
case 'APPROVED':
return 'pending';
case 'FAILED':
case 'VOIDED':
return 'failed';
default:
return 'pending';
}
}
private mapPayPalError(error: any): Error {
if (error.message?.includes('INSTRUMENT_DECLINED')) {
return new PaymentDeclinedError('Card declined');
}
return new PaymentProviderError(error.message);
}
private async getCaptureIdFromOrder(orderId: string): Promise<string> {
const detailsRequest = new paypalCheckoutServerSDK.orders.OrdersGetRequest(
orderId
);
const response = await this.client.execute(detailsRequest);
return response.result.purchase_units[0].payments.captures[0].id;
}
}
Now your system supports both Stripe and PayPal. Your domain code doesn't change. You wire different adapters depending on configuration.
const environment = process.env.PAYMENT_PROVIDER || 'stripe';
let paymentGateway: PaymentGateway;
if (environment === 'stripe') {
paymentGateway = new StripeAdapter(process.env.STRIPE_API_KEY!);
} else if (environment === 'paypal') {
paymentGateway = new PayPalAdapter(
process.env.PAYPAL_CLIENT_ID!,
process.env.PAYPAL_CLIENT_SECRET!
);
} else {
throw new Error(`Unknown payment provider: ${environment}`);
}
const orderService = new OrderService(paymentGateway);
Map vendor errors to your errors
Every vendor has different error codes, different exceptions, different retry logic. The adapter translates all of it into your error hierarchy.
// Domain errors - your application owns these
class PaymentError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
class PaymentDeclinedError extends PaymentError {}
class PaymentConfigurationError extends PaymentError {}
class PaymentProviderUnavailableError extends PaymentError {}
class PaymentProviderError extends PaymentError {}
// The adapter translates vendor errors to domain errors
class StripeAdapter implements PaymentGateway {
private mapStripeError(error: any): PaymentError {
if (error instanceof Stripe.errors.CardError) {
// Card declined, expired, invalid CVC, etc.
return new PaymentDeclinedError(
`Card declined: ${error.decline_code || error.message}`
);
}
if (error instanceof Stripe.errors.RateLimitError) {
// We're hitting Stripe's rate limits
return new PaymentProviderUnavailableError(
'Payment processor is temporarily unavailable'
);
}
if (error instanceof Stripe.errors.InvalidRequestError) {
// Bad parameters sent to Stripe
return new PaymentConfigurationError(
'Invalid payment configuration'
);
}
if (error instanceof Stripe.errors.AuthenticationError) {
// Invalid API key
return new PaymentConfigurationError('Invalid API credentials');
}
// Unknown Stripe error
return new PaymentProviderError(
`Payment processor error: ${error.message}`
);
}
}
Your caller handles domain errors, not vendor errors. Retry logic, alerting, and user messaging stay consistent regardless of which provider is active.
async function handleCheckout(order: Order, customerId: string) {
try {
await orderService.processOrder(order, customerId);
} catch (error) {
if (error instanceof PaymentDeclinedError) {
return { status: 400, message: 'Card was declined. Try a different card.' };
}
if (error instanceof PaymentProviderUnavailableError) {
// Retry later or show "try again in a moment"
return { status: 503, message: 'Payment processor is temporarily unavailable.' };
}
if (error instanceof PaymentConfigurationError) {
// Log this for ops team - API keys are broken
logger.error('Payment configuration error', error);
return { status: 500, message: 'Internal payment processing error.' };
}
throw error;
}
}
Common mistakes
- Leaking vendor concepts into the interface. If your
PaymentGatewayinterface has a method calledcreatePaymentIntent()or parameters namedcustomer_id, you've lost the abstraction. Keep vendor concepts inside the adapter. - Making the adapter too thin. The adapter should handle all the translation: field mapping, type conversion, error mapping, pagination, retries. If your domain code has try-catch blocks specifically for vendor errors, that logic belongs in the adapter.
- Ignoring idempotency. Payment APIs must be idempotent. Your
ChargeRequestshould include anidempotencyKey, and each adapter must use it. Stripe hasidempotency_key; PayPal usesrequest_idor custom headers. The adapter handles this translation. - Forgetting to test both adapters.strong> Write integration tests that run the same test suite against StripeAdapter and PayPalAdapter. Use a mock adapter for unit tests, but verify each real adapter with actual API calls (or sandbox) before production.
- Coupling your domain to one provider's webhook format. Webhooks from Stripe look nothing like webhooks from PayPal. Handle webhook parsing in the adapter layer, or create a separate adapter class just for webhooks.
Key takeaways
- Define a
PaymentGatewayinterface that speaks your domain language, not vendor language. - Each payment provider gets one adapter class that implements your interface and translates all vendor-specific details.
- Your domain logic depends only on the interface, never on vendor SDKs.
- Swapping providers means implementing a new adapter and changing configuration—no domain code changes.
- Map all vendor errors to your error hierarchy inside the adapter so callers don't need to know about provider-specific exceptions.
- The adapter should be thick enough to handle all translation work: field mapping, type conversion, error mapping, and idempotency concerns.
- Test each adapter independently to ensure it correctly translates between your interface and the vendor's API.






Comments
Loading comments…