Building a Flexible and Scalable Node.js Backend with Express: A Step-by-Step Tutorial

Camilo Salazar
7 min readFeb 27, 2023

--

This tutorial walks through the project structure, implementing the controller, service, router and using the dependency injection pattern in a nodejs express application.

Photo by James Harrison on Unsplash

Introduction

If you’re starting a new JavaScript backend project, you’ll find this tutorial to be a practical and straightforward guide. We’ll be using the Express package to create a flexible, scalable, and maintainable project. Throughout the tutorial, I’ll guide you through the steps and share the tips and tricks I’ve learned, enabling you to set up your project quickly and efficiently. Let’s dive in and get started!

The complete code for this Node.js Express backend can be found in the following GitHub repository link, where you can explore all the files and code used to build it.

Dependencies setup

If you don’t have Node.js installed yet, simply head to their website and download the appropriate installer for your platform.

Now that you have Node.js installed, it’s time to create our project’s directory and navigate to it.

mkdir express-tutorial && cd express-tutorial

To keep track of our project’s dependencies, we can use npm to initialize a new JavaScript package by running the command npm init -y. This will generate a package.json file that contains information about our project, such as its name, version, dependencies, and other metadata.

To install the Express package, we can simply use npm and run the command npm install express. This will install the package and add it to our dependencies in the package.json file.

In addition, we will also install two more packages: body-parser and nodemon. Body-parser is a middleware that allows us to parse incoming request bodies before our handlers, making it easier to work with JSON data in requests, while nodemon is a tool that automatically restarts our server whenever changes are made to our code.

npm install body-parser && npm install -D nodemon

With Express successfully installed, we can now proceed to construct the foundation of our project’s structure.

.
├── package-lock.json
├── package.json
└── src
├── app.js
├── index.js
├── components
│ └── user
│ ├── user.controller.js
│ ├── user.entities.js
│ ├── user.module.js
│ ├── user.router.js
│ └── user.service.js
└── loaders
└── routes.js

We can modify our package.json file to make our project compatible with ECMAScript modules and add a script to start the application using nodemon.

// ./package.json
{
...
"type": "module",
"scripts": {
"start": "nodemon --inspect src/index.js"
},
...
}

With our project structure established and npm script configured, we can now move on to building our Node.js Express application. This is where the real excitement begins!

Express server setup

In the app.js file, we’ll create an Express server that will handle incoming requests and responses. We’ll also utilize the Body Parser middleware, which will parse incoming JSON requests to allow for easier manipulation of the data we receive.

// ./src/app.js

import express from 'express';
import bodyParser from 'body-parser';

const app = express();

app.use(bodyParser.json());

export default app;

In our index.js file, we’ll configure our application to listen for incoming requests on port 4000, which will be the entry point for our backend.

// ./src/index.js

import app from './app.js';

app.listen(4000, () => {
console.log('Server listening...');
});

Now that we have everything set up, we can run npm start in our terminal to start the backend server. If everything was set up correctly, we should see a message indicating that the server is listening.

> nodemon --inspect src/index.js

