Define the contract first
The whole point is that callers depend on the contract, not the concrete provider:
export interface PaymentProvider {
createCharge(input: ChargeInput): Promise<ChargeResult>;
refund(chargeId: string, amount?: number): Promise<RefundResult>;
webhookVerify(rawBody: string, signature: string): Promise<WebhookEvent>;
}
No Stripe types leak out. No provider-specific fields on the input. ChargeInput is ours; every provider adapts to it.
Implement each provider
export class StripeProvider implements PaymentProvider {
constructor(private client: Stripe) {}
async createCharge(input: ChargeInput): Promise<ChargeResult> {
const pi = await this.client.paymentIntents.create({
amount: input.amountMinor,
currency: input.currency,
customer: input.customerId,
});
return { id: pi.id, status: mapStatus(pi.status) };
}
// ... refund, webhookVerify
}
export class LocalProvider implements PaymentProvider {
// completely different API under the hood, same contract outward
}
Select at the edge, not deep in the code
The selection lives in one place — the composition root — not scattered through business logic:
function paymentProviderFor(region: Region): PaymentProvider {
if (region === 'BD') return new LocalProvider(localClient);
return new StripeProvider(stripeClient);
}
// in the request handler
const provider = paymentProviderFor(user.region);
const charge = await provider.createCharge({ ... });
Business code has no idea which provider ran. That is the point.
Where the pattern actually paid off
When we onboarded the local provider, the changes were:
- Write
LocalProvider(200 lines) - Add a region branch in
paymentProviderFor(3 lines) - Write contract tests that both providers must pass
The checkout flow, receipt emails, refund admin, reconciliation job — none of them changed. That is months of refactoring avoided.
Contract tests are the other half
An interface is just a promise. Contract tests enforce it:
describe.each([
['stripe', new StripeProvider(testClient)],
['local', new LocalProvider(testClient)],
])('PaymentProvider: %s', (name, provider) => {
it('creates a charge and returns a mapped status', async () => {
const result = await provider.createCharge(validInput);
expect(['pending', 'succeeded', 'failed']).toContain(result.status);
});
});
Run the same suite against every provider. Any divergence between implementations shows up immediately.
The shape to copy
Anywhere you have a pluggable external system — payments, email, SMS, object storage, analytics — this pattern scales. Define the contract, implement per vendor, select at the edge, contract-test everyone. The alternative is if (vendor === 'stripe') scattered across your code, and that is the monolith you eventually have to extract.
