Adapter Design Pattern: A Guide to Manage Multiple Third-Party Integrations

Introduction

Imagine a hectic day at the office. It's lunchtime, and after spending the first three hours in a lengthy and exhausting meeting, you're eager to order food from your favorite vendor. Instinctively, you navigate to the order history and repeat your previous order from yesterday, as you're not in the mood for a culinary adventure. Upon checkout, you select your saved card and click the familiar "Place Order" button, not waiting to see the outcome because you subconsciously expect your order to be placed without any issues. After 10 minutes, you realize that you haven't received the usual push notification confirming your order. You unlock your phone and see an error banner that reads, "Unable to process payment. Please retry in a few minutes." You can't believe your eyes. You retry a couple of times, but nothing changes. Frustrated, you close the app and download a competitor's app because you refuse to go hungry due to a failed payment.

Believe it or not, scenarios like this are quite common across various technology sectors, including failed payments, unsuccessful email or text message deliveries, and the like. As you can infer from the experience described above, customers don't care about service downtimes from your third-party providers. They simply want the app to work seamlessly when they need to use it. Anything less than that compromises the user experience.

To prevent scenarios like these, a typical approach involves integrating multiple providers that offer the same service. One provider acts as the primary source, while the others serve as backup providers. This setup allows for seamless real-time switching between providers in the event of downtime from one of them.

Ideally, you would want to implement a solution that accomplishes this objective in a scalable and efficient manner. How can we achieve this?

Introducing the Adapter Pattern

The Adapter Pattern is a structural design pattern used in software development to enable the interface of one class to work with another interface that would otherwise be incompatible. It is advantageous when you have existing classes or components with different interfaces that need to cooperate. The Adapter Pattern serves as a bridge between these two interfaces, allowing them to collaborate seamlessly.

Managing Multiple Third-Party Integrations

It's not uncommon for applications to perform specific functions by integrating with third-party systems through APIs and SDKs. However, a drawback of this approach is that the part of the system relying on third-party integration becomes tightly coupled with it. This means that if the third-party system experiences downtime, the corresponding functionality will be unavailable to customers.

One way to mitigate the impact of potential downtimes from third-party providers is to have backup options in the form of multiple integrations for similar features. Nevertheless, managing several third-party providers can be a challenging task.

Implementing the Adapter Pattern: A Practical Example

Consider a hypothetical e-commerce company, TrendyWears , which needs to send emails to its users triggered by various actions such as sign-up, email verification, transactions, and orders. For this example, we can make the following assumptions:

  • In the past, TrendyWears integrated a single email provider that experienced downtimes during critical business periods (flash sales, Black Friday, New Year, etc.).

  • Having faced disappointment, they decided to integrate three email providers to seamlessly switch between them in real-time.

  • The company has an internal service for managing various providers for different functions (messaging, payments, logistics, etc.).

Now, let's see how it all fits together.

In this example, we will be using TypeScript.

Defining a common interface

The first step in implementing the Adapter pattern involves defining a common interface. We can establish a unified Notifications interface, which includes a single sendEmail method for sending emails as follows:

interface Notifications {
    sendEmail(template: string): Promise<string>;
}

The Notifications interface establishes a contract to which the Adapter class adheres.

Creating the Adapter class

Recall that we mentioned earlier that the company TrendyWears integrated three fictitious email providers. To ensure consistency, we can have these email providers adhere to the same contract as follows:

// sample email metadata
interface EmailMetadta {
    first_name: string;
    email_address: string;
}

// mailer contract
interface Mailer {
    mailMessage(template: string, metadata: EmailMetadata): Promise<void>;
    fetchDeliveryLog(): Promise<any>;
}

class EmailClientA implements Mailer {
    async mailMessage(template: string): Promise<void> {
        // external API call to service provider A
    }

    // purely for demonstration purpose
    async fetchDeliveryLog(): Promise<any> {}
}

class EmailClientB implements Mailer {
    async mailMessage(template: string): Promise<void> {
        // external API call to service provider B
    }
}

class EmailClientC implements Mailer {
    async mailMessage(template: string): Promise<void> {
        // external API call to service provider C
    }
}

Next, we create the initial structure of the Adapter class as follows:

class Notificator implements Notifications {
    private mailClientA: EmaiClientA;
    private mailClientB: EmailCLientB;
    private mailClientC: EmailClientC;

    constructor(mailClientA: EmailClientA, mailClientB: EmailClientB, mailClientC: EmailClientC) {
        this.mailClientA = mailClientA;
        this.mailClientB = mailClientB;
        this.mailClientC = mailClientC;
    }

    async sendEmail(template: string, metadata: any): Promise<void> {
        // we'll develop this in a bit
    }
}

Implement custom logic for switching

Next, we will implement custom logic for switching between email clients. To maintain simplicity, we will base our implementation on the following assumptions:

  • We have a simple retry method for retrying failed attempts, as follows:

  •           async function retry<T>(maxRetries: number, timeout: string, fn: (attempt: number) => Promise<T>): Promise<T> {
                  // custom logic for retry
              }
    
  • The method for switching between providers utilizes a straightforward round-robin approach for n consecutive failed email delivery attempts.

We can implement a simple switching logic as follows:

class Notificator implements Notifications {
    private mailClientA: EmaiClientA;
    private mailClientB: EmailCLientB;
    private mailClientC: EmailClientC;

    private providers: string[];
    private currentProvider: string;

    constructor(mailClientA: EmailClientA, mailClientB: EmailClientB, mailClientC: EmailClientC) {
        this.mailClientA = mailClientA;
        this.mailClientB = mailClientB;
        this.mailClientC = mailClientC;

        // simple hard-coded provider queue
        this.providers = ["mailClientA", "mailClientB", "mailClientC"];
        this.currentProvider = providers[0]; // default provider
    }

    async sendEmail(template: string, metadata: any): Promise<string> {
        // these should be stored as env variables
        const maxRetries = 6;
        const timeout = "15ms";

      return retry(maxRetries, timeout, async(attempt) => {
            try {
                return await this[this.currentProvider].mailMessage(template, metadata);
            } catch(error) {
                // switch to next provider in even intervals
                const isEvenAttempt = attempt % 2 === 0;
                if (isEvenAttempt) {
                    this.resetCurrentProvider();
                }

                // trigger retry
                throw error;
            }
        });
    }

    private resetCurrentProvider() {
        this.providers.push(this.providers.shift());    
        this.currentProvider = this.providers[0];
    }

}

From the above example:

  • We have a queue of providers, which are references to each of the email clients. In a production app, this can be persisted in an in-memory store like Redis.

  • The currentProvider variable keeps track of the current email client reference, and this variable is reset after every failed event attempt.

  • The sendEmail method is retried for a maximum of 6 attempts.

The Notificator class effectively serves as a bridge between the sendEmail function and various email clients, while also providing an abstraction for dynamically switching between email clients in real time.

Benefits

As demonstrated in the previous example, the adapter pattern offers several benefits to software systems, some of which include:

  • Reliability: The system becomes more dependable as it can seamlessly switch between third-party providers, ensuring uninterrupted service even if one provider encounters issues or downtime.

  • Separation of Concerns: The Adapter pattern aids in maintaining a clear separation of concerns. Other components of the system that rely on the feature interface don't need to be aware of the intricacies of individual third-party integrations. Instead, they interact with the feature through a common interface, making the system more modular and maintainable.

  • Service-Level Agreements (SLAs): Businesses can more effectively meet their service-level agreements (SLAs) with customers, as the Adapter pattern allows them to adapt to changing conditions and ensure that commitments are fulfilled.

Challenges and Considerations

While the Adapter pattern offers numerous benefits, managing multiple third-party integrations and their respective implementations can become increasingly challenging.

As with any decision in software engineering, trade-offs must be made between managing these integrations within a service class inside the system or creating a dedicated integration service for this purpose, while considering the additional costs involved. The choice depends on the number of integrations and the specific needs of the business.

Adapting this concept to our hypothetical TrendyWears company, we could create a separate trendywears-integrations service whose sole responsibility is managing multiple integrations for the company, spanning various domains such as messaging (email and/or push notifications), payments, logistics, and more. For the messaging integrations mentioned in our example, we could expose functionalities as APIs, allowing other services within the system (such as orders, payments, etc.) to interact with them. These include:

  • A sendEmail API for sending emails: Services interacting with this API remain unaware of the providers being used.

  • A getCurrentProvider API: This could be helpful for client-facing applications (web/mobile) to determine the appropriate SDKs and implement routing logic.

  • A resetProvider API for resetting the current provider: Typically, this is called after n consecutive failed attempts.

Real-World Applications

The Adapter pattern has several real-world applications related to managing multiple third-party integrations, some of which include:

  • Messaging (email and/or push notifications)

  • Cloud services (switching between multiple cloud providers, cloud storage, etc.)

  • Payments (supporting multiple payment providers)

  • API versioning

Conclusion

The power of the Adapter Pattern lies in its ability to enhance system reliability, separate concerns among components, and adapt to changing conditions. It is a tool that empowers businesses to meet their commitments, even in the face of a dynamic and uncertain technological landscape. By understanding and effectively applying the Adapter Pattern, software engineers can unlock new dimensions of robustness and flexibility in their systems, ultimately delivering a more reliable and adaptable experience to users and businesses.