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?
- 2. The Problem It Solves
- 3. Structure and Participants
- 4. Class Adapter vs Object Adapter
- 5. Payment Gateway Adapter -- Full Example
- 6. Legacy System Integration Example
- 7. Wrapping Third-Party APIs
- 8. Before and After Comparison
- 9. When to Use and When to Avoid
- 10. Key Takeaways
- 11. Explain-It Challenge
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()│ │ │
└────────────┘ └─────────────┘ └──────────────┘
| Role | What it does |
|---|---|
| Target | The interface the client expects |
| Adaptee | The existing class with an incompatible interface |
| Adapter | Translates calls from Target interface to Adaptee interface |
| Client | Works with objects through the Target interface |
2. The Problem It Solves
You encounter the Adapter problem constantly in real software:
- Third-party API changes -- you upgrade a library and its API is completely different
- Multiple vendors -- Stripe, PayPal, and Square each have different payment APIs, but your checkout needs one consistent interface
- Legacy systems -- an old SOAP service needs to work with your modern REST-based application
- 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
| Aspect | Object Adapter | Class Adapter |
|---|---|---|
| Mechanism | Composition (has-a) | Inheritance (is-a) |
| Flexibility | Can adapt any subclass of Adaptee | Only adapts the specific class |
| Override | Cannot override Adaptee behavior easily | Can override Adaptee methods |
| JavaScript fit | Excellent (no multiple inheritance needed) | Workable but less flexible |
| Recommendation | Preferred in most cases | Use 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:
| Scenario | Why Adapter helps |
|---|---|
| Integrating third-party libraries | Shield your code from vendor-specific APIs |
| Migrating from old to new systems | Bridge legacy and modern interfaces during transition |
| Supporting multiple implementations | One interface, many backends (payment, logging, storage) |
| Writing testable code | Adapt real services to interfaces that are easy to mock |
| Working with inconsistent APIs | Normalize responses from different sources |
Avoid Adapter when:
| Scenario | Why Adapter is wrong |
|---|---|
| Interfaces are already compatible | Unnecessary layer adds complexity |
| You control both interfaces | Just make them match directly |
| Performance is critical | Each adapter call adds a tiny overhead (usually negligible) |
| The adaptee will change frequently | You end up constantly updating the adapter too |
10. Key Takeaways
- The Adapter pattern translates one interface into another that clients expect -- it solves the "incompatible interface" problem.
- Object adapters (composition) are preferred over class adapters (inheritance) in JavaScript because they are more flexible.
- The adapter does NOT add new behavior -- it only translates. If you are adding behavior, you want the Decorator pattern instead.
- Real-world uses: payment gateways, logging libraries, legacy system wrappers, database drivers, API normalization.
- Adapters follow the Open/Closed Principle -- you can add new adapters for new vendors without modifying existing client code.
- 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:
- What is the difference between the Target, Adaptee, and Adapter roles?
- Why is the object adapter preferred over the class adapter in JavaScript?
- How does the Adapter pattern help when switching from Stripe to PayPal?
- What is the difference between an Adapter and a Facade?
- Draw an ASCII diagram showing how a
StripeAdaptersits betweenCheckoutServiceandStripeSDK.
Navigation: <- 9.4 Overview | 9.4.b -- Facade ->