[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node --inspect src/index.js`
Starting inspector on 127.0.0.1:9229 failed: address already in use
Server listening...

At this point, our server is up and running, but we haven’t yet configured any routes.

Entities

To keep our application organized and modular, we’ll use the dependency injection pattern and separate our application into components. Each component will have its own module, where we’ll instantiate our controller, service, and router. We’ll also have an entities file where we’ll define the entities of our component. This will help us keep our code clean and maintainable.

First let’s define our user entity. For our example, it will be a simple object with four properties: id, email, password, and age. However, for security reasons, we’ll specify that the password property will not be included when retrieving a user entity.

// ./src/components/user.entities.js

import crypto from 'crypto';

class User {
constructor(email, password, age) {
this.id = crypto.randomUUID();
this.email = email;
this.password = password;
this.age = age;
}

toJSON() {
return {
id: this.id,
email: this.email,
age: this.age,
};
}
}

export default User;

Router

We’ll now write the router to define the URLs of our backend API and call the corresponding controller functions to handle requests.

// ./src/components/user.router.js

import express from 'express';

class UserRouter {
constructor(userController) {
this.userController = userController;
}

getRouter() {
const router = express.Router();
router.route('/:id').get(this.userController.getUser);
router.route('/').get(this.userController.getUsers);
router.route('/').post(this.userController.createUser);
return router;
}
}

export default UserRouter;

Controller

Next, we will write the controller which receives incoming requests from the router and prepares necessary parameters to call the appropriate service functions. Here, we define the logic for handling each API endpoint of our backend.

// ./src/components/user.controller.js

import User from './user.entities.js';

class UserController {
constructor(userService) {
this.userService = userService;
}

createUser = (req, res) => {
const user = new User(req.body.email, req.body.password, req.body.age);
return res.status(201).send(this.userService.addUser(user));
};

getUsers = (_, res) => res.status(200).send(this.userService.getUsers());

getUser = (req, res) => {
const { id } = req.params;
return res.status(200).send(this.userService.getUser(id));
};
}

export default UserController;

Service

Now, let’s move on to the service layer, where we define the business logic of our application. This layer is responsible for implementing the functionality required by our API endpoints, and may also handle storage-related operations. For better code organization and maintainability, we may consider separating the storage logic into a separate file following a repository pattern in the future.

// ./src/components/user.service.js

class UserService {
constructor() {
this.users = [];
}

addUser = (user) => {
this.users.push(user);
return user;
};

getUsers = () => this.users;

getUser = (id) => {
const user = this.users.find((u) => u.id === id);
return user;
};
}

export default UserService;

Module

In the dependency injection module, we’ll instantiate all the components, including the router, controller, and service, and inject the necessary dependencies. This approach makes it easier to manage the components of our application and makes it more modular, which is beneficial for scalability and maintainability. Additionally, using dependency injection allows us to easily switch out components as needed, making our codebase more flexible and adaptable to changes in the future.

// ./src/components/user.module.js

import UserController from './user.controller.js';
import UserService from './user.service.js';
import UserRouter from './user.router.js';

const userService = new UserService();
const userController = new UserController(userService);
const userRouter = new UserRouter(userController);

export default {
service: userService,
controller: userController,
router: userRouter.getRouter(),
};

Integrating our new user module with our Express application is the final step. Here, we bring together the components we’ve defined to handle incoming requests and respond to them appropriately.

// ./src/loaders/routes.js

import userModule from '../components/user/user.module.js';

export default (app) => {
app.use('/users', userModule.router);
};

Test the application

If you want to delve into unit testing and end-to-end testing, I recommend checking out my tutorial on Jest and Supertest.

To test our application, we can start by making a POST request to http://localhost:4000/users to create a new user.

curl --location 'http://localhost:4000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "email2@example.com",
"password": "123456",
"age": 15
}'

After sending the POST request to create a new user, we should receive a response that confirms the successful creation of the user. This response contain information about the user, such as their unique ID, email, age and have a status code of 201.

{
"id": "1ceec5e3-8f96-4b96-bdea-6df227d31ec3",
"email": "email2@example.com",
"age": 15
}

Note that we won’t receive the password property in the response for security reasons.

We can now list our users by making a GET request to http://localhost:4000/users.

curl --location 'http://localhost:4000/users'

We should receive a response similar to this:

[
{
"id": "1ceec5e3-8f96-4b96-bdea-6df227d31ec3",
"email": "email2@example.com",
"age": 15
}
]

We can also retrieve a specific user by making a GET request to http://localhost:4000/users/:id, where “:id” is the unique identifier of the user we want to retrieve.

curl --location 'http://localhost:4000/users/1ceec5e3-8f96-4b96-bdea-6df227d31ec3'

We should receive a response similar to this:

{
"id": "1ceec5e3-8f96-4b96-bdea-6df227d31ec3",
"email": "email2@example.com",
"age": 15
}

Conclusion

And that’s it, we have created a simple yet effective Node.js backend application using the Express framework. We have covered the basic concepts of routing, controllers, services, and dependency injection, which are fundamental for building scalable and maintainable applications.

If you found this tutorial helpful, be sure to follow me for more content like this, and feel free to leave comments and suggestions for future topics you’d like me to cover. Some potential ideas for future extensions could include error handling, incorporating an ORM like Prisma, or adding authentication and authorization to our application.

--

--

Camilo Salazar

Driven to share powerful ideas and experiences that ignite inspiration, deepen knowledge, and empower others