Mastering Dependency Injection: Enhancing Code Modularity and Maintainability
Luis Filipe Pedroso
Posted on June 26, 2024
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;
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
}
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
}
}
or, to simplify:
class Api {
constructor(private client: Fetcher<any>) {}
}
And update our get
method:
async get(url: string) {
return this.client.get(url);
}
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;
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;
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();
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;
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();
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!
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
June 26, 2024