Mastering Dependency Injection: Enhancing Code Modularity and Maintainability

luisfpedroso

Luis Filipe Pedroso

Posted on June 26, 2024

Mastering Dependency Injection: Enhancing Code Modularity and Maintainability

As the sun set behind the rolling hills of Silicon Valley, John found himself at a crucial crossroads. Months ago, a library was introduced into the project, only to now reveal numerous bugs, leaving John with the daunting task of updating 30 files to achieve the team's goal. Faced with this challenge, John realized that simply updating imports and refactoring the code wasn't the best path forward. Instead, he saw an opportunity to apply a design pattern that would not only resolve the current issue but also streamline future modifications.
John decided to use Dependency Injection (DI), a powerful design pattern that decouples the code, shifting responsibilities outside of a single file. This approach ensures that individual files don't directly consume third-party packages but rely on functions or classes within the project. By doing so, John could transform a potentially extensive refactor into a single, manageable transformation down the road.
In this post, we’ll explore how to implement Dependency Injection by creating an API client that consumes data from the GitHub API, demonstrating the pattern's practical application and benefits.

The first class:

Let's start by creating our first class, which we'll call Api.

class Api {
  constructor() {}

  async get(url: string) {

  }
}

export default Api;
Enter fullscreen mode Exit fullscreen mode

This class has a single method called get, responsible for executing GET requests. Now, think for a minute about how you would implement the code for this method. Would you add a library such as Axios and call it in the get method? Something like:

async get(url: string) {
  const response = await axios.get(url)
  return response.data
}
Enter fullscreen mode Exit fullscreen mode

If you thought about doing something like this, let me tell you that here's our core for dependency injection. Instead of attaching this class with a third-party library, we will move the dependency one level up. Therefore, all the logic regarding the headers of the request, or how to perform a request, will be placed in a new class responsible for only this.
To achieve that, let's create a new field for our class called client, and set its value in the constructor of the class.

class Api {
  private client: Fetcher<any>;

  constructor(client: Fetcher<any>) {
    this.client = client
  }
}
Enter fullscreen mode Exit fullscreen mode

or, to simplify:

class Api {
  constructor(private client: Fetcher<any>) {}
}
Enter fullscreen mode Exit fullscreen mode

And update our get method:

async get(url: string) {
  return this.client.get(url);
}
Enter fullscreen mode Exit fullscreen mode

The contract

Now, we need to create something called Fetcher. What is Fetcher? It’s an interface that defines methods for those that implement it. By doing this, we can ensure that the Api class will call a valid method in the get method.
Let's create it:

interface Fetcher<T> {
  initialize(baseUrl: string): void;
  get(url: string): Promise<T>;
}

export default Fetcher;
Enter fullscreen mode Exit fullscreen mode

In this interface, we define two functions, initialize, and get. You will understand the purpose of the initialize in a bit.

The first fetcher

It's time to create the first class responsible for making requests.

Let's call it FetchFetcher.

import Fetcher from "./Fetcher";

class FetchFetcher<T> implements Fetcher<T> {
  private baseUrl: string = "";

  initialize(baseURL: string): void {
    this.baseUrl = baseURL;
  }

  async get(url: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${url}`);
    const parsedResponse = await response.json();

    return parsedResponse;
  }
}

export default FetchFetcher;
Enter fullscreen mode Exit fullscreen mode

Beautiful, huh? Now we have a class that uses the Javascript Fetch API and does a single stuff.

Connecting the pieces

To connect everything, let's create an initialization file:

import Api from "./Api";
import FetchFetcher from "./FetchFetcher";

async function main() {
  const fetchFetcher = new FetchFetcher();

  fetchFetcher.initialize("https://api.github.com");

  const api = new Api(fetchFetcher);

  const response = await api.get("/users/luisfilipepedroso");
  console.log(response);
}

main();
Enter fullscreen mode Exit fullscreen mode

You might be asking yourself, why create a class called Api to perform requests, or wouldn't it be better to call get from the FetchFetcher directly? Well, you are half right, since, at this moment, we can create any fetchers we want. Take a look:

import axios, { AxiosInstance } from "axios";
import Fetcher from "./Fetcher";

class AxiosFetcher<T> implements Fetcher<T> {
  private axiosInstance: AxiosInstance | null = null;

  initialize(baseURL: string): void {
    this.axiosInstance = axios.create({
      baseURL,
    });
  }

  async get(url: string): Promise<T> {
    const response = await this.axiosInstance!.get<T>(url);
    return response.data;
  }
}

export default AxiosFetcher;
Enter fullscreen mode Exit fullscreen mode

Now we have a class that uses the Axios library, and we can initiate it in the main file:

import Api from "./Api";
import AxiosFetcher from "./AxiosFetcher";
import FetchFetcher from "./FetchFetcher";

async function main() {
  const axiosFetcher = new AxiosFetcher();
  // const fetchFetcher = new FetchFetcher();

  axiosFetcher.initialize("https://api.github.com");
  // fetchFetcher.initialize("https://api.github.com");

  const api = new Api(axiosFetcher);

  const response = await api.get("/users/luisfilipepedroso");
  console.log(response);
}

main();
Enter fullscreen mode Exit fullscreen mode

And the code will continue working, without touching the Api class.

Conclusion

Using Dependency Injection allows us to decouple our code, making it more flexible and easier to maintain. By abstracting the HTTP client, we can swap implementations without modifying the core logic.

To recap, here are the key benefits and steps we covered:

  • Decoupling Code: Dependency Injection helps to separate concerns by shifting dependencies outside of a single class.
  • Flexibility: We can easily switch between different HTTP clients (like Fetch and Axios) without altering the core Api class.
  • Maintainability: This approach makes the codebase more maintainable by isolating third-party dependencies.

What do you think about this post? Let me know your thoughts!

💖 💪 🙅 🚩
luisfpedroso
Luis Filipe Pedroso

Posted on June 26, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related