Clean Architecture in ExpressJS Applications.png

Clean Architecture in ExpressJS Applications

Hassib Moddasser

By Hassib Moddasser

Published on Dec 12, 2021 · 8 min read


Introduction

You may have previously heard of the term Clean Architecture quite a lot, especially if you are in a development team, or maybe surfing the web, reading articles, or even getting recommended on YouTube to watch some videos. Still, you did not exactly grasp the concept. Do not worry; you are not alone; it was unclear to me what Clean Architecture was and how to use and leverage it.

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

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.

This article will be different from any other article you can find on the web because I've included a real-life scenario. 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 the OneFood application using ExpressJS with a Clean Architecture. In the end, we will conclude how Clean Architecture makes a difference. OneFood is a premium food delivery Startup currently operating in Italy, Germany, and Austria.

According to Stack Overflow Insights of 2021, JavaScript completes its ninth year in a row as the most commonly used programming language. Node JS placed in the sixth 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, the 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 architecture 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.

The Dependency Rule

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 becomes five layers in our case study, which are as below:

  1. Entities
  2. Use Cases
  3. Interface Adapters
  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.md
3│ package.json
4│ ...
5
6└─── app
7│ │ app.js
8│ │ config.js
9│ │ database.js
10│ │ logger.js
11│ │ server.js
12│ │
13│ └─── controllers
14│ │ │ order.controller.js
15│ │ │ ...
16│ │
17│ └─── data-access
18│ │ │ index.js
19│ │ │ order.db.js
20│ │ │ ...
21│ │
22│ └─── entities
23│ │ │ order.entity.js
24│ │ │ ...
25│ │
26│ └─── routes
27│ │ │ order.routes.js
28│ │ │ ...
29│ │
30│ └─── use-cases
31│ │ └─── orders
32│ │ │ │ index.js
33│ │ │ │ post-order.js
34│ │ │ │ ...
35│ │ └─── ...
36
37└─── ...

1. Entities

At the center of the onion are the Entities of the software, which constitutes 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 they don't have any dependency. They encapsulate the most general and high-level rules that the application would use.

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.

Let us jump to the code and look at what an Order entity would look like in the OneFood application, declared as a Mongoose schema:

javascript
1// /entities/order.entity.js
2
3import mongoose from 'mongoose';
4import mongoosePaginate from 'mongoose-paginate-v2';
5
6// Import other schemas from the same folder...
7
8const {Schema} = mongoose;
9const min = [0.0, 'price can not be less then {MIN}.'];
10let OrdersSchema = new Schema({
11 _id: {type: String, required: true},
12 orderNumber: {type: String, unique: true},
13 customerId: {type: String, required: true, ref: 'Customers'},
14 status: {
15 type: String,
16 required: true,
17 enum: ['open', 'pending', 'shipping', 'paid', 'failed', 'expired', 'canceled']
18 },
19 paymentStatus: {
20 type: String,
21 default: 'open',
22 enum: ['open', 'pending', 'canceled', 'authorized', 'expired', 'failed', 'paid']
23 },
24 // etc
25}, {timestamps: true, collection: 'orders'});
26
27// Virtuals...
28// Other utility functions...
29
30OrdersSchema.plugin(mongoosePaginate);
31
32// save
33OrdersSchema = setOrderModelHook('save', OrdersSchema);
34export default mongoose.models.Order || mongoose.model('Orders', OrdersSchema);

The first thing you will probably notice is that, except for mongoose and mongoose-paginate-v2, there are no other imports statements at the top of our code. Typically, when you open a JavaScript module, you will see a bunch of required or import statements. Still, in this case, we are not importing anything because this is an entity, and if you remember from the diagram, Entities are in the center and have no dependency.

All Entities can then be exported like this:

javascript
1// /entities/index.js
2
3import Orders from './orders.entity';
4// ...
5
6export {
7 Orders,
8 // ...
9};

But how can we use other libraries or tools within our Entity? 

The answer is Dependency Injection. Somewhere upstream, we have some code that will call the buildMakeOrder function and inject all the various dependencies required.

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 in the OneFood application and look at the Order’s entity interactions:

javascript
1// /use-cases/orders/post-order.js
2
3export default function makePostOrder({
4 ordersDb,
5 orderItemsDb,
6 getPromoCodeDetails
7}) {
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 Use Cases can then be exported like this:

javascript
1// /use-cases/orders/index.js
2
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});
24
25// ... and also every Use Case singularly
26export default orderService;
27export {
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 OneFood application’s controllers directory and choose OrderController by sample to see what’s going on:

javascript
1// /controllers/order.controller.js
2
3import {
4 postOrder,
5 // ...
6} from 'use-cases/orders';
7
8import {
9 postPaymentLink
10} 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.js
2
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() and .get() functions defining each endpoint.
  • creates a clean request object with all possible useful information needed by controllers and feed 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 parsed
26 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: httpResponse
40 };
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.message
50 }
51 });
52 });
53};

Then, let's export all routes:

javascript
1// /routes/index.js
2
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 declaration
12// with a function that checks if the authentication token is present
13// 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 MongoDB driver for Node JS as a database driver.

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.js
2
3import express from 'express';
4// other imports
5
6// initialize express server
7const app = express();
8
9export 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 a 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.