Skip to main content
HomeBlogsContactLinks
RSS

Adapter Pattern in TypeScript: Real-World Example and Use Cases

26 May 2026

  • Tech
  • Learning
  • Engineering
  • Design Pattern

Table of Contents

  • Prerequisites
  • Introduction
  • What is the Adapter Pattern
  • Problem in Booking System
  • Solution
  • Implementation
  • Real Usage
  • Pros and Cons
  • When not to use it
  • Conclusion
  • Reference

Prerequisites

  • Basic understanding of OOP concepts
  • Basic understanding of UML diagrams
  • Basic TypeScript knowledge

If you're not familiar with the first two points, this blog might not be suitable for you. Otherwise, you're good to go. The examples are intentionally simple and can be easily replicated in other languages.

Introduction

Hey devs, welcome back to the design pattern series!

So far, we have completed all five Creational patterns in our Booking System. We used Factory Method to create different payment processors, Abstract Factory to produce booking families, Builder to construct bookings step by step, Prototype to clone recurring booking templates, and Singleton to manage global shared state like AvailabilityCache and BookingConfig.

Now we enter an entirely new territory - the Structural patterns. Where Creational patterns are all about how objects are born, Structural patterns are about how objects are composed and connected. They help you build larger, more flexible structures out of smaller pieces without creating tight coupling between them.

The first Structural pattern we are tackling is the Adapter and the problem it solves is one you will encounter very early in any real-world project. The moment you need to use a third-party library that simply does not speak your system's language.

What is the Adapter Pattern

Adapter is a structural design pattern that allows objects with incompatible interfaces to work together. It acts as a wrapper - it translates calls from one interface into calls that the other side can understand, without modifying either of the two.

The classic real-world analogy is a power socket adapter. Your laptop charger has a specific plug shape. The wall socket in a foreign country has a completely different shape. You don't rebuild the wall, and you don't buy a new laptop. You carry a small adapter that bridges the gap between the two incompatible interfaces.

In software, the pattern works exactly the same way. You have an existing interface your system depends on, and a third-party class that does what you need - but exposes a completely different API. The Adapter sits between them, translating one into the other.

01_What_Is_Adapter_Pattern

Problem in Booking System

In our Booking System, we have a clean interface that every payment processor must follow:

interface IPaymentProcessor {
  processPayment(amount: number, currency: string): PaymentResult;
  refundPayment(transactionId: string): RefundResult;
  getTransactionStatus(transactionId: string): TransactionStatus;
}

Our entire system - the BookingService, the InvoiceService, the checkout flow - depends on this IPaymentProcessor interface. Every processor in our codebase implements it, and that's what keeps things clean and replaceable.

Now imagine the business decides to integrate with a popular third-party payment provider like Stripe. You install the SDK and open the docs, and this is what you find:

// Stripe SDK - third-party, cannot be modified
class StripePaymentSDK {
  charge(paymentData: {
    amountInCents: number;
    currencyCode: string;
    metadata?: Record<string, string>;
  }): StripeChargeResponse;

  issueRefund(chargeId: string, options?: { reason?: string }): StripeRefundResponse;

  retrieveCharge(chargeId: string): StripeChargeObject;
}

The method names are different. The parameter shapes are different. The return types are completely different. Your existing system has no idea how to talk to this SDK because it does not implement IPaymentProcessor.

You are now facing the classic incompatible interface problem.

02_The_Incompatible_Interface_Problem

The tempting but wrong approach here is to just make StripePaymentSDK implement IPaymentProcessor directly by modifying the SDK class. But you can't - it's a third-party library. You don't own it, and you definitely should not fork it just to rename some methods.

Another bad approach is to scatter Stripe-specific code all over your booking flow - calling charge() and issueRefund() directly wherever payments happen. Now your system is tightly coupled to Stripe. Switching providers in the future means hunting through dozens of files.

// Without Adapter - tightly coupled
class BookingService {
  private stripe = new StripePaymentSDK();

  async confirmBooking(booking: Booking) {
    // Stripe-specific code scattered inside business logic
    const result = await this.stripe.charge({
      amountInCents: booking.totalAmount * 100,
      currencyCode: booking.currency,
    });

    if (result.status !== "succeeded") {
      throw new Error("Payment failed");
    }
    // ...
  }
}

This is the exact kind of tight coupling that makes codebases painful to maintain. The BookingService now knows too much about Stripe's internal API shape.

Solution

The Adapter pattern says: don't change your system, don't change the library - write a translator in between.

We create a StripePaymentAdapter class that implements our IPaymentProcessor interface. Internally, it holds a reference to StripePaymentSDK and delegates to it. From the outside, it looks exactly like any other payment processor in our system. From the inside, it knows how to speak Stripe.

Key Idea

  • Your system continues to depend only on IPaymentProcessor
  • The Adapter handles all the translation - method name mapping, data shape transformation, return type conversion
  • You can swap providers by writing a new adapter, not by touching your business logic

03_The_Adapter_Solution

