Adapter Pattern for Legacy System Integration

How to make incompatible APIs work together without rewriting existing code

The Integration Problem

You need to integrate a legacy payment processor, but:

  • The legacy API uses processPayment(amount, cardNumber)
  • Your modern code expects charge(PaymentDetails): PaymentResult
  • You can't modify the legacy code (no source, too risky, compliance locked)
  • You can't rewrite your modern code (hundreds of call sites)

What is the Adapter Pattern?

From the Gang of Four (1994):

"Converts the interface of one class into an interface expected by clients, allowing classes with incompatible interfaces to collaborate."

It's a structural pattern that acts as a translation layer.

Think: power plug adapter for international travel.

The Four Components

  • Client - your application code using the target interface
  • Target Interface - the interface your code expects
  • Adapter - translates between target and adaptee
  • Adaptee - the legacy component with incompatible interface

The adapter implements what the client wants while wrapping what already exists.

Before: Direct Legacy Coupling

// Legacy payment processor (can't modify this)
class LegacyPaymentProcessor {
  processPayment(amount: number, cardNumber: string): boolean {
    // Old implementation
    return true;
  }
}

// Your code - tightly coupled to legacy API
class CheckoutService {
  processOrder(amount: number, card: string) {
    const legacy = new LegacyPaymentProcessor();
    const success = legacy.processPayment(amount, card);
    // Can't test, can't swap, can't modernize
  }
}

Before: The Problem

Hardcoded dependency. Can't test. Can't migrate.

Step 1: Define Target Interface

// The modern interface your application wants
interface PaymentGateway {
  charge(paymentDetails: PaymentDetails): PaymentResult;
}

interface PaymentDetails {
  amount: number;
  currency: string;
  paymentMethod: PaymentMethod;
}

interface PaymentResult {
  success: boolean;
  transactionId: string;
  message: string;
}

Target Interface

This is what your code SHOULD work with.

Step 2: Build the Adapter

class LegacyPaymentAdapter implements PaymentGateway {
  constructor(private legacyProcessor: LegacyPaymentProcessor) {}

  charge(paymentDetails: PaymentDetails): PaymentResult {
    // Translate modern format to legacy format
    const amount = paymentDetails.amount;
    const cardNumber = paymentDetails.paymentMethod.token;
    
    // Delegate to legacy system
    const success = this.legacyProcessor.processPayment(amount, cardNumber);
    
    // Translate legacy response to modern format
    return {
      success,
      transactionId: `TXN-${Date.now()}`,
      message: success ? 'Payment processed' : 'Payment failed'
    };
  }
}

After: Decoupled via Adapter

class CheckoutService {
  constructor(private paymentGateway: PaymentGateway) {}

  processOrder(paymentDetails: PaymentDetails) {
    const result = this.paymentGateway.charge(paymentDetails);
    if (result.success) {
      console.log(`Order completed: ${result.transactionId}`);
    }
  }
}

// Production usage
const legacyProcessor = new LegacyPaymentProcessor();
const adapter = new LegacyPaymentAdapter(legacyProcessor);
const checkout = new CheckoutService(adapter);

After: The Benefits

CheckoutService has no idea the legacy system exists.

Adapters Create Testing Seams

As Michael Feathers describes in Working Effectively with Legacy Code:

Adapters are "seams" - places where you can alter behavior without editing in that place.

Testing with Adapters

// Production: real adapter
const prodService = new CheckoutService(
  new LegacyPaymentAdapter(new LegacyPaymentProcessor())
);

// Testing: mock adapter
class MockPaymentGateway implements PaymentGateway {
  charge(): PaymentResult {
    return { success: true, transactionId: 'TEST', message: 'ok' };
  }
}
const testService = new CheckoutService(new MockPaymentGateway());

Now you can test without touching the legacy system.

Real-World: Apify's Payment Migration

From Apify's 2025 blog post on migrating Braintree to Stripe:

Challenge: Migrate 10,000+ users from Braintree to Stripe without disruption

