Episode 9 — System Design / 9.4 — Structural Design Patterns

9.4.a -- Adapter Pattern

In one sentence: The Adapter pattern lets two classes with incompatible interfaces work together by wrapping one of them in a translation layer -- like a power plug adapter that lets your US laptop charge in a European outlet.

Navigation: <- 9.4 Overview | 9.4.b -- Facade ->


Table of Contents


1. What is the Adapter Pattern?

The Adapter (also called Wrapper) is a structural design pattern that allows objects with incompatible interfaces to collaborate. It works by creating a middle-layer object that translates calls from one interface into calls the other object understands.

  WITHOUT ADAPTER:
  ┌────────────┐        ┌──────────────┐
  │   Client   │───X───>│  Third-Party │   Incompatible!
  │            │        │  Service     │   Client expects processPayment()
  │ expects:   │        │  has:        │   Service has makeCharge()
  │ process    │        │ makeCharge() │
  │ Payment()  │        │              │
  └────────────┘        └──────────────┘

  WITH ADAPTER:
  ┌────────────┐     ┌─────────────┐     ┌──────────────┐
  │   Client   │────>│   Adapter   │────>│  Third-Party │
  │            │     │             │     │  Service     │
  │ calls:     │     │ translates  │     │              │
  │ process    │     │ process     │     │ makeCharge() │
  │ Payment()  │     │ Payment()   │     │              │
  │            │     │ into        │     │              │
  │            │     │ makeCharge()│     │              │
  └────────────┘     └─────────────┘     └──────────────┘
RoleWhat it does
TargetThe interface the client expects
AdapteeThe existing class with an incompatible interface
AdapterTranslates calls from Target interface to Adaptee interface
ClientWorks with objects through the Target interface

2. The Problem It Solves

You encounter the Adapter problem constantly in real software:

  1. Third-party API changes -- you upgrade a library and its API is completely different
  2. Multiple vendors -- Stripe, PayPal, and Square each have different payment APIs, but your checkout needs one consistent interface
  3. Legacy systems -- an old SOAP service needs to work with your modern REST-based application
  4. Testing -- you need to swap a real service with a mock that has a different interface

Without the Adapter pattern, you face these bad alternatives:

  BAD OPTION 1: Modify the third-party code
  - You don't own it
  - Updates will overwrite your changes

  BAD OPTION 2: Modify your client code
  - Couples your code to a specific vendor
  - Changing vendors means rewriting everything

  BAD OPTION 3: Use conditionals everywhere
  if (provider === 'stripe') { stripe.charges.create() }
  else if (provider === 'paypal') { paypal.payment.execute() }
  else if (provider === 'square') { square.payments.create() }
  - Violates Open/Closed Principle
  - Every new provider means editing existing code

3. Structure and Participants

  ┌─────────────────────────────────┐
  │          <<interface>>          │
  │         PaymentProcessor        │
  │─────────────────────────────────│
  │ + processPayment(amount, token) │
  │ + refund(transactionId)         │
  │ + getStatus(transactionId)      │
  └──────────────┬──────────────────┘
                 │ implements
       ┌─────────┴─────────┐
       │                   │
  ┌────▼──────────┐  ┌─────▼─────────────┐
  │ StripeAdapter │  │ PayPalAdapter     │
  │───────────────│  │───────────────────│
  │ - stripe      │  │ - paypal          │
  │               │  │                   │
  │ + process     │  │ + process         │
  │   Payment()   │  │   Payment()       │
  │ + refund()    │  │ + refund()        │
  │ + getStatus() │  │ + getStatus()     │
  └──────┬────────┘  └──────┬────────────┘
         │ delegates         │ delegates
  ┌──────▼────────┐  ┌──────▼────────────┐
  │  Stripe SDK   │  │  PayPal SDK       │
  │───────────────│  │───────────────────│
  │ charges.      │  │ payment.execute() │
  │  create()     │  │ payment.get()     │
  │ refunds.      │  │ sale.refund()     │
  │  create()     │  │                   │
  └───────────────┘  └───────────────────┘

