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;
}
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
}
}
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;
}
}
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'
});
Run the Example
yarn start
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.
Output Breakdown
LegacyPayment flow:
-
1)
ThePaymentService
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
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
Loved this! The adapter pattern finally makes sense. 🙌