Demystifying the Adapter Pattern
Erick Engelhardt

Erick Engelhardt @erickengelhardt

About: Software architect & engineer with 20+ years in full-stack development, DevOps, and leadership. Passionate about scalable solutions, clean code, and enhancing developer experience.

Joined:
Mar 30, 2024

Demystifying the Adapter Pattern

Publish Date: May 30
2 1

Ever tried plugging a USB-C into a micro-USB port? That’s exactly what the Adapter Pattern is here for. Not literally, but conceptually.

The Adapter Pattern is all about making incompatible interfaces work together. It acts like a bridge between two systems that weren’t designed to cooperate. You wrap one object with another that translates method calls and properties into a compatible format.


When Should You Use It?

  • You have legacy code that doesn't match your current interface.
  • You want to integrate third-party libraries with your own abstraction.
  • You're refactoring a codebase gradually and need backward compatibility.
  • You're dealing with multiple interfaces that perform similar but not identical functions.

This is especially useful in enterprise-scale projects where changing one system has ripple effects across many modules.


Real-World Example: Payments

Imagine you're switching payment providers. Your app was built using an older LegacyPayment class, but you want to move toward a new unified interface.

Step 1 - Legacy system and desired abstraction

export class LegacyPaymentSdk {
  sendPayment(amount: number) {
    console.log(`Paid $${amount} via LegacyPayment`);
  }
}

export class StripeSdk {
  charge(value: number, metadata: Record<string, any>) {
    console.log(`Charged $${value} via Stripe with metadata:`, metadata);
  }
}


export interface PaymentPayload {
  amount: number;
  payerName: string;
  payerEmail: string;
}

export interface PaymentProvider {
  pay(data: PaymentPayload): PaymentResult;
}

export interface PaymentResult {
  transactionId: string;
  success: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Step 2 - Adapters for each implementation

export class LegacyPaymentAdapter implements PaymentProvider {
  constructor(private legacy: LegacyPaymentSdk) {}

  pay(data: PaymentPayload): PaymentResult {
    console.log('Adapter translating pay() to sendPayment()');
    // Only amount is used; legacy gateway does not support metadata
    this.legacy.sendPayment(data.amount);
    return { transactionId: 'legacy-123', success: true }; // Simulated response
  }
}

export class StripeAdapter implements PaymentProvider {
  constructor(private stripe: StripeSdk) {}

  pay(data: PaymentPayload): PaymentResult {
    console.log('Adapter translating pay() to charge()');
    const metadata = {
      name: data.payerName,
      email: data.payerEmail
    };
    this.stripe.charge(data.amount, metadata);
    return { transactionId: 'stripe-456', success: true }; // Simulated response
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3 - Service layer using the interface

export class PaymentService {
  constructor(private provider: PaymentProvider) {}

  handleCheckout(data: PaymentPayload): PaymentResult {
    console.log('Service initiating payment...');
    const result = this.provider.pay(data);
    console.log(`Service completed payment. Transaction ID: ${result.transactionId}, Success: ${result.success}`);
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4 - Usage with different adapters

const legacyAdapter = new LegacyPaymentAdapter(new LegacyPaymentSdk());
const stripeAdapter = new StripeAdapter(new StripeSdk());

const legacyService = new PaymentService(legacyAdapter);
const stripeService = new PaymentService(stripeAdapter);

legacyService.handleCheckout({
  amount: 100,
  payerName: 'Alice',
  payerEmail: 'alice@example.com'
});

stripeService.handleCheckout({
  amount: 200,
  payerName: 'Bob',
  payerEmail: 'bob@example.com'
});
Enter fullscreen mode Exit fullscreen mode

Run the Example

yarn start
Enter fullscreen mode Exit fullscreen mode

Full Output

1) Service initiating payment...
2) Adapter translating pay() to sendPayment()
3) Paid $100 via LegacyPayment
4) Service completed payment.

5) Service initiating payment...
6) Adapter translating pay() to charge()
7) Charged $200 via Stripe with metadata: { name: 'Bob', email: 'bob@example.com' }
8) Service completed payment.
Enter fullscreen mode Exit fullscreen mode

Output Breakdown

LegacyPayment flow:

  • 1) The PaymentService starts the legacy checkout process.
  • 2) The adapter translates the call to match the legacy interface.
  • 3) Payment is processed using the legacy gateway.
  • 4) The service completes the first transaction.

Stripe flow:

  • 5) Begins the second checkout using the Stripe adapter.
  • 6) Adapter maps the unified method to Stripe’s method.
  • 7) Payment succeeds, including user metadata.
  • 8) Finalizes the second transaction.

Testing the Adapter Integration

The repository includes unit tests to ensure your service logic remains decoupled from any specific SDK or gateway.

The file payment.service.spec.ts uses a mocked version of the PaymentProvider interface to test the PaymentService independently.

This allows you to:

  • Fully isolate service behavior from real implementations
  • Guarantee contract compliance
  • Swap adapters without affecting test coverage

Run the Tests

yarn test
Enter fullscreen mode Exit fullscreen mode

Test File: payment.service.spec.ts

This testing approach ensures your PaymentService can be reused anywhere, as long as a compatible adapter is injected.


This structure lets you plug and play with multiple providers without changing your business logic. Each adapter can include gateway-specific logic, such as formatting, validation, metadata conversion, retries, and so on.

If tomorrow you adopt another provider (like PayPal or Adyen), all you need is another adapter.


Benefits of the Adapter Pattern

  • Decoupling: Your core app logic is isolated from external or legacy systems.
  • Gradual migration: Swap internals behind the scenes without breaking public interfaces.
  • Consistency: Enforce a common API across different providers or modules.
  • Testability: Mock adapters in tests instead of actual implementations.
  • Extendability: Add logic to adapt not just method names but also data formatting, validation, or retries.

Common Pitfalls

  • Overuse: Don't use adapters as an excuse to skip proper refactoring.
  • Performance hit: Wrapping layers can introduce slight overhead.
  • Too much abstraction: Over-adapting might confuse new developers.

Use it where there's a real gap in compatibility, not as a blanket solution for all mismatches.


Pro Tips

  • Pair with Dependency Injection for even better separation of concerns.
  • Combine with Factory Pattern to automate the adapter selection logic.
  • Favor interfaces over concrete classes to increase flexibility.
  • Avoid adapting things that should just be rewritten. Sometimes adapters hide tech debt instead of solving it.

Adapters aren't glamorous, but they solve real-world messes. And let's be honest, clean architecture often comes down to choosing the right boring solution at the right time.

Check out a full working repo: https://github.com/erickne/ts-adapter-pattern

Comments 1 total

Add comment