Clean Architecture in ExpressJS Applications.png

Clean Architecture in ExpressJS Applications (NodeJS)

Hassib Moddasser

By Hassib Moddasser

Last update on Jun 26, 2023 · 9 minutes


I can't emphasize enough how learning and implementing the Clean Architecture in all our projects has saved—and is saving—us so much time in the development of new features, the testability of the system, and the general maintenance of their components.

Implementing it will require a little effort in understanding how it works and will also generate a bit more files than just following, for example, the ExpressJS Getting Started guide.

However, we will never go back. It served us so robustly that we'll just keep focusing on shipping.

Now, let's start.

Introduction

The secret to building a large project that is easy to maintain and performs better is to separate files and classes into components that can change independently without affecting other components: this is what Clean Architecture is all about.

Clean Architecture is an architectural style created by Robert C. Martin. It is a set of standards that aims to develop an application that makes it easier to quality code that will perform better, is easy to maintain, and has fewer dependencies as the project grows.

Firstly, I will introduce what Clean Architecture is. We will then head to the development side of things to see how the engineering team at Merlino Software Agency developed OneFood and Isla using NodeJS/ExpressJS with a Clean Architecture. In the end, we will conclude how Clean Architecture makes a difference.

According to Stack Overflow Insights of 2021, JavaScript completes its ninth year in a row as the most commonly used programming language. NodeJS is placed as the sixth most widely used tool, and Express JS, a minimal Node JS framework, got the third position as the most common web framework. Therefore, having your application in Express JS decorated with Clean Architecture is a blessing that leads to better performance and easy maintainability of the project.

Let us have a quick introduction to Software Architecture before diving into it!

What is Software Architecture

Before providing a definition, consider that the actions people may do using systems on a daily basis — scheduling a Tweet to post, sending an automated transactional email, etc. — heavily depend on the Software Architecture of the system in use.

Many things we know and use would be impossible without software architecture, but why is that?

A system's architecture acts as a blueprint. It establishes a communication and coordination mechanism among components, gives an abstraction to control system complexity, and represents the design decisions related to overall system structure and behavior.

In simple words, Software Architecture exposes a system's structure while hiding implementation details. Moreover, it also focuses on how the elements and components within a system interact with one another.

The subject of Software Architecture appears to have sparked much attention in recent years. Furthermore, among the many different types and styles, Clean Architecture attracted more attention than the others.

Let us head to the central part of the article and find out what Clean Architecture is.

The Clean Architecture

Microservices and serverless architecture have emerged as revolutionary architectural patterns in the software development world during the last several years. Each has its characteristics, behaviors, technological structure, and required expertise. However, they are all part of a broader desire for a better separation of architectural concerns and improved testability.

Clean Architecture is a set of standards that aims to develop layered architectures that make it easier to write quality code that will perform better, is easy to maintain, and has fewer dependencies as the project grows. This architecture can be applied no matter what language you code in.

The illustration below shows the four concentric circles that compose a Clean Architecture, how they interact with each other and their dependencies.

Robert C. Martin Clean Architecture DiagramRobert C. Martin Clean Architecture Diagram

The Dependency Rule

Clean Architecture DependenciesClean Architecture Dependencies

In the illustration above, please pay attention to the arrows, the fact that they are pointing from the outermost circle down into the innermost circle and that they only go in one direction. The arrows represent the dependence flow, indicating that an outer ring can depend on an inner ring but that an inner ring cannot rely on an outer ring; this is what The Dependency Rule is all about.

For example, Entities know nothing about all the other circles, while the Frameworks & Drivers, the outer layer, know everything about the inner rings.

In simple words, variables, functions, classes, or any other named software entity declared in an outer circle must not be mentioned by the code in an inner circle.

Four Layers of Clean Architecture

There are four concentric circles in Clean Architecture that each represents different areas of Software, which are as below:

  1. Entities
  2. Use Cases
  3. Interface Adapters
  4. Frameworks and Drivers

The development team of Merlino also implemented another layer called Routes, and that's totally okay. If you find that you may need more than four, just go on. However, the Dependency Rule must always apply.

So, the four layers become five layers in our case study, which are as below:

  1. Entities (Mongoose models and their abstraction)
  2. Use Cases
  3. Interface Adapters (Controllers)
  4. Routes
  5. Frameworks and Drivers

Let us find out what each circle means along with our case study, but before diving in, here is the project's hierarchy;

plaintext|1.2│   README.md3│   package.json  4│   ...   56└─── app7│   │   app.js // 5. Framework and Drivers8│   │   server.js // start master and forks other workers9│   │   config.js // get config from env or config file10│   │   database.js11│   │   logger.js12│   │13│   └─── models // 1.a) entities (i.e., Mongoose models)14│   │   │   index.js15│   │   │   orders.model.js16│   │   │   ...17│   │18│   └─── data-access // 1.b) entities (i.e., abstraction of Mongoose models)19│   │   │   index.js20│   │   │   orders.db.js21│   │   │   ...22│   │23│   └─── use-cases // 2) Use cases24│   │   └─── orders25│   │   │   │   index.js26│   │   │   │   post-order.js27│   │   │   │   ...28│   │   └─── ...29│   │30│   └─── controllers // 3) Interface Adapters31│   │   │   order.controller.js32│   │   │   ...33│   │34│   └─── routes // 4) Routes35│   │   │   order.routes.js36│   │   │   ...3738└─── ...

