1. Introduction
When architecting yet another application, we put a lot of effort into choosing the correct technology. We think about which framework we should use, which database and ORM we should rely on.
While all these questions are valid and important, a single detail gets too little attention according to my experience: how do we architect the application so that it is maintainable and testable? That’s where Clean Architecture (CA) comes to play.
We might argue that, if we are bootstrapping the application, we don’t need to think about maintainability and testability. I definitely agree with this. However, if we’ve mastered Clean Architecture, then applying those principles requires a minimal amount of extra effort. We can get a maintainable and testable application for free.
In this article, I give a brief intro into Clean Architecture and show you how to refactor your app in order to use the principles of CA.
2. What is Clean Architecture (CA)?
What is Clean Architecture? What makes it so powerful, yet so cheap?
The main principle of CA is to separate the business logic from the underlying details such as frameworks, databases and external APIs.
Separating business logic from the details means keeping the business logic in a single layer (aka the service layer). Also, CA forces business logic not to depend (i.e., require either directly or indirectly) any of the details. This principle is called Dependency inversion principle.
CA provides us with a quick and easy way to modularize the code. We can be sure that if we’re changing the internals of the business layer, then we don’t need to think about any other layer in the codebase. When changing the framework and driver layers, we can be confident that we haven’t broken any business logic.
The image below depicts the structure of CA. Even though it seems complex, bear with me. Getting the basics is way easier than you actually think.
[^1]
3. Building a Sample Project
Let’s imagine that we have a very simple business. We provide our clients with the list of sports matches taking place today with the win probabilities. Our customers can fetch the required data via our API.
Here’s an example controller of our public API:
The core of our business is not knowing all the matches taking place today but actually being able to predict really well who is going to win. Hence, we can fetch the list of games happening today via a dedicated third party API. In this example, we’re using Live Score API.
Live Score API allows fetching a list of football matches. Our clients, however, also want to be able to retrieve basketball games.
As we can see from the code below, the naïve implementation of 2 different sports would be readable, but there would be a small issue. If we were to add any other external APIs to this controller, then we would end up with cluttered and hard-to-read code.
4. Migrating to Clean Architecture
Let’s look at how we can transform our application. The key is to make as small of steps as possible. As soon as we refactor one certain aspect of the code, we should publish a pull request and deploy it before proceeding to the next step.
4.1. Move the Business Logic to a Separate Module
The first step towards Clean Architecture is to make a clear distinction between the business logic and the rest of the code.
For example, in our sample application, the controller function that is exported inside module.exports
, is not business logic because the way we handle request and response is not related to the core of our business.
getOdds
, on the other hand, is actually related to our business. Hence, we need to ensure that it gets separated from the rest of the code. We are going to place the content of getOdds
into a folder called use-cases
.
After completing the first step, lib/use-cases/get-odds-use-case
looks like this:
The controller which invokes the use case looks like this:
Completing this step has ensured that the controllers depend on the use-cases. According to the picture in section 2, that should be the case: a controller should depend on (i.e. require) a use-case.
4.2. Create an Interface for External Dependencies
After we’ve successfully made a clear distinction between business logic and non-business logic, let’s solve the next problem. GetOddsUseCase
depends on two external APIs: Live Score and API Basketball. However, according to the image in section 2, it should be the other way: Web should depend on Use Cases.
In order to inverse this dependency, the first step is to define a unified interface that describes both APIs.
Let’s create the file lib/use-cases/interfaces/sports-api.interface.js
4.3. Implement SportsAPI Interface
After defining a suitable interface, both external APIs should implement the SportsAPI interface.
Let’s create a file lib/sports-api-implementations/live-score.js
for implementing Live Score API.
To implement API Basketball, let’s create the module lib/sports-api-implementations/api-basketball.js
Note that both of these implementations are not in the lib/use-cases
folder and hence, we can’t require them directly from the use case. In order to start using both implementations, we need to make a small change to the use case.
As you can see, instead of directly passing the sport (either football or basketball) to the use case, the getOdds
method requires having an instance of sportsAPI as an argument.
4.4. Inject SportsAPI as an Argument
In order to start using the refactored getOdds
method, we need to modify the controller so that we would pass sportsAPI
as an argument.
Let’s take a closer look to the getSportsAPIImplementation method:
We can see that instead of deciding in our business logic, which API client should we invoke, we’ve pushed it out of the business layer.
That’s it - we’ve finally achieved our goal of not having any kind of external dependencies inside our core business logic.
5. Conclusion
In this article, we’ve looked into how we can transform our Node.js application to use Clean Architecture.
One of the most important principles is not to require any non-business dependencies inside the business logic. Instead, we should reverse the dependency graph, and instead, the business layer should depend on abstract interfaces. All external dependencies should implement the interfaces.