4. Class Adapter vs Object Adapter

There are two flavors of the Adapter pattern:

Object Adapter (Composition -- preferred in JavaScript)

The adapter holds a reference to the adaptee and delegates calls:

// Object Adapter -- uses COMPOSITION
class StripeAdapter {
  constructor() {
    this.stripe = new StripeSDK('sk_test_key'); // holds reference
  }

  processPayment(amount, token) {
    // Delegates to stripe's different API
    return this.stripe.charges.create({
      amount: amount * 100, // Stripe uses cents
      currency: 'usd',
      source: token,
    });
  }
}

Class Adapter (Inheritance -- limited in JS)

The adapter extends the adaptee class (requires multiple inheritance, so rare in JavaScript):

// Class Adapter -- uses INHERITANCE (less common in JS)
class StripeAdapter extends StripeSDK {
  processPayment(amount, token) {
    // Calls inherited method from StripeSDK
    return this.charges.create({
      amount: amount * 100,
      currency: 'usd',
      source: token,
    });
  }
}

Comparison

AspectObject AdapterClass Adapter
MechanismComposition (has-a)Inheritance (is-a)
FlexibilityCan adapt any subclass of AdapteeOnly adapts the specific class
OverrideCannot override Adaptee behavior easilyCan override Adaptee methods
JavaScript fitExcellent (no multiple inheritance needed)Workable but less flexible
RecommendationPreferred in most casesUse only when you need to override Adaptee internals

5. Payment Gateway Adapter -- Full Example

This is the canonical real-world example. Your e-commerce platform needs to support multiple payment gateways.

// ============================================================
// PAYMENT GATEWAY ADAPTER PATTERN -- FULL IMPLEMENTATION
// ============================================================

// ---- TARGET INTERFACE (what our application expects) ----

class PaymentProcessor {
  /**
   * @param {number} amount - Amount in dollars
   * @param {string} token - Payment token
   * @returns {Promise<{id: string, status: string, amount: number}>}
   */
  async processPayment(amount, token) {
    throw new Error('processPayment() must be implemented');
  }

  async refund(transactionId, amount) {
    throw new Error('refund() must be implemented');
  }

  async getTransactionStatus(transactionId) {
    throw new Error('getTransactionStatus() must be implemented');
  }
}


// ---- ADAPTEE 1: Stripe SDK (uses cents, different method names) ----

class StripeSDK {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async createCharge(amountInCents, currency, source) {
    console.log(`[Stripe] Charging ${amountInCents} cents (${currency})`);
    return {
      id: `ch_${Date.now()}`,
      amount: amountInCents,
      currency,
      status: 'succeeded',
      source,
    };
  }

  async createRefund(chargeId, amountInCents) {
    console.log(`[Stripe] Refunding ${amountInCents} cents for ${chargeId}`);
    return {
      id: `re_${Date.now()}`,
      charge: chargeId,
      amount: amountInCents,
      status: 'succeeded',
    };
  }

  async retrieveCharge(chargeId) {
    console.log(`[Stripe] Retrieving charge ${chargeId}`);
    return { id: chargeId, status: 'succeeded', amount: 5000 };
  }
}


// ---- ADAPTEE 2: PayPal SDK (uses different structure entirely) ----

class PayPalSDK {
  constructor(clientId, secret) {
    this.clientId = clientId;
    this.secret = secret;
  }

  async executePayment(paymentDetails) {
    console.log(`[PayPal] Executing payment of $${paymentDetails.total}`);
    return {
      paymentId: `PAY-${Date.now()}`,
      state: 'approved',
      transactions: [{ amount: { total: paymentDetails.total } }],
    };
  }

  async refundSale(saleId, refundDetails) {
    console.log(`[PayPal] Refunding sale ${saleId}`);
    return {
      refundId: `REF-${Date.now()}`,
      state: 'completed',
      sale_id: saleId,
    };
  }