Apify's Adapter Solution

Solution: Implemented adapters for both payment processors

  • New customers routed to Stripe
  • Existing customers stayed on Braintree
  • Both worked through same PaymentProcessor interface
  • Gradual migration over 18 days

Outcome: Zero downtime, no failed payments, seamless transition

Supporting Multiple Providers

interface PaymentProcessor {
  processPayment(amount: number, currency: string): Promise<PaymentResult>;
}

class StripeAdapter implements PaymentProcessor {
  constructor(private stripe: StripeSDK) {}
  async processPayment(amount: number, currency: string) {
    // Stripe expects cents
    const charge = await this.stripe.createCharge(amount * 100, currency);
    return { success: charge.status === 'succeeded', transactionId: charge.id };
  }
}

Multiple Providers Continued

class PayPalAdapter implements PaymentProcessor {
  constructor(private paypal: PayPalSDK) {}
  async processPayment(amount: number, currency: string) {
    const payment = await this.paypal.execute_payment({ amount, currency });
    return { 
      success: payment.state === 'approved', 
      transactionId: payment.paymentID 
    };
  }
}

Same interface, different implementations. Swap anytime.

Object vs Class Adapters

Object Adapter (preferred):

class Adapter implements Target {
  constructor(private adaptee: Adaptee) {} // Composition
  method() { return this.adaptee.legacyMethod(); }
}

Class Adapter (less flexible):

class Adapter extends Adaptee implements Target { // Inheritance
  method() { return this.legacyMethod(); }
}

Use object adapters. More flexible, runtime composition, avoids inheriting unwanted methods.

Connection to Strangler Fig Pattern

Martin Fowler's Strangler Fig pattern for incremental modernization:

  • Build new system alongside legacy
  • Use adapters to make both systems work through common interfaces
  • Route traffic through gateway (new vs old decisions)
  • Gradually move functionality to new system
  • Remove legacy system when complete

Adapters are the bridge layer during transition.

Adapter vs Facade vs Decorator

Adapter

Changes incompatible interface → compatible interface

interface Target { charge(); }
class Adapter implements Target { 
  charge() { adaptee.processPayment(); } 
}

Facade vs Decorator

Facade

Simplifies complex interface → simpler interface

class Facade { 
  doEverything() { 
    system.step1(); system.step2(); system.step3(); 
  } 
}

Decorator

Adds behavior, keeps same interface

class Decorator implements Target { 
  charge() { log(); return target.charge(); } 
}

When to Use Adapters

✅ Use adapters when:

  • Integrating third-party APIs with incompatible interfaces
  • You can't modify legacy code (no source, too risky, compliance)
  • Migrating between service providers (need both during transition)
  • Creating testable seams in untestable legacy code
  • Implementing Strangler Fig modernization strategy

When NOT to Use Adapters

❌ Don't use adapters when:

  • You control both interfaces (just refactor to common interface)
  • Speculating about future needs (YAGNI principle)
  • A full rewrite would be simpler
  • Performance overhead is unacceptable (measure first)

Common Pitfalls to Avoid

Adapter Bloat

Too many adapters obscure system design

  • Keep adapters focused on interface translation only
  • Consolidate where it makes sense

God Adapter

Adapter does too much beyond translation

  • No business logic in adapters
  • One adapter per adaptee

More Pitfalls

Permanent Band-Aid

Adapters hide fundamental problems

  • Use adapters as temporary transition tools
  • Sometimes refactoring is better than adapting

Performance Overhead

Extra layer adds latency

  • Usually negligible, but measure in hot paths

Your Action Plan

This week:

  • Identify one integration point with incompatible interfaces
  • Define the target interface your code wants
  • Build an adapter to translate to the existing system

This month:

  • Add adapters to create testing seams in untestable code
  • Use adapters to support dual providers during migration

Remember

Adapters enable modernization without risky rewrites. They're transition tools, not permanent architecture.

Start small. Test thoroughly. Migrate incrementally.