1. Entities

At the center of the onion are the Entities of the software, which constitute the business logic of software. An entity can be an object with methods, or it can be a set of data structures and functions, they don't know anything about the outer layers and don't have any dependency.

In simple words, Entities are the primary concepts of your business.

When something external happens, Entities are the least likely to change. A change to page navigation or security, for example, would not be expected to affect these objects. The entity circle should not be affected by any operational changes to any application.

If you're building a Social Media application, you might have entities like Posts, Comment, or User, or in the case of OneFood — a premium food delivery business and a client of ours — a few of their entities would be Customer, Order, Payment, Product, Vendor, etc.

Important to consider is that since we use Mongoose, we'll also have model definitions, which are specific to this implementation and we'll soon see how to abstract them. All models are then exported like follows:

All Entities can then be exported like this:

javascript|1// /models/index.js2
3import Orders from './orders.model';4// ...5
6export {7  Orders,8  // ...9};

Then, in `data-access`, let's create an abstraction layer that allows us to decouple the database methods that we call in our application (i.e., `findOne`, insert`, etc.) from the type of database, in this case, MongoDB. We might want to change to a SQL database in the future and that'll be totally fine. One could argue that this is what Prisma does as a service.

javascript|1// /data-access/orders.db.js2
3// outside libraries are ok to import4import {v4 as uuid} from "uuid";5import _ from "lodash";6
7export default function makeOrdersDb({Orders}) {8  async function findOne(_filter, _options = {}) {9    const {populate, sort} = _options;10    const query = Orders.findOne(_filter);11    if (sort) query.sort(sort);12    _.forEach(populate || [], (p) => query.populate(p));13    return query.lean().exec();14  }15  async function insert({id: _id = uuid(), ...orderInfo}) {16    return Orders.create({_id, ...orderInfo});17  }18  async function update(_filter, _orderInfo) {19    return Orders.findOneAndUpdate(_filter, _orderInfo, {new: true});20  }21  async function remove(_id) {22    const res = await Orders.deleteOne({_id});23    return {24      found: res.n,25      deleted: res.deletedCount26    };27  }28  async function find(_filter, _options = {}) {29    const {populate} = _options;30    const query = Orders.find(_filter);31    if (populate) _.forEach(populate || [], (p) => query.populate(p));32    return query.lean().exec();33  }34  async function aggregate(pipeline = []) {35    return Orders.aggregate(pipeline);36  }37  async function paginate(_query, _options) {38    const {sort, populate, page, limit} = _options;39    return Orders.paginate(_query, {40      sort,41      lean: true,42      page,43      limit,44      populate45    });46  }47  return Object.freeze({48    findOne,49    insert,50    update,51    remove,52    find,53    aggregate,54    paginate55  });56}

Several of these files would then be exported in their index.js like follows:

javascript|1// /data-access/index.js2
3// your DB model definitions, in OneFood's case they are Mongoose files4import * as models from "../models";5
6import makeOrdersDb from "./orders.db";7
8const ordersDb = makeOrdersDb(models);9
10export {11  ordersDb,12  ...13};

As you can see we are making the models available to their abstraction layer through Dependency Injection. Somewhere upstream, we have some code that will call the `

ordersDb.findOne()
` function and all the various dependencies required (i.e., `models`) will be automatically available.

Dependency Injection has several advantages described below:

  • The first advantage is Testability. By injecting dependencies, we can test our code independently of anything else.
  • The other advantage is as the developer is writing this code, they do not need all of these dependencies to be ready. In this case, the developer can focus on the business logic of the Entity.
  • The most important advantage of doing this is that the developer can change the implementation details of the project dependencies independently of this code.

2. Use Cases

The Use Cases layer, which lies outside the Entities layer, contains login and rules related to the behavior and design of the system.

In simple words, Use Cases are interactions between Entities. For example, suppose we are in our Social Media application example. In that case, we can have a Use Case like user posts, or in the OneFood application, a customer places an order.

Changes to this layer should not affect the entities. Changes to externalities such as the database, user interface, or frameworks are unlikely to affect this layer.

Let us check out the /use-cases directory and look at the Order's definition:

javascript|1// /use-cases/orders/post-order.js2
3export default function makePostOrder({4  ordersDb,5  orderItemsDb,6  getPromoCodeDetails7}) {8  return async function postOrder({body, headers, user}) {9    10    // Validate parameters...11    // Create order...12    // Create order items...13
14    return order;15  };16}

All Orders Use Cases can then be exported like this:

javascript|1// /use-cases/orders/index.js2
3import {4  ordersDb,5  // ...6} from 'data-access';7
8import Config from 'config';9
10import makePostOrder from './post-order';11// ...12
13const postOrder = makePostOrder({14  ordersDb,15  Config,16  // other injected dependencies ...17});18
19// Export a service containing all Use Cases ...20const orderService = Object.freeze({21  postOrder,22  // ...23});24export default orderService;25
26// ... and also every Use Case singularly27export {28  postOrder,29  // ...30};

3. Interface Adapters

The Interface Adapters or the Adapter layer holds the controllers, APIs, and gateways. The Interface Adapters govern the flow of communication between external components and the system's back-end.

In simple words, Interface Adapters are isolating our various Use Cases from the tools that we use

Let us look at the controllers directory and choose OrderController by sample to see what's going on:

javascript|1// /controllers/order.controller.js2
3import {4  postOrder,5  // ...6} from 'use-cases/orders';7
8import {9  postPaymentLink10} from 'use-cases/payments';11
12export default Object.freeze({13  postOrder: (httpRequest) => postOrder(httpRequest, postPaymentLink),14  // ...15});

4. Routes

The /routes folder is where you can organize all of your different REST endpoints declarations. The file below exposes all available endpoints related to the Orders Entity:

javascript|1// /routes/order.routes.js2
3import express from 'express';4
5import controller from 'controllers/api/orderController';6import makeExpressCallback from 'routes/make-callback';7
8const router = express.Router();9
10router.route('/order').post(makeExpressCallback(controller.postOrder));11router.route('/order/:orderId').get(makeExpressCallback(controller.getOrderById));12// ...13
14export default router;

Bonus: you may have noticed the makeExpressCallback function in the code. That is a wrapper function that:

  • receives req and res as inputs from Express's .post(), .get(), etc. functions of each endpoint.
  • creates a clean request object with all possible useful information needed by controllers and feeds them with it (i.e., controller.postOrder);
  • handles whatever it's returned by controllers (i.e., data or errors), and replied to clients in a standardized way.

Check this file out:

javascript|1import * as Sentry from '@sentry/node';2
3export default (controller) => (req, res) => {4  const httpRequest = {5    body: req.body,6    query: req.query,7    params: req.params,8    ip: req.ip,9    method: req.method,10    path: req.path,11    user: req.user,12    logger: req.logger,13    source: {14      ip: req.ip,15      browser: req.get('User-Agent')16    },17    headers: {18      'Content-Type': req.get('Content-Type'),19      Referer: req.get('referer'),20      'User-Agent': req.get('User-Agent')21    }22  };23
24  // req.user coming from 'policies/token.js',25  // after the JWT token is parsed26  if (req.user) {27    Sentry.setUser(req.user);28  } else {29    Sentry.configureScope((scope) => scope.setUser(null));30  }31
32  controller(httpRequest)33    .then((httpResponse) => {34      res.set('Content-Type', 'application/json');35      res.type('json');36      const body = {37        success: true,38        code: 200,39        data: httpResponse40      };41      res.status(200).send(body);42    })43    .catch((e) => {44      Sentry.captureException(e);45      res.status(400).send({46        success: false,47        code: 400,48        error: {49          description: e.message50        }51      });52    });53};

Then, let's export all routes:

javascript|1// /routes/index.js2
3import express from 'express';4import token from 'policies/token';5import orders from './orders';6// ...7
8const router = express.Router();9
10// Bonus: you can split this /routes folder in 2: private and public.11// In the private index.js file you would precede all routes declaration12// with a function that checks if the authentication token is present13// in all requests and it's valid.14router.use(token);15router.use(orders);16
17export default router;

5. Frameworks and Drivers

The Frameworks and Drivers, also known as the Infrastructure Layer, is the outermost layer that provides all necessary details about frameworks, drivers, and tools such as Databases that we use to build our application. All the details of the system go in this layer.

For example, in the case of the OneFood application, the engineering team used Express JS as the framework and Mongoose (MongoDB) for Node JS as the database framework.

The entry point to the entire application is the app.js file. This is where we use the Express JS framework. You will see many import statements, initialization of Express server, using the routes middleware, and finally exporting the app module.

javascript|1// app.js2
3import express from 'express';4import routes from "routes";5// other imports6
7// initialize express server8const app = express();9
10app.use("/", routes);11
12export default app;

Conclusion

Following these guidelines is simple and will save you from a lot of trouble in the future. By separating Software into layers and obeying the Dependency Rule, you will create Software that is more:

  • Testable
  • Maintainable
  • Independent of a framework
  • Independent of a database
  • Independent of UI
  • ... and independent of any other tools and drivers

Keep in mind that there's no boundary of having just four circles/layers. If you find that you may need more than four, just go on. However, the Dependency Rule must always apply.

What do you think about this article?

Whether it was helpful or not, we would love to hear your thoughts! Please leave a comment below.