  async getPayment(paymentId) {
    console.log(`[PayPal] Getting payment ${paymentId}`);
    return { paymentId, state: 'approved' };
  }
}


// ---- ADAPTEE 3: Square SDK (yet another API shape) ----

class SquareSDK {
  constructor(accessToken) {
    this.accessToken = accessToken;
  }

  async createPayment(body) {
    console.log(`[Square] Creating payment of ${body.amountMoney.amount} cents`);
    return {
      payment: {
        id: `sq_${Date.now()}`,
        amountMoney: body.amountMoney,
        status: 'COMPLETED',
      },
    };
  }

  async refundPayment(body) {
    console.log(`[Square] Refunding payment ${body.paymentId}`);
    return {
      refund: {
        id: `sqr_${Date.now()}`,
        paymentId: body.paymentId,
        status: 'COMPLETED',
      },
    };
  }

  async getPayment(paymentId) {
    console.log(`[Square] Getting payment ${paymentId}`);
    return {
      payment: { id: paymentId, status: 'COMPLETED' },
    };
  }
}


// ============================================================
// ADAPTERS -- each translates our interface to the vendor's API
// ============================================================

class StripeAdapter extends PaymentProcessor {
  constructor(apiKey) {
    super();
    this.stripe = new StripeSDK(apiKey);
  }

  async processPayment(amount, token) {
    // Stripe uses cents, not dollars
    const result = await this.stripe.createCharge(
      Math.round(amount * 100),
      'usd',
      token
    );
    // Normalize response to our standard format
    return {
      id: result.id,
      status: result.status === 'succeeded' ? 'success' : 'failed',
      amount: result.amount / 100,
    };
  }

  async refund(transactionId, amount) {
    const result = await this.stripe.createRefund(
      transactionId,
      Math.round(amount * 100)
    );
    return {
      id: result.id,
      status: result.status === 'succeeded' ? 'success' : 'failed',
    };
  }

  async getTransactionStatus(transactionId) {
    const result = await this.stripe.retrieveCharge(transactionId);
    return {
      id: result.id,
      status: result.status === 'succeeded' ? 'success' : 'failed',
    };
  }
}


class PayPalAdapter extends PaymentProcessor {
  constructor(clientId, secret) {
    super();
    this.paypal = new PayPalSDK(clientId, secret);
  }

  async processPayment(amount, token) {
    const result = await this.paypal.executePayment({
      total: amount.toFixed(2),
      currency: 'USD',
      token,
    });
    return {
      id: result.paymentId,
      status: result.state === 'approved' ? 'success' : 'failed',
      amount: parseFloat(result.transactions[0].amount.total),
    };
  }

  async refund(transactionId, amount) {
    const result = await this.paypal.refundSale(transactionId, {
      total: amount.toFixed(2),
      currency: 'USD',
    });
    return {
      id: result.refundId,
      status: result.state === 'completed' ? 'success' : 'failed',
    };
  }

  async getTransactionStatus(transactionId) {
    const result = await this.paypal.getPayment(transactionId);
    return {
      id: result.paymentId,
      status: result.state === 'approved' ? 'success' : 'failed',
    };
  }
}


class SquareAdapter extends PaymentProcessor {
  constructor(accessToken) {
    super();
    this.square = new SquareSDK(accessToken);
  }

  async processPayment(amount, token) {
    const result = await this.square.createPayment({
      sourceId: token,
      amountMoney: {
        amount: Math.round(amount * 100), // Square uses cents
        currency: 'USD',
      },
      idempotencyKey: `key_${Date.now()}`,
    });
    return {
      id: result.payment.id,
      status: result.payment.status === 'COMPLETED' ? 'success' : 'failed',
      amount: result.payment.amountMoney.amount / 100,
    };
  }

  async refund(transactionId, amount) {
    const result = await this.square.refundPayment({
      paymentId: transactionId,
      amountMoney: { amount: Math.round(amount * 100), currency: 'USD' },
      idempotencyKey: `ref_${Date.now()}`,
    });
    return {
      id: result.refund.id,
      status: result.refund.status === 'COMPLETED' ? 'success' : 'failed',
    };
  }

