mdashikjs/blog
All posts
Strategy Pattern in the Wild: Swapping Payment Providers Without Touching Callers
Backend

Strategy Pattern in the Wild: Swapping Payment Providers Without Touching Callers

Backend4 min

Strategy Pattern in the Wild: Swapping Payment Providers Without Touching Callers

We started with Stripe. Then legal needed a regional provider for one market. Without the strategy pattern this would have been a three-week rewrite. With it, it was a 200-line file and a switch in config.

Design PatternsArchitecturePayments
Share:

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:

  1. Write LocalProvider (200 lines)
  2. Add a region branch in paymentProviderFor (3 lines)
  3. 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.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.