Adapter Pattern - Class Diagram

The canonical structure of the Adapter pattern involves four participants:

  • Target (IPaymentProcessor) - the interface your system depends on
  • Adaptee (StripePaymentSDK) - the existing class with the incompatible interface that you want to use
  • Adapter (StripePaymentAdapter) - implements Target, wraps the Adaptee, and translates calls
  • Client (BookingService) - works only with the Target interface, unaware of the Adaptee

04_Adapter_Pattern_Class_Diagram

Implementation

Target Interface

This is the contract our system already has. Nothing changes here.

interface IPaymentProcessor {
  processPayment(amount: number, currency: string): PaymentResult;
  refundPayment(transactionId: string): RefundResult;
  getTransactionStatus(transactionId: string): TransactionStatus;
}

The Adaptee - Third-party Stripe SDK

We can't touch this. It's the SDK exactly as it comes from the npm package.

// Third-party - not owned by us
class StripePaymentSDK {
  charge(paymentData: {
    amountInCents: number;
    currencyCode: string;
    metadata?: Record<string, string>;
  }): StripeChargeResponse {
    // Stripe internal logic
    return {
      id: `ch_${Date.now()}`,
      status: "succeeded",
      amount: paymentData.amountInCents,
      currency: paymentData.currencyCode,
    };
  }

  issueRefund(chargeId: string, options?: { reason?: string }): StripeRefundResponse {
    return {
      id: `re_${Date.now()}`,
      chargeId,
      status: "succeeded",
    };
  }

  retrieveCharge(chargeId: string): StripeChargeObject {
    return {
      id: chargeId,
      status: "succeeded",
      paid: true,
    };
  }
}

Supporting Type Definitions

We need to define our system's return types and Stripe's response shapes so the Adapter can translate between them.

// Our system's return types
type PaymentResult = {
  transactionId: string;
  success: boolean;
  message: string;
};

type RefundResult = {
  refundId: string;
  success: boolean;
};

type TransactionStatus = {
  transactionId: string;
  status: "pending" | "completed" | "failed";
  paid: boolean;
};

// Stripe's response shapes (as they come from the SDK)
type StripeChargeResponse = {
  id: string;
  status: string;
  amount: number;
  currency: string;
};

type StripeRefundResponse = {
  id: string;
  chargeId: string;
  status: string;
};

type StripeChargeObject = {
  id: string;
  status: string;
  paid: boolean;
};

The Adapter

This is the heart of the pattern. The StripePaymentAdapter implements IPaymentProcessor, holds a reference to StripePaymentSDK, and handles all the translation internally.

class StripePaymentAdapter implements IPaymentProcessor {
  private stripe: StripePaymentSDK;

  constructor(stripeSDK: StripePaymentSDK) {
    this.stripe = stripeSDK;
  }

  processPayment(amount: number, currency: string): PaymentResult {
    // Stripe expects amount in cents, our system uses decimal
    const stripeResponse = this.stripe.charge({
      amountInCents: Math.round(amount * 100),
      currencyCode: currency.toUpperCase(),
    });

    return {
      transactionId: stripeResponse.id,
      success: stripeResponse.status === "succeeded",
      message: stripeResponse.status === "succeeded"
        ? "Payment processed successfully"
        : "Payment failed",
    };
  }

  refundPayment(transactionId: string): RefundResult {
    const stripeResponse = this.stripe.issueRefund(transactionId, {
      reason: "requested_by_customer",
    });

    return {
      refundId: stripeResponse.id,
      success: stripeResponse.status === "succeeded",
    };
  }

  getTransactionStatus(transactionId: string): TransactionStatus {
    const charge = this.stripe.retrieveCharge(transactionId);

    return {
      transactionId: charge.id,
      status: charge.status === "succeeded" ? "completed" : "failed",
      paid: charge.paid,
    };
  }
}

Notice what the Adapter is doing in processPayment. Our system passes amount as a decimal (e.g., 99.99), but Stripe expects it in cents (9999). The Adapter silently handles that conversion. Our BookingService never needs to know this detail exists.

Real Usage

Plugging the Adapter into the Booking System

Because StripePaymentAdapter implements IPaymentProcessor, you can inject it anywhere a payment processor is expected and the rest of the system behaves identically regardless of which provider is underneath.

class BookingService {
  constructor(private paymentProcessor: IPaymentProcessor) {}

  async confirmBooking(booking: Booking): Promise<void> {
    const result = this.paymentProcessor.processPayment(
      booking.totalAmount,
      booking.currency
    );

    if (!result.success) {
      throw new Error(`Payment failed: ${result.message}`);
    }

    booking.status = "confirmed";
    booking.transactionId = result.transactionId;
  }

  async cancelBooking(booking: Booking): Promise<void> {
    if (!booking.transactionId) {
      throw new Error("No transaction to refund");
    }

    const refund = this.paymentProcessor.refundPayment(booking.transactionId);

    if (!refund.success) {
      throw new Error("Refund failed");
    }

    booking.status = "cancelled";
  }
}

// Wiring it all together
const stripeSDK = new StripePaymentSDK();
const stripeAdapter = new StripePaymentAdapter(stripeSDK);
const bookingService = new BookingService(stripeAdapter);

The BookingService is completely agnostic about Stripe. It only knows IPaymentProcessor.

Adding a Second Provider

Now suppose the business also wants to support PayPal. You write a PayPalPaymentAdapter that wraps the PayPal SDK and implements the same IPaymentProcessor interface. You do not touch BookingService. You do not touch StripePaymentAdapter. You just write a new adapter and plug it in.

class PayPalPaymentAdapter implements IPaymentProcessor {
  private paypal: PayPalSDK;

  constructor(paypalSDK: PayPalSDK) {
    this.paypal = paypalSDK;
  }

  processPayment(amount: number, currency: string): PaymentResult {
    // PayPal uses its own method and response shape — translated here
    const response = this.paypal.createOrder({
      purchase_units: [{ amount: { value: amount.toString(), currency_code: currency } }],
    });

    return {
      transactionId: response.id,
      success: response.status === "COMPLETED",
      message: response.status === "COMPLETED" ? "Payment completed" : "Payment pending",
    };
  }

  refundPayment(transactionId: string): RefundResult {
    const response = this.paypal.refundCapture(transactionId);
    return {
      refundId: response.id,
      success: response.status === "COMPLETED",
    };
  }

  getTransactionStatus(transactionId: string): TransactionStatus {
    const order = this.paypal.getOrder(transactionId);
    return {
      transactionId: order.id,
      status: order.status === "COMPLETED" ? "completed" : "pending",
      paid: order.status === "COMPLETED",
    };
  }
}

// Switching providers is just one line
const paypalSDK = new PayPalSDK();
const paypalAdapter = new PayPalPaymentAdapter(paypalSDK);
const bookingService = new BookingService(paypalAdapter);

05_The_Multi_Provider_Flow

This is the real power of the Adapter pattern in a payment context. Your system is completely open to new providers - and completely closed to having to be changed every time one is added.

Pros and Cons

Pros

  • You can integrate third-party libraries or legacy code without modifying them, which respects both the Open/Closed Principle and the fact that you simply may not own that code.
  • Your core business logic stays completely isolated from provider-specific implementation details - things like currency conversion, API shape differences, and error response formats all live inside the adapter where they belong.
  • Adding a new payment provider is as simple as writing a new adapter class, with zero changes to existing code.
  • It becomes trivial to swap adapters in tests - you can write a MockPaymentAdapter that implements IPaymentProcessor and use it in unit tests without touching any real payment SDK.

Cons

  • The overall complexity of the codebase increases because you are adding new classes whose only job is translation. For a small number of adapters this is fine, but it can accumulate.
  • If the two interfaces are fundamentally different in behavior - not just in naming - the adapter may end up doing too much work or making assumptions that are fragile. An adapter that has to fake behavior is a warning sign.
  • Debugging can become slightly harder because there is now a layer of indirection between your system and the underlying library. When something goes wrong with a payment, you have to trace through the adapter to understand what was actually sent to the SDK.

When not to use it

The Adapter is not a general-purpose wrapper to throw around every third-party dependency. If the interface you are adapting is already close to what you need - maybe just a method renamed - a simple wrapper or utility function may be enough without the overhead of a full pattern. Similarly, if you only ever have one payment provider and there is no realistic scenario where you would switch, introducing an adapter layer and a IPaymentProcessor interface just for the sake of it is over-engineering. Write the simplest code that solves the problem, and reach for the Adapter when you genuinely need to protect your system from an incompatible external interface.

And remember - the pattern exists to solve the incompatibility problem, not to be applied everywhere something external is involved.

Conclusion

  • Adapter Pattern = a translator between two incompatible interfaces, so they can work together without modifying either side
  • We saw how StripePaymentAdapter wraps the Stripe SDK and exposes it as IPaymentProcessor, keeping BookingService completely decoupled from Stripe internals
  • Adding a new payment provider - PayPal, Square, Razorpay - is now just a matter of writing a new adapter, not touching business logic
  • The Adapter is the first Structural pattern we have applied, and it sets up a key theme of this group: composition over modification

Ok devs, that's it for today. I tried my best to explain this pattern. In the next post, we'll explore the Bridge pattern and see how to separate a BookingRenderer from its rendering targets - JSON, PDF, Email - using abstraction. If you have any queries or suggestions, please feel free to reach out to me.

Final Mental Model

  • Factory Method -> create payment processors by type
  • Prototype -> reuse existing bookings via cloning
  • Adapter -> wrap incompatible third-party SDKs so your system never needs to change

06_The_Mental_Model_Of_Booking_System

Code, learn, refactor, repeat

Reference

  • Adapter Pattern - by Refactoring Guru

Comments

Add a new comment

Stay Connected

GitHub •LinkedIn •X •Daily.dev •Email

© 2026 Chiristo. Feel free to share or reference this post with proper credit