  async getTransactionStatus(transactionId) {
    const result = await this.square.getPayment(transactionId);
    return {
      id: result.payment.id,
      status: result.payment.status === 'COMPLETED' ? 'success' : 'failed',
    };
  }
}


// ============================================================
// CLIENT CODE -- works with ANY payment processor
// ============================================================

class CheckoutService {
  constructor(paymentProcessor) {
    // Accepts any PaymentProcessor -- does not know which vendor
    this.payment = paymentProcessor;
  }

  async checkout(cart) {
    const total = cart.items.reduce((sum, item) => sum + item.price, 0);
    console.log(`\nProcessing checkout for $${total}...`);

    const result = await this.payment.processPayment(total, cart.paymentToken);
    console.log('Payment result:', result);

    if (result.status === 'success') {
      console.log(`Order confirmed! Transaction: ${result.id}`);
    }
    return result;
  }
}


// ---- USAGE: swap providers without changing CheckoutService ----

async function main() {
  const cart = {
    items: [
      { name: 'Widget', price: 29.99 },
      { name: 'Gadget', price: 19.99 },
    ],
    paymentToken: 'tok_visa_test',
  };

  // Use Stripe
  console.log('=== STRIPE ===');
  const stripeCheckout = new CheckoutService(new StripeAdapter('sk_test'));
  await stripeCheckout.checkout(cart);

  // Use PayPal -- same CheckoutService, different adapter
  console.log('\n=== PAYPAL ===');
  const paypalCheckout = new CheckoutService(
    new PayPalAdapter('client_id', 'secret')
  );
  await paypalCheckout.checkout(cart);

  // Use Square -- same CheckoutService, different adapter
  console.log('\n=== SQUARE ===');
  const squareCheckout = new CheckoutService(new SquareAdapter('sq_token'));
  await squareCheckout.checkout(cart);
}

main();

Output:

=== STRIPE ===
Processing checkout for $49.98...
[Stripe] Charging 4998 cents (usd)
Payment result: { id: 'ch_1712345678', status: 'success', amount: 49.98 }
Order confirmed! Transaction: ch_1712345678

=== PAYPAL ===
Processing checkout for $49.98...
[PayPal] Executing payment of $49.98
Payment result: { id: 'PAY-1712345678', status: 'success', amount: 49.98 }
Order confirmed! Transaction: PAY-1712345678

=== SQUARE ===
Processing checkout for $49.98...
[Square] Creating payment of 4998 cents
Payment result: { id: 'sq_1712345678', status: 'success', amount: 49.98 }
Order confirmed! Transaction: sq_1712345678

6. Legacy System Integration Example

A common real-world scenario: your modern app needs data from a legacy XML-based SOAP service.

// ============================================================
// LEGACY SYSTEM ADAPTER
// ============================================================

// ---- LEGACY SYSTEM: returns XML-like data with old method names ----

class LegacyUserService {
  getUserById(userId) {
    // Returns data in old format
    return {
      xml: `<user>
        <id>${userId}</id>
        <fname>Jane</fname>
        <lname>Doe</lname>
        <electronic_mail>jane.doe@oldcorp.com</electronic_mail>
        <dept_code>ENG-042</dept_code>
        <active_flag>Y</active_flag>
      </user>`,
    };
  }

  searchUsers(deptCode) {
    return {
      xml: `<users>
        <user><id>1</id><fname>Jane</fname><lname>Doe</lname></user>
        <user><id>2</id><fname>John</fname><lname>Smith</lname></user>
      </users>`,
    };
  }
}


// ---- MODERN INTERFACE: what our app expects ----

// Our app expects: { id, firstName, lastName, email, department, isActive }


// ---- ADAPTER: bridges old and new ----

class LegacyUserAdapter {
  constructor() {
    this.legacy = new LegacyUserService();
  }

  // Simple XML parser (production would use a real XML parser)
  _parseXmlValue(xml, tag) {
    const regex = new RegExp(`<${tag}>(.*?)</${tag}>`);
    const match = xml.match(regex);
    return match ? match[1] : null;
  }

  _transformUser(xml) {
    return {
      id: parseInt(this._parseXmlValue(xml, 'id')),
      firstName: this._parseXmlValue(xml, 'fname'),
      lastName: this._parseXmlValue(xml, 'lname'),
      email: this._parseXmlValue(xml, 'electronic_mail'),
      department: this._parseXmlValue(xml, 'dept_code'),
      isActive: this._parseXmlValue(xml, 'active_flag') === 'Y',
    };
  }

  async findById(userId) {
    const result = this.legacy.getUserById(userId);
    return this._transformUser(result.xml);
  }

  async findByDepartment(department) {
    const result = this.legacy.searchUsers(department);
    const userBlocks = result.xml.match(/<user>[\s\S]*?<\/user>/g) || [];
    return userBlocks.map((block) => this._transformUser(block));
  }
}


// ---- CLIENT CODE: knows nothing about XML or legacy systems ----

async function displayUser() {
  const userService = new LegacyUserAdapter();

  const user = await userService.findById(42);
  console.log('User:', user);
  // { id: 42, firstName: 'Jane', lastName: 'Doe',
  //   email: 'jane.doe@oldcorp.com', department: 'ENG-042', isActive: true }

  const team = await userService.findByDepartment('ENG-042');
  console.log('Team:', team);
}

displayUser();

7. Wrapping Third-Party APIs

Another pattern: wrapping a third-party logging library so you can swap it later.

// ============================================================
// THIRD-PARTY API WRAPPER ADAPTER
// ============================================================

// ---- Our application's logger interface ----

class Logger {
  info(message, meta) { throw new Error('Not implemented'); }
  warn(message, meta) { throw new Error('Not implemented'); }
  error(message, meta) { throw new Error('Not implemented'); }
  debug(message, meta) { throw new Error('Not implemented'); }
}


// ---- Third-party: Winston-like logger ----

class WinstonLikeLogger {
  constructor(config) {
    this.level = config.level;
    this.transports = config.transports;
  }
  log(level, msg, metadata) {
    console.log(`[${level.toUpperCase()}] ${msg}`, metadata || '');
  }
}


// ---- Third-party: Pino-like logger (different API) ----

class PinoLikeLogger {
  constructor(options) {
    this.options = options;
  }
  child(bindings) { return this; }
  info(obj, msg) { console.log(`INFO: ${msg || obj}`, typeof obj === 'object' ? obj : ''); }
  warn(obj, msg) { console.log(`WARN: ${msg || obj}`, typeof obj === 'object' ? obj : ''); }
  error(obj, msg) { console.log(`ERROR: ${msg || obj}`, typeof obj === 'object' ? obj : ''); }
  debug(obj, msg) { console.log(`DEBUG: ${msg || obj}`, typeof obj === 'object' ? obj : ''); }
}


// ---- Adapters ----

class WinstonAdapter extends Logger {
  constructor(config) {
    super();
    this.winston = new WinstonLikeLogger(config);
  }
  info(message, meta)  { this.winston.log('info', message, meta); }
  warn(message, meta)  { this.winston.log('warn', message, meta); }
  error(message, meta) { this.winston.log('error', message, meta); }
  debug(message, meta) { this.winston.log('debug', message, meta); }
}

class PinoAdapter extends Logger {
  constructor(options) {
    super();
    this.pino = new PinoLikeLogger(options);
  }
  info(message, meta)  { this.pino.info(meta || {}, message); }
  warn(message, meta)  { this.pino.warn(meta || {}, message); }
  error(message, meta) { this.pino.error(meta || {}, message); }
  debug(message, meta) { this.pino.debug(meta || {}, message); }
}


// ---- Usage: swap logger without touching application code ----

function createLogger(type = 'winston') {
  if (type === 'winston') {
    return new WinstonAdapter({ level: 'info', transports: ['console'] });
  }
  return new PinoAdapter({ level: 'info' });
}

const logger = createLogger('pino');
logger.info('User logged in', { userId: 42 });
logger.error('Payment failed', { orderId: 'ORD-123', reason: 'timeout' });

8. Before and After Comparison

Before (tightly coupled to Stripe)

// BAD: Client is coupled directly to Stripe's API
class CheckoutService {
  async checkout(cart) {
    const stripe = require('stripe')('sk_test');
    const total = cart.items.reduce((sum, i) => sum + i.price, 0);

    // Stripe-specific: amount in cents, specific param names
    const charge = await stripe.charges.create({
      amount: Math.round(total * 100),
      currency: 'usd',
      source: cart.paymentToken,
      description: `Order for ${cart.userId}`,
    });

    // Stripe-specific response shape
    if (charge.status === 'succeeded') {
      return { success: true, transactionId: charge.id };
    }
    return { success: false, error: charge.failure_message };
  }
}

// Problem: switching to PayPal means rewriting CheckoutService
// Problem: testing requires mocking Stripe's exact API shape
// Problem: using two providers simultaneously is a mess

After (adapter pattern)

// GOOD: Client depends on abstraction, not vendor
class CheckoutService {
  constructor(paymentProcessor) {
    this.payment = paymentProcessor; // any PaymentProcessor adapter
  }

  async checkout(cart) {
    const total = cart.items.reduce((sum, i) => sum + i.price, 0);
    const result = await this.payment.processPayment(total, cart.paymentToken);

    if (result.status === 'success') {
      return { success: true, transactionId: result.id };
    }
    return { success: false, error: result.error };
  }
}

// Swap providers with zero changes to CheckoutService:
const checkout = new CheckoutService(new StripeAdapter('sk_test'));
// or
const checkout2 = new CheckoutService(new PayPalAdapter('id', 'secret'));
// or for tests:
const checkout3 = new CheckoutService(new MockPaymentAdapter());

9. When to Use and When to Avoid

Use Adapter when:

ScenarioWhy Adapter helps
Integrating third-party librariesShield your code from vendor-specific APIs
Migrating from old to new systemsBridge legacy and modern interfaces during transition
Supporting multiple implementationsOne interface, many backends (payment, logging, storage)
Writing testable codeAdapt real services to interfaces that are easy to mock
Working with inconsistent APIsNormalize responses from different sources

Avoid Adapter when:

ScenarioWhy Adapter is wrong
Interfaces are already compatibleUnnecessary layer adds complexity
You control both interfacesJust make them match directly
Performance is criticalEach adapter call adds a tiny overhead (usually negligible)
The adaptee will change frequentlyYou end up constantly updating the adapter too

10. Key Takeaways

  1. The Adapter pattern translates one interface into another that clients expect -- it solves the "incompatible interface" problem.
  2. Object adapters (composition) are preferred over class adapters (inheritance) in JavaScript because they are more flexible.
  3. The adapter does NOT add new behavior -- it only translates. If you are adding behavior, you want the Decorator pattern instead.
  4. Real-world uses: payment gateways, logging libraries, legacy system wrappers, database drivers, API normalization.
  5. Adapters follow the Open/Closed Principle -- you can add new adapters for new vendors without modifying existing client code.
  6. The adapter is a thin layer -- it should contain only translation logic, not business logic.

11. Explain-It Challenge

Without looking back, explain in your own words:

  1. What is the difference between the Target, Adaptee, and Adapter roles?
  2. Why is the object adapter preferred over the class adapter in JavaScript?
  3. How does the Adapter pattern help when switching from Stripe to PayPal?
  4. What is the difference between an Adapter and a Facade?
  5. Draw an ASCII diagram showing how a StripeAdapter sits between CheckoutService and StripeSDK.

Navigation: <- 9.4 Overview | 9.4.b -- Facade ->