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.

Adapter Pattern for Legacy System Integration