Introduction
You're shipping features that depend on a payment processor, SMS gateway, or analytics platform. Six months later, you need to switch vendors—maybe for cost, reliability, or feature reasons. If your business logic is tightly coupled to the vendor's SDK, that switch becomes a nightmare: you're refactoring imports across your codebase, rewriting error handling, and risking bugs in core flows. The solution is simple but often overlooked: wrap every vendor SDK: a practical guide for developers teaches you to build an abstraction layer between your application and external services. This approach isolates vendor-specific concerns, keeps your domain logic clean, and makes switching or mocking vendors straightforward.
Define Your Own Interface
Start by forgetting the vendor's API exists. Instead, ask: what does my application need from this service? Write an interface (or abstract class) that describes your requirements in terms of your domain, not theirs.
Let's say you're integrating Stripe for payments. Stripe's SDK has dozens of methods, enums, and response types. Your business doesn't care about Stripe's internal models. Your business cares about collecting a payment, checking if it succeeded, and retrieving transaction history.
// interfaces/payment-processor.ts
export interface PaymentProcessor {
charge(input: ChargeInput): Promise;
refund(transactionId: string, amount?: number): Promise;
getTransaction(transactionId: string): Promise;
}
export interface ChargeInput {
amountCents: number;
currency: string;
customerId: string;
description: string;
}
export interface ChargeResult {
transactionId: string;
status: 'success' | 'declined' | 'processing';
amountCents: number;
}
export interface RefundResult {
refundId: string;
status: 'initiated' | 'completed' | 'failed';
amountCents: number;
}
export interface TransactionDetails {
transactionId: string;
amountCents: number;
currency: string;
status: 'succeeded' | 'pending' | 'failed' | 'refunded';
createdAt: Date;
}Notice what's not here: no Stripe-specific event types, no webhook metadata structures, no enumeration of decline reasons that Stripe defines. You're naming fields and statuses that match your domain language. If you later switch to Square or Adyen, the fields stay the same—only the implementation changes.
This is the core discipline: your interface must reflect your business rules, not the vendor's feature set.
Implement It With the Vendor SDK
Now create a concrete implementation that wraps the vendor's SDK. This is where you translate between two worlds: the vendor's API and your interface.
// adapters/stripe-payment-processor.ts
import Stripe from 'stripe';
import {
PaymentProcessor,
ChargeInput,
ChargeResult,
RefundResult,
TransactionDetails,
} from '../interfaces/payment-processor';
import { PaymentError } from '../errors/payment-error';
export class StripePaymentProcessor implements PaymentProcessor {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' });
}
async charge(input: ChargeInput): Promise {
try {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: input.amountCents,
currency: input.currency,
customer: input.customerId,
description: input.description,
confirm: true,
});
return {
transactionId: paymentIntent.id,
status: this.mapStripeStatus(paymentIntent.status),
amountCents: paymentIntent.amount,
};
} catch (error) {
throw this.mapStripeError(error);
}
}
async refund(transactionId: string, amount?: number): Promise {
try {
const refund = await this.stripe.refunds.create({
payment_intent: transactionId,
amount: amount, // undefined means full refund
});
return {
refundId: refund.id,
status: refund.status === 'succeeded' ? 'completed' : 'initiated',
amountCents: refund.amount,
};
} catch (error) {
throw this.mapStripeError(error);
}
}
async getTransaction(transactionId: string): Promise {
try {
const paymentIntent = await this.stripe.paymentIntents.retrieve(
transactionId,
);
return {
transactionId: paymentIntent.id,
amountCents: paymentIntent.amount,
currency: paymentIntent.currency,
status: this.mapStripeStatus(paymentIntent.status),
createdAt: new Date(paymentIntent.created * 1000),
};
} catch (error) {
throw this.mapStripeError(error);
}
}
private mapStripeStatus(
status: string,
): 'succeeded' | 'pending' | 'failed' | 'refunded' {
const statusMap: Record = {
succeeded: 'succeeded',
processing: 'pending',
requires_action: 'pending',
requires_capture: 'pending',
requires_confirmation: 'pending',
requires_payment_method: 'failed',
canceled: 'failed',
};
return statusMap[status] || 'pending';
}
private mapStripeError(error: any): Error {
// See next section: error mapping
if (error.type === 'StripeCardError') {
return new PaymentError('card_declined', error.message);
}
if (error.type === 'StripeInvalidRequestError') {
return new PaymentError('invalid_request', error.message);
}
return new PaymentError('processor_error', 'An unexpected error occurred');
}
}
The implementation is straightforward: each method translates your domain model to the vendor's API and back. The vendor SDK stays confined to this file. Your business logic never imports Stripe or sees a PaymentIntent.
Swap or Mock Vendors Easily
Because you're programming to an interface, switching vendors requires only a new implementation—no changes to business logic.
// adapters/square-payment-processor.ts
import { Client, Environment } from 'square';
import {
PaymentProcessor,
ChargeInput,
ChargeResult,
RefundResult,
TransactionDetails,
} from '../interfaces/payment-processor';
import { PaymentError } from '../errors/payment-error';
export class SquarePaymentProcessor implements PaymentProcessor {
private client: Client;
constructor(accessToken: string) {
this.client = new Client({
accessToken,
environment: Environment.Production,
});
}
async charge(input: ChargeInput): Promise {
try {
const { result } = await this.client.paymentsApi.createPayment({
sourceId: input.customerId, // Square's model differs
amountMoney: {
amount: input.amountCents,
currency: input.currency.toUpperCase(),
},
note: input.description,
});
return {
transactionId: result.payment.id,
status: result.payment.status === 'COMPLETED' ? 'success' : 'processing',
amountCents: result.payment.amountMoney.amount,
};
} catch (error) {
throw this.mapSquareError(error);
}
}
// ... other methods omitted for brevity
}
Now your controller or service that collects payments looks like this:
// services/order-service.ts
export class OrderService {
constructor(private paymentProcessor: PaymentProcessor) {}
async createOrder(orderId: string, amountCents: number): Promise {
const result = await this.paymentProcessor.charge({
amountCents,
currency: 'usd',
customerId: 'cust_123',
description: `Order #${orderId}`,
});
if (result.status === 'success') {
await this.recordPayment(orderId, result.transactionId);
}
}
}
To swap Stripe for Square, you change one dependency injection binding. That's it. Your order service, payment validations, and audit logging remain untouched.
For testing, inject a mock implementation:
// __tests__/order-service.test.ts
class MockPaymentProcessor implements PaymentProcessor {
async charge(input: ChargeInput): Promise {
return {
transactionId: 'mock_txn_123',
status: 'success',
amountCents: input.amountCents,
};
}
async refund(): Promise {
return { refundId: 'mock_ref_123', status: 'completed', amountCents: 0 };
}
async getTransaction(): Promise {
return {
transactionId: 'mock_txn_123',
amountCents: 5000,
currency: 'usd',
status: 'succeeded',
createdAt: new Date(),
};
}
}
describe('OrderService', () => {
it('records successful payments', async () => {
const processor = new MockPaymentProcessor();
const service = new OrderService(processor);
await service.createOrder('order_456', 5000);
// assertions...
});
});
No HTTP mocks, no SDK stubs, no brittle fixtures. Your mock implements the same contract.
Map Vendor Errors to Your Error Types
Vendors throw different errors for different scenarios. Stripe raises StripeCardError for declined cards. Square raises ApiError with a reason code. Your business logic shouldn't know about these differences.
Define your own error hierarchy:
// errors/payment-error.ts
export class PaymentError extends Error {
constructor(
public readonly code:
| 'card_declined'
| 'insufficient_funds'
| 'invalid_request'
| 'processor_error'
| 'network_error',
message: string,
public readonly retryable: boolean = false,
) {
super(message);
this.name = 'PaymentError';
}
}
In your adapter, catch vendor errors and translate them:
// adapters/stripe-payment-processor.ts (error mapping)
private mapStripeError(error: any): PaymentError {
if (error.type === 'StripeCardError') {
const code = error.decline_code;
if (code === 'insufficient_funds') {
return new PaymentError('insufficient_funds', error.message, true);
}
if (code === 'lost_card' || code === 'stolen_card') {
return new PaymentError('card_declined', 'Card was lost or stolen', false);
}
return new PaymentError('card_declined', error.message, false);
}
if (error.type === 'StripeRateLimitError') {
return new PaymentError(
'processor_error',
'Payment processor is busy',
true,
);
}
if (error.type === 'StripeAuthenticationError') {
return new PaymentError(
'processor_error',
'Authentication with payment processor failed',
false,
);
}
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
return new PaymentError('network_error', error.message, true);
}
return new PaymentError('processor_error', 'An unexpected error occurred', false);
}
Your service layer handles your error types, not the vendor's:
// services/order-service.ts (error handling)
async createOrder(orderId: string, amountCents: number): Promise {
try {
const result = await this.paymentProcessor.charge({
amountCents,
currency: 'usd',
customerId: 'cust_123',
description: `Order #${orderId}`,
});
if (result.status === 'success') {
await this.recordPayment(orderId, result.transactionId);
}
} catch (error) {
if (error instanceof PaymentError) {
if (error.code === 'card_declined') {
throw new OrderError('payment_declined', 'Your card was declined');
}
if (error.retryable) {
// Schedule retry
await this.scheduleRetry(orderId, amountCents);
}
}
throw error;
}
}
This approach also makes observability cleaner. You log and monitor your error codes, not vendor-specific ones. When you switch vendors, your error handling remains consistent.
Common Mistakes
- Leaking vendor types into your domain: Avoid importing vendor SDK types in your business logic. If you find yourself using
Stripe.PaymentIntentorstripe.Invoiceoutside the adapter, your abstraction is leaking. - Creating interfaces that are too thin: An interface with a single method that mirrors the vendor's API isn't helpful. Your interface should group related operations that make sense for your business (like
charge,refund, andgetTransactiontogether). - Ignoring webhook handling: Vendors often send webhooks for async events (payment confirmed, refund completed, subscription renewed). Don't ignore this in your wrapper. Create a separate interface for webhook handlers that normalizes vendor-specific event structures.
- Forgetting to handle rate limits and retries: Vendor SDKs often have built-in retry logic. When you wrap them, decide if you want to expose retry behavior through your interface or handle it silently within the adapter.
- Over-engineering for hypothetical switches: Wrapping every vendor is good practice, but don't design for five possible implementations upfront. Build for your current vendor and one plausible alternative.
Key Takeaways
- Define your own interface in terms of your business domain, not the vendor's API.
- Implement the interface using the vendor's SDK in a dedicated adapter class.
- Inject the interface into your business logic, never the vendor SDK directly.
- Map vendor-specific error types to your own error hierarchy.
- Swapping vendors or mocking them for tests becomes a one-line change to dependency configuration.
- Keep vendor concerns isolated; this isolates risk and makes refactoring safer.






Comments
Loading comments…