Clean Architecture in Node.js

Ben Mishali
12 min readMar 7, 2023

--

Introduction

Clean Architecture is a software development concept that focuses on creating modular and maintainable code. The architecture allows the developer to create a codebase that is easy to understand, test, and extend. It emphasizes on separating the core business logic of the application from the infrastructure details. In this article, we will discuss Clean Architecture in Node.js, and explore how to implement it in your Node.js projects with code samples and how applying the concepts on them.

Identifying and Solving Problems

When developing Node.js applications, we often face some problems such as code duplication, unorganized codebase, and tight coupling. In the next section we use a simple example of a Node.js application that retrieves data from a database and returns it as a JSON response to solve these problems. Clean Architecture provides solutions to these problems by emphasizing on the following principles:

Separation of Concerns (SoC)

A fundamental principle of software engineering that emphasizes the importance of dividing a system into distinct modules or components, each with a specific and independent responsibility. In Node.js, SoC is often implemented using a modular architecture, where each module is responsible for a single task or functionality.

Here’s an example of how Separation of Concerns can be applied to this code:

// index.js - BAD CODE !!!!

const express = require('express')
const app = express()
const database = require('./database')

app.get('/users', async (req, res) => {
try {
const users = await database.getUsers()
res.json(users)
} catch (error) {
res.status(500).send('Internal Server Error')
}
})

app.listen(3000, () => {
console.log('Server is listening on port 3000')
})

In this code, we have mixed the concerns of routing, data retrieval, and error handling into a single file. This can make the code difficult to maintain and scale as the application grows.

Here’s how we can apply Separation of Concerns to this code:

// index.js

const express = require('express')
const app = express()
const usersRouter = require('./routes/users')

app.use('/users', usersRouter)

app.listen(3000, () => {
console.log('Server is listening on port 3000')
})
// routes/users.js

const express = require('express')
const router = express.Router()
const usersController = require('../controllers/users')

router.get('/', usersController.getUsers)

module.exports = router
// controllers/users.js

const database = require('../database')

async function getUsers(req, res) {
try {
const users = await database.getUsers()
res.json(users)
} catch (error) {
res.status(500).send('Internal Server Error')
}
}

module.exports = {
getUsers,
}

In this refactored code, we have separated the concerns of routing, controller, and data retrieval into three separate files. The index.js file is responsible for creating the Express app and registering the routes, the routes/users.js file is responsible for handling the /users route and delegating the request to the usersController, and the controllers/users.js file is responsible for retrieving the data from the database and returning the response to the client.

By separating these concerns, we can achieve better modularity and maintainability of the code. For example, if we want to add more routes, we can create a new file in the routes directory and register it in the index.js file without modifying the existing code. Similarly, if we want to change the way data is retrieved from the database, we can modify the database.js file without modifying the route or controller code.

Dependency Injection (DI)

In Clean Architecture, dependencies are injected, which means the higher-level modules should depend on lower-level modules. This ensures that the business logic is not coupled with the infrastructure details.

Here’s an example of how Dependency Injection can be applied to this code:

// index.js - BAD CODE !!!

const express = require('express')
const app = express()

const usersController = require('./controllers/users')
app.get('/users', async (req, res) => {
try {
const users = await usersController.getUsers()
res.json(users)
} catch (error) {
res.status(500).send('Internal Server Error')
}
})

app.listen(3000, () => {
console.log('Server is listening on port 3000')
})
// controllers/users.js

const database = require('../database')
async function getUsers() {
try {
const users = await database.getUsers()
return users
} catch (error) {
throw new Error('Error retrieving users')
}
}

module.exports = {
getUsers,
}

In this code, the index.js file depends directly on the usersController module, which in turn depends directly on the database module. This violates the Dependency Injection principle because high-level modules should not depend on low-level modules.

Here’s how we can apply Dependency Injection to this code:

// index.js

const express = require('express')
const app = express()
const usersController = require('./controllers/users')
const database = require('./database')

app.get('/users', async (req, res) => {
try {
const users = await usersController.getUsers(database)
res.json(users)
} catch (error) {
res.status(500).send('Internal Server Error')
}
})

app.listen(3000, () => {
console.log('Server is listening on port 3000')
})
// controllers/users.js

async function getUsers(database) {
try {
const users = await database.getUsers()
return users
} catch (error) {
throw new Error('Error retrieving users')
}
}

module.exports = {
getUsers,
}

In this refactored code, we have applied Dependency Injection by passing the database module as a parameter to the getUsers function in the usersController module. This way, the high-level index.js module does not depend directly on the low-level database module, but both depend on the abstraction of the getUsers function.

By applying Dependency Injection, we can achieve better modularity, flexibility, and testability of the code. For example, if we want to switch to a different database implementation, we can create a new module that implements the getUsers function and pass it as a parameter to the getUsers function in the usersController module without modifying the existing code. Similarly, we can easily mock the database module for unit testing purposes.

Single Responsibility Principle (SRP)

Each module or function should have a single responsibility. This helps in creating a codebase that is easy to understand and maintain.

Here’s an example in Node.js that illustrates the SRP principle:

// user.js - BAD CODE !!!

class User {
constructor(name, email, password) {
this.name = name;
this.email = email;
this.password = password;
}

saveToDatabase() {
await database.saveUser(user)
.then(() => {
res.status(200).send()
})
.catch(err => {
res.status(400).send('Email Error')
});
}

sendWelcomeEmail() {
await mailService.send(user, 'Welcome')
.then(() => {
res.status(200).send()
})
.catch(err => {
res.status(400).send('Email Error')
});
}
}

In this example, we have a User class that has two responsibilities:

  1. Saving user data to a database
  2. Sending a welcome email to the user

This violates the SRP principle because the User class has more than one reason to change. For example, if we decide to change the way we save user data to a database, we would also have to modify the User class, even though that's not related to sending a welcome email.

To follow the SRP principle, we can separate these responsibilities into separate classes:

class User {
constructor(name, email, password) {
this.name = name;
this.email = email;
this.password = password;
}
}

class UserRepository {
saveToDatabase(user) {
await database.saveUser(user)
.then(() => {
res.status(200).send()
})
.catch(err => {
res.status(400).send('Email Error')
});
}
}

class EmailService {
sendWelcomeEmail(user) {
await mailService.send(user, 'Welcome')
.then(() => {
res.status(200).send()
})
.catch(err => {
res.status(400).send('Email Error')
});
}
}
}

Now we have three classes, each with a single responsibility:

  1. User class: responsible for representing a user.
  2. UserRepository class: responsible for saving user data to a database.
  3. EmailService class: responsible for sending a welcome email to the user.

By separating the responsibilities, we have made our code more modular and easier to maintain. If we need to change the way we save user data to a database, we only need to modify the UserRepository class. The User class and EmailService class remain unchanged.

Concepts & Detailed Design

Clean Architecture in Node.js is based on the principles of Clean Architecture introduced by Robert C. Martin (aka Uncle Bob). It emphasizes the separation of concerns, decoupling of dependencies, and modularity. The goal is to create a codebase that is easy to understand, test, and maintain.

The main idea behind Clean Architecture is to divide the application into different layers, where each layer has a specific responsibility. The layers communicate with each other through well-defined interfaces. This allows for the easy modification and testing of the application without affecting other parts of the codebase.

Clean Architecture — PXPGraphics

Node.js is a popular runtime environment for building web applications. It has a vast ecosystem of libraries and frameworks that can be used to implement Clean Architecture. Here are some of the key concepts of Clean Architecture in Node.js:

Infrastructure Layer

The infrastructure layer is responsible for handling external dependencies, such as databases, APIs, or filesystems. It should be decoupled from the domain layer to allow for easy testing and modification. The infrastructure layer should implement interfaces defined by the domain layer.

In Node.js, the infrastructure layer can be implemented using packages or modules. For example, you can use the popular package Knex.js to handle database queries. The infrastructure layer should be designed to be pluggable, allowing for easy replacement of external dependencies.

Here’s an example of an infrastructure module that implements a database adapter:

const knex = require('knex');

class UserDatabase {
constructor(config) {
this.db = knex(config);
}

async getById(id) {
const data = await this.db('users').where({ id }).first();
return data ? User.create(data) : null;
}

async save(user) {
const data = User.toData(user);
const { id } = user;

if (id) {
await this.db('users').where({ id }).update(data);
} else {
const [newId] = await this.db('users').insert(data);
user.id = newId;
}
}
}

This module provides methods to get a user by ID and save a user to the database using the Knex.js library.

Presentation Layer

The presentation layer is responsible for displaying the output of the application to the user and handling user input. It should be decoupled from the application layer and the infrastructure layer. The presentation layer can be implemented using web frameworks like Express.js or Hapi.js.

In Node.js, the presentation layer can be implemented using web frameworks or modules. Web frameworks provide a powerful and flexible way to implement the presentation layer and host all logic together.

Here’s an example of a presentation module implementing a REST API using the Express.js web framework:

const express = require('express');
const bodyParser = require('body-parser');
const UserService = require('./services/user-service');
const UserDatabase = require('./infra/user-database');

const app = express();

app.use(bodyParser.json());

const userDatabase = new UserDatabase(config);
const userService = new UserService(userDatabase);

app.get('/users/:id', async (req, res) => {
const { id } = req.params;
try {
const user = await userService.getUserById(id);
res.json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
});

app.put('/users/:id', async (req, res) => {
const { id } = req.params;
const { userData } = req.body;
try {
let user = await userService.getUserById(id);
user = User.create({ ...user, ...userData });
await userService.saveUser(user);
res.json(user);
} catch (error) {
res.status(404).json({ error: error.message });
}
});

This module creates an Express.js application, sets up the user service and database, and provides REST API endpoints to get and update user data.

Application Layer

The application layer is responsible for orchestrating the interaction between the domain layer and the infrastructure layer. It contains the use cases of the application, which represent the interactions between the user and the system. The application layer should be decoupled from the domain layer and the infrastructure layer.

In Node.js, the application layer can be implemented using classes or modules. Classes provide a clear separation of concerns and encapsulation. Modules provide a simpler approach to implement the application layer.

Here’s an example of an application class representing a user service:

class UserService {
constructor(userDatabase) {
this.userDatabase = userDatabase;
}

async getUserById(id) {
const user = await this.userDatabase.getById(id);
if (!user) {
throw new Error(`User not found with ID ${id}`);
}
return user;
}

async saveUser(user) {
await this.userDatabase.save(user);
}
}

This class provides methods to get a user by ID and save a user, using the UserDatabase infrastructure module.

Domain (Enterprise) Layer

The domain layer is the heart of the application, containing the business logic and rules. It should be independent of any external libraries or frameworks, making it easy to test and modify. The domain layer is the most critical part of the application, and any changes should be made with great care.

In Node.js, the domain layer can be implemented using classes or modules. Classes provide a clear separation of concerns, encapsulation, and reusability. Modules, on the other hand, provide a simpler approach to implement the domain layer.

Here’s an example of a domain class representing a user entity:

class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}

changeName(name) {
this.name = name;
}

changeEmail(email) {
this.email = email;
}

static create(data) {
const { id, name, email } = data;
return new User(id, name, email);
}

static toData(user) {
const { id, name, email } = user;
return { id, name, email };
}
}

This class encapsulates the user data and provides methods to change the user’s name and email.

Using Third-Party Packages

Using third-party packages is a great way to increase the cleanliness of your code. There are many packages available that can help you to organize your codebase and make it more modular. For example, you can use a package like Express.js to handle routing and middleware. You can also use a package like Knex.js to handle database queries. Using these packages can help you to reduce code duplication and make your codebase more organized.

Here are a few more examples of using third-party packages:

Lodash

Lodash is a utility library that provides a wide variety of functions for working with arrays, objects, and other data structures. By using Lodash, you can avoid writing repetitive code for tasks such as filtering, sorting, and transforming data.

For example, consider the following code for filtering an array of users by age:

const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 35 },
];

const filteredUsers = users.filter(user => user.age >= 30);

Using Lodash, you could instead write the following code:

const _ = require('lodash');

const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 35 },
];

const filteredUsers = _.filter(users, user => user.age >= 30);

This code is more concise and easier to read, and the use of the filter function from Lodash makes it clear what the code is doing.

Moment.js

Moment.js is a library for parsing, manipulating, and formatting dates and times. By using Moment.js, you can avoid writing complex date manipulation code and reduce the likelihood of errors.

For example, consider the following code for formatting a date as a string:

const date = new Date();
const formattedDate = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;

Using Moment.js, you could instead write the following code:

const moment = require('moment');
const date = new Date();
const formattedDate = moment(date).format('YYYY-MM-DD');

This code is easier to read and maintain, and the use of the format function from Moment.js makes it clear what the code is doing.

Winston

Winston is a logging library that provides a variety of features for logging messages, including different log levels, log file rotation, and customizable formatting. By using Winston, you can avoid writing custom logging code and ensure that your logs are consistent and easy to read.

For example, consider the following code for logging a message:

console.log(`[${new Date().toISOString()}] INFO: User logged in`);

Using Winston, you could instead write the following code

const winston = require('winston');

const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(info => `[${info.timestamp}] ${info.level}: ${info.message}`)
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'app.log' }),
],
});
logger.info('User logged in');

This code is more configurable and easier to read, and the use of Winston makes it clear what the code is doing.

Conclusion

Clean Architecture is a powerful concept that can help you to create modular and maintainable code in your Node.js projects. By following the principles of Clean Architecture, you can create a codebase that is easy to understand, test, and extend. You can also use third-party packages and different folder structures to increase the cleanliness of your code. Implementing Clean Architecture in your Node.js projects can help you to avoid common problems such as code duplication, unorganized codebase, and tight coupling.

Visit me at ben.dev.io

--

--

Ben Mishali

𝔹𝕖𝕟 | 𝕊𝕠𝕗𝕥𝕨𝕒𝕣𝕖 𝔼𝕟𝕘𝕚𝕟𝕖𝕖𝕣 🕵‍♂️ Technologies Explorer 👨🏻‍💻 Web & Native developer 🤓 Sharing my knowledge and experiences