Post

Clean Architecture in Node.js

Transform your Node.js app to use Clean Architecture

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, and which database and ORM we should rely on.

While all these questions are valid and important, in my experience one question gets too little attention: how do we architect the application so that it is maintainable and testable? That’s where Clean Architecture (CA) comes into play.

We might argue that, if we are bootstrapping the application, we don’t need to think about maintainability and testability. There’s some truth to that. However, once we’ve mastered Clean Architecture, applying its 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 introduction to 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 on (i.e., require either directly or indirectly) any of the details. This is achieved through the 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 Architecture1

3. Building a Sample Project

Let’s imagine that we have a very simple business. We provide our clients with a list of today’s sports matches along with the win probabilities. Our customers can fetch the data via our API.

Here’s an example controller of our public API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const liveScore = require('../../live-score');

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 = async (req, res) => {
    const matches = await liveScore.getMatches();

    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 supporting two 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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);
};

const getMatches = async sport => {
    if (sport === 'basketball') {
        const basketballMatches = await apiBasketball.getMatches();

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

    const footballMatches = await liveScore.getMatches();

    return footballMatches.map(fixture => ({
        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 take steps that are as small as possible. As soon as we refactor one 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 exported via module.exports is not business logic, because the way we handle the request and the response is not related to the core of our business.

Fetching the matches and calculating the win probabilities, on the other hand, is our business. Hence, we need to ensure that it gets separated from the rest of the code. We are going to move that logic into a function called getOdds inside a folder called use-cases.

After completing the first step, lib/use-cases/get-odds-use-case.js looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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.getMatches();

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

        const footballMatches = await liveScore.getMatches();

        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:

1
2
3
4
5
6
7
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 is how it should be: 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. The use case depends on two external APIs: Live Score and API Basketball. However, according to the image in section 2, dependencies may only point inwards: a use case must not depend on external interfaces. It should be the other way around.

In order to invert 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:

1
2
3
4
5
6
7
function SportsAPI() { }

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

module.exports = SportsAPI;

As JavaScript has no interfaces, this file is documentation of the contract rather than something the compiler enforces. The implementations below are plain objects with the same shape.

4.3. Implement the SportsAPI Interface

After defining a suitable interface, we need an implementation of it for each external API.

Let’s create the file lib/sports-api-implementations/live-score.js for Live Score API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 neither implementation lives in the lib/use-cases folder, and hence the use case must not require them directly. In order to start using both implementations, we need to make a small change to the use case. The rest of the file stays as it was:

1
2
3
4
5
6
7
module.exports = {
    getOdds: async sportsAPI => {
        const matches = await sportsAPI.getMatches();

        return calculateWinProbabilities(matches);
    }
};

As you can see, instead of passing the sport (either football or basketball) to the use case, the getOdds method now takes a sportsAPI implementation 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 it passes a sportsAPI implementation as an argument.

1
2
3
4
5
6
7
8
9
10
11
12
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 at getSportsAPIImplementation, which lives in lib/sports-api-implementations/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const apiBasketball = require('./api-basketball');
const liveScore = require('./live-score');

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

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

        throw new Error(`Unknown sport: ${sport}`);
    }
};

We can see that instead of deciding in our business logic which API client to invoke, we’ve pushed that decision out of the business layer.

That’s it - we’ve achieved our goal of not having any 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 invert the dependency: the business layer depends on abstract interfaces, and all external dependencies implement those interfaces.

  1. Diagram from Robert C. Martin’s The Clean Architecture. ↩︎

© Kert Pjatkin. All rights reserved.