Clean Architecture in Node.js


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.

Clean Architecture[^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:

const liveScore = require('../../live-score');

const calculateWinProbabilitiesForMatch = match => {
    const homeTeamWinProbability = Math.random();
    const awayTeamWinProbability = 1 - homeTeamWinProbability;

    return {
        homeTeam: match.homeTeam,
        awayTeam: match.awayTeam,
        league: match.league,
        homeTeamWinProbability,
        awayTeamWinProbability
    };
}

const calculateWinProbabilities = (matches) => {
    return matches.map(calculateWinProbabilitiesForMatch);
}

module.exports = async (req, res) => {
    const matches = await liveScore.getMatches(req.params.matchId);

    res.send({ data: calculateWinProbabilities(matches) })
}

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.

const liveScore = require('../../live-score');
const apiBasketball = require('../../api-basketball');

const calculateWinProbabilitiesForMatch = match => {
    const homeTeamWinProbability = Math.random();
    const awayTeamWinProbability = 1 - homeTeamWinProbability;

    return {
        homeTeam: match.homeTeam,
        awayTeam: match.awayTeam,
        league: match.league,
        homeTeamWinProbability,
        awayTeamWinProbability
    };
}

const calculateWinProbabilities = (matches) => {
    return matches.map(calculateWinProbabilitiesForMatch);
}

const getMatches = sport => {
    if(sport === "basketball") {
        return apiBasketball.getMatches().map(match => ({
            homeTeam: match.teams.home.name,
            awayTeam: match.teams.away.name,
            leagueName: match.league.name
        }));
    }

    return liveScore.getMatches().map(match => ({
        homeTeam: fixture.home_name,
        awayTeam: fixture.away_name,
        leagueName: fixture.league?.name
    }));
}

module.exports = async (req, res) => {
    const matches = await getMatches(req.query.sport);

    res.send({ data: calculateWinProbabilities(matches) })
}

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:

const liveScore = require('../../live-score');
const apiBasketball = require('../../api-basketball');

const calculateWinProbabilitiesForMatch = match => {
    const homeTeamWinProbability = Math.random();
    const awayTeamWinProbability = 1 - homeTeamWinProbability;

    return {
        homeTeam: match.homeTeam,
        awayTeam: match.awayTeam,
        leagueName: match.leagueName,
        homeTeamWinProbability,
        awayTeamWinProbability
    };
}

const calculateWinProbabilities = (matches) => {
    return matches.map(calculateWinProbabilitiesForMatch);
}

module.exports = {
    getOdds: async sport => {
        if(sport === "basketball") {
            const basketballMatches = await apiBasketball
                .getBasketballMatches()

            return calculateWinProbabilities(
                basketballMatches.map(match => ({
                    homeTeam: match.teams.home.name,
                    awayTeam: match.teams.away.name,
                    leagueName: match.league.name
                }))
            );
        }

        const footballMatches = await liveScore.getAllFootballMatches();

        return calculateWinProbabilities(
            footballMatches.map(fixture => ({
                homeTeam: fixture.home_name,
                awayTeam: fixture.away_name,
                leagueName: fixture.league?.name
            }))
        );
    }
}

The controller which invokes the use case looks like this:

const getOddsUseCase = require('../../use-cases/get-odds-use-case')

module.exports = async (req, res) => {
    const odds = await getOddsUseCase.getOdds(req.query.sport)

    res.send({ data: odds })
}

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

function SportsAPI() { }

SportsAPI.prototype.getMatches = function getMatches() {
    throw new Error("Not implemented");
};


module.exports = SportsAPI;

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.

const axios = require('axios');

module.exports = {
    getMatches: async () => {
        const response = await axios.get(
            `https://livescore-api.com/api-client/fixtures/matches.json`
        );

        return response.data.data.fixtures.map(match => ({
            homeTeam: match.home_name,
            awayTeam: match.away_name,
            leagueName: match?.league?.name
        }));
    }
}

To implement API Basketball, let’s create the module lib/sports-api-implementations/api-basketball.js

const axios = require('axios');

module.exports = {
    getMatches: async () => {
        const response = await axios.get(
            'https://api-basketball.p.rapidapi.com/games?date=2020-12-20',
            {
                headers: {
                    'x-rapidapi-key': process.env.RAPID_API_KEY,
                    "x-rapidapi-host": "api-basketball.p.rapidapi.com"
                }
            }
        );


        return response.data.response.map(match => ({
            homeTeam: match.teams.home.name,
            awayTeam: match.teams.away.name,
            leagueName: match.league.name
        }));
    }
}

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.

module.exports = {
    getOdds: async sportsAPI => {
        const matches = await sportsAPI.getMatches();

        return calculateWinProbabilities(matches);
    }
}

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.

const getOddsUseCase = require('../../use-cases/get-odds-use-case');
const { getSportsAPIImplementation } = require('../../sports-api-implementations');

module.exports = async (req, res) => {
    const sportsAPIImplementation = getSportsAPIImplementation(
        req.query.sport
    );

    const odds = await getOddsUseCase.getOdds(sportsAPIImplementation);

    res.send({ data: odds })
}

Let’s take a closer look to the getSportsAPIImplementation method:

const apiBasketball = require('./api-basketball');
const liveScore = require('./live-score');

module.exports = {
    getSportsAPIImplementation: (sports) => {
        if(sports === "football") {
            return liveScore;
        }

        if(sports === 'basketball') {
            return apiBasketball;
        }
    }
}

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.