The code I'm sharing is basic and is intended to illustrate the main concept without overwhelming complexity. It's focused on the design pattern rather than implementing the best possible Backend API handler.

The Use Case for Facade Pattern

In frameworks like Angular, a built-in abstraction exists for performing AJAX calls, ensuring type safety and a smooth developer experience. However, in React or pure JavaScript, external libraries like axios become necessary. Today, I'll develop a custom abstraction idea on top of the axios library to achieve a specific goal: decoupling API call implementations for seamless migration to other libraries or solutions.

The Problem

Imagine a scenario where you're working on a large project and you find yourself using the Axios instance directly throughout the codebase, whether it's in components, services, or any other layer:

// @@@ React
// In UserDetails.tsx
const UsersDetails = () => {
  useEffect(() => {
    axios.get("https://your-api/users").then((response) => {
      // Do any state updates.
    });
  }, []);
};

// @@@ Redux-Toolkit acts
// users.acts

export const actDeviceSelect = createAsyncThunk(
  "users/createUser",
  async (payload, { rejectWithValue }) => {
    try {
      axios.post("https://your-api/users").then((response) => {
        // Do any state updates.
      });
    } catch {
      return rejectWithValue("Ups, something went wrong");
    }
  }
);

This poses a significant issue. Why? Because you've tightly coupled your application's core logic with its presentation layer, relying directly on a library that could potentially be replaced in the future by something else, such as native fetch, another library, or a custom implementation.

Repeating such code in numerous places will lead to a maintenance nightmare, with an overwhelming number of files to manage!

Abstraction for the Rescue

The simplest way to improve this is by creating an additional layer in your application architecture - services. These services will internally utilize the library and expose a public API for specific requests - this is the facade pattern.

// @@@ TypeScript
// UsersService

export const UsersService = {
  getUsers: () => {
    return axios.get("https://your-api/users");
  },
  createUser: () => {
    return axios.post("https://your-api/users");
  },
};

// @@@ React
// In UserDetails.tsx
const UsersDetails = () => {
  useEffect(() => {
    UsersService.getUsers().then((response) => {
      // Do any state updates.
    });
  }, []);
};

// @@@ Redux-Toolkit acts
// users.acts

export const actDeviceSelect = createAsyncThunk(
  "users/createUser",
  async (payload, { rejectWithValue }) => {
    try {
      UsersService.createUser(payload).then((response) => {
        // Do any state updates.
      });
    } catch {
      return rejectWithValue("Ups, something went wrong");
    }
  }
);

This solution addresses several issues. Now, instead of hunting down every instance where Axios is utilized, you only need to modify the services layer, which significantly streamlines the process. While transitioning to a different solution may still entail some effort, particularly if you have tests in place, it's far from being a daunting task.

However, I'm eager to introduce you to an even more advanced approach that simplifies this further, requiring changes in just one file!

Adding a Facade to a Facade

It might sound unconventional, but we can develop another Facade for Axios. What would that entail?

// api.ts
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios";

interface Api {
  get<T = any, R = AxiosResponse<T>>(
    url: string,
    config?: AxiosRequestConfig
  ): Promise<R>;
  post<T = any, R = AxiosResponse<T>>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): Promise<R>;
  // ...etc.
}

const api: Api= {
  get: <T, R>(url: string, config?: AxiosRequestConfig): Promise<R> => {
    return axios.get(url, config);
  },
  post: <T, R>(
    url: string,
    data?: T,
    config?: AxiosRequestConfig
  ): Promise<R> => {
    return axios.post(url, data, config);
  },
  // ...etc.
};

export default api;

With that, we've streamlined the process, reducing the number of files requiring modification. To transition to native fetch or another solution, you only need to edit one file - the api.ts.

import { api } from "api";

const UsersService = {
  getUsers: () => {
    return api.get("your-api-url").then((response) => {});
  },
  // ...etc
};

So, the workload reduction is substantial! Of course, implementing strategies like this is only worthwhile for large projects where the dedicated library or solution is fragile and might undergo changes soon.

Digging In Further

Certainly, we can refine our approach even further. Currently, we're relying on interfaces from axios, which mandates using identical function signatures for each specific method. While this may not pose an issue, we have the opportunity to enhance our contract and reduce reliance on the shape of the axios library.

import { get, post } from 'your-api-library-file';
// "users" is the API controller path
// UserModel is the shape of the response that we receive.
get<UserModel>("users");

// "UserPayload" is the shape of the data that we send
// "UserModel" is the shape of the response that we receive
// "users" is the API controller path
post<UserPayload, UserModel>("users");

So, now we can reduce the dependency not only to the one file, but also one several lines only!

type ControllerPath = `users` | `posts`;

const combinePath = (controllerPath: ControllerPath) => {
  return process.env.BE_PATH + "/" + controllerPath;
};

export const get = async <Response>(
  controllerPath: ControllerPath
): Promise<Response> => {
  // We can add any logic we need and change the signature
  // for our needs.
  try {
    // The dependency on "Axios" is only in this place!
    return await axios.get<Response>(combinePath(controllerPath));
  } catch (err: unknown) {
    throw err;
  }
};

// Call it like that
await get<UserModel>('posts')

Now, we're not confined to the contract offered by the Axios library; we can define our own contract and simply utilize the Axios instance to make the requests and pass parameters, nothing more.

This level of flexibility is truly powerful, allowing for extensive customization. However, it's important to note that this approach may not be suitable for every application. Its relevance depends on the complexity of the current solutions and specific project requirements.

Summary

As you've seen, the facade pattern can significantly reduce your future workload while enhancing customization and developer-friendliness, particularly for APIs that are challenging to work with. This pattern has broad applicability across various contexts, making it valuable to grasp and understand its implementation principles.

I'd like to provide you with the full implementation in this article, but delving into it would distract from the main point - showcasing a real-world use case of the facade pattern. Implementing such a utility for managing API calls warrants its own article, as it involves more than just one design pattern. Therefore, I've chosen to omit this part for now.

If you're interested in a more generic look at this pattern, take a look at the following Facade Pattern in TypeScript.

Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.