Published

9 best practices for building Node.js API

18 minutes read

Introduction

Node.js is a good tool for building REST API. It is one of the most popular techs among devs. If you are a Node.js developer or are learning Node.js to build REST APIs, this post is for you.

This post describes a set of best practices for building Node.js APIs with the Express framework.

Applying patterns and best practices in coding is essential. Patterns increase code readability and ease software support.

The set of best practices described below is the result of my experience. I have experience building Node.js applications and teaching web development. And a lot of readings of technical books and blog posts.

The complete code of all good practice examples is in this GitHub repository

1 - Layered architecture

Use the principle of separation of concerns. Segregating an application code into three-tier architecture: API routes, Services, and Models.

Node.js three-tier architecture
  • API routes - contains all the API routes of the app. It is responsible too for handling the HTTP requests and HTTP responses.
  • Services - use this layer to put your business logic and for integrating the data layer and the API routes layer.
  • Models - define the access to the data and database entities.

2 - Project structure

In Node.js applications, are generally used two types of directory structure:

  • division by component
  • division by technical role

In the division by component, the files are grouped by feature directory. See the example below:

projetc-structure
src/
  ├── courses/
        ├── courses.service.js
        ├── course.model.js
        ├── courses.routes.js
  ├── students/
        ├── students.service.js
        ├── student.model.js
        ├── students.routes.js
  ├── app.js

Structuring by technical roles, the files are grouped in the folders by layers, according to your responsibility. As you can see in the example below:

projetc-structure
src/
  ├── services/
        ├── courses.js
        ├── students.js
  ├── model/
        ├── course.js
        ├── student.js
  ├── routes/
        ├── index.js
        ├── courses.js
        ├── students.js
  ├── app.js

Between these two approaches, I recommend structuring by technical role layer. Yet, there is not the correct option. It is up to each project team to discuss and choose a default structure.

Based on structured by technical role layer, I organize my projects as follows:

projetc-structure
├── server.js        the entry point for starting the server (network configurations)
src/
  ├── app.js         Express(http-server) config
  ├── api/           Express route handlers for all the endpoints of the app
  ├── config/        environment configurations
  ├── services/      service layer with the business logic
  ├── models/        model layer with database entities
test/
  ├── unit/          all the unit tests of the app
  ├── integration/   all the integration tests of the app
├── package.json     main project configuration file
├── ...              others config files (.gitignore, jest.config, .sequelizerc, etc)

I recommend separating Express definitions from HTTP network configuration. I usually named app for Express definition and server for HTTP network configs and server startup (see example above). The server file with network configurations (port, protocol, etc) and startup. And the app file with the express API configuration (routes, JSON, and middleware). This allows testing the API in-process in an isolated test environment. It guarantees a better separation of concerns and a cleaner code.

3 - Separate business logic from the router controllers

Some applications mix the APIs routes handling code with the application's business rules.

Sometimes the business rules and data access code are put in the API routes layer. As in the example below (Don't do this!):

src/routes/courses.js
// arquivo de rotas
router.post('/courses', async (req, res) => {
  try {
    const { name, ch } = req.body
        const existingCourse = await courseModel.findAll({
      where: {
        name: name
      }
    })
    if (existingCourse.length > 0) {
      throw new Error('Course already registered')
    }
    await courseModel.create({ name, ch })
    res.status(201).json({ message: 'Course created!' })
  } catch (err) {
    res.status(400).send(err.message)
  }
}

In other cases, the APIs routes handling (HTTP request and HTTP response objects) are passed from the API routes layer to the services layer. As we can see in the example below (Don't do this too!).

src/routes/courses.js
// arquivo de tratamento de rotas na camada de routes
router.post('/courses', (req, res) => coursesService.create(req, res));
src/services/courses.js
//  arquivo de serviços
 create(req, res) {
  try {
    const { name, ch } = req.body
        const existingCourse = await courseModel.findAll({
      where: {
        name: name
      }
    })
    if (existingCourse.length > 0) {
      throw new Error('Course already registered')
    }
    await courseModel.create({ name, ch })
    res.status(201).json({ message: 'Course created!' })
  } catch (err) {
    res.status(400).send(err.message)
  }
}

In the two examples above, the APIs routes handling code is mixed with the business rules.

APIs routes are responsible for handling all HTTP requests and responses (validate request body data, URL parameters, sending the response with content and status code, etc). And the business rules perform requirements validation and data manipulation.

As a result, the function/class assumes many responsibilities, violating good practices of cohesion and separation of concepts. This impacts the code readability and maintainability. And makes it harder to write unit tests. (eg. dealing with complex mocks for express req and res objects).

Given this situation, you can isolate the code for handling APIs routes in the routes layer. And put the code for business rules and data access in the services layer.

The following is an example of good practice based on the concepts of separation of responsibilities and cohesion. See below:

src/routes/courses.js
// handling HTTP requests and responses on the API routes layer
router.post('/', async (req, res) => {
  try {
    const { name, ch } = req.body
    await courseService.create({ name, ch })
    res.status(201).json({ name, ch })
  } catch (err) {
    res.status(400).send(err.message)
  }
})
src/services/courses.js
// business logic and data manipulation on the service layer
async create (courseDTO) {
  try {
    await this.verifyIfCourseNameIsRegistered(courseDTO.name)
    await courseModel.create(courseDTO)
  } catch (err) {
    throw new Error(err.message)
  }
}

async verifyIfCourseNameIsRegistered (courseName) {
  const existingCourse = await courseModel.findAll({
    where: {
      name: courseName
    }
  })
  if (existingCourse.length > 0) {
    throw new Error('Course already registered')
  }
}

With this separation, you keep the code cohesive and more readable. Also, makes it simpler to create unit tests per layer.

For all the examples used in this item, I used a Sequelize model. This model represents the course entity for the Postgres database.

models/course.js
  const CourseModel = sequelize.define('Course', {
    name: {
      type: DataTypes.STRING,
      unique: true,
      allowNull: false,
      validate: {
        notEmpty: true
      }
    },
    ch: {
      type: DataTypes.INTEGER,
      allowNull: false,
      validate: {
        notEmpty: true
      }
    }
  })

Summarizing this item:

  • Separate the API routes code from the services layer;
  • The API routes layer must handle everything related to HTTP requests: URL, status code, headers, and methods;
  • Do not pass req and res objects to the services layer;
  • The services layer must handle only business rules and integration with models.

4 - Identification of the API resource endpoints and operations

Identify your resources using a plural noun. In the REST applications, resources are something that can be manipulated and have a state representational. For example, in the education application, there are the following resources: student, course, teacher, etc.

In the case of the education application, you must define the endpoints of its resources as:

  • /courses - endpoint for manipulating all operations of courses
  • /students - endpoint for manipulating all operations of students
  • /teachers -endpoint for manipulating all operations of teachers

And, how can identify the type of request operations? Use the HTTP semantic methods for each operation type. Below have an example of how to organize API routes for courses:

  • GET /courses - return all courses
  • POST /courses - add new course
  • PUT /courses/:id - update a specific course (based on its id)
  • DELETE /courses/:id - remove a specific course (based on its id)
  • GET /courses/:id - return a specific course (based on its id)

Identification of resource endpoints using plural nouns and defining operations by HTTP method based on your semantic is a standard, keep it!

Learn more about HTTP methods at https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods

5 - Use HTTP status codes correctly

The HTTP protocol has a set of response status codes that indicate if the request was successful or if an error occurred. Return codes are grouped by class as listed below:

  • 2xx - codes that indicate the success of the request, the two most used are:
    • 200 Ok - Indicates successful request (for any type of request).
    • 201 Ok - Indicates success for resource creation requests (used as success return for POST requests).
  • 3xx - codes that indicate that the resource has been moved (little used),
  • 4xx - codes that indicate errors caused by the client
    • 400 Bad Request – indicates that some data sent in the request is incorrect.
    • 401 Unauthorized – indicates that the client is not authorized to access the resource, usually caused by a lack of authentication.
    • 403 Forbidden – indicates that the user is known (is authenticated) but not allowed to access that resource.
    • 404 Not Found – indicates that the requested resource does not exist.
  • 5xx - indicates some unexpected error on the server-side, handle it for not displayed directly to the client.

Once in a while, we come across an API response that returns status code 200 (which indicates success in the request), and in the body of the response comes an error message or a flag (true or false) indicating that an error has occurred... It looks like success, but it's an error.

Meet the main HTTP status codes to avoid returning HTTP status codes that do not match the reality of the request.

Use the HTTP response code based on the status of the real status of the request.

6 - Create integration tests for your API routes

An automated test is fundamental to guarantee the reliability of the software. Automated tests are organized into three categories:

  • Unit test - tests each function/method in isolation, makes a lot of use of mocks to isolate the test;
  • Integration test - tests the functionality as a whole. Don't use mocks;
  • End-to-end test - tests the application as if it were the end-user, via the interface. It's an expensive and slow test.

For the development of APIs, create integration tests! Integration tests are relatively fast. Are easier to write than other tests and provides reliable quality guarantees.

Integration tests help you ensure that the different parts of your software can work together. The app is tested as a whole from the API route to the database.

In the Node.js environment, is easy to run integration tests with Jest and Supertest. Both are widely used to create and run integration tests. The code below presents an integration test example for adding a new course to API.

test/api/course.test.js
const request = require('supertest')
const config = require('../../../src/config')
const app = require('../../../src/app')
const { sequelize } = require('../../../src/models')

const API_COURSES = `${config.API_BASE}/courses`

const DEFAULT_COURSE = {
  name: 'Curso 1',
  ch: 1500
}

beforeAll(async () => {
  await sequelize.sync({ force: true }) // conecta com o banco de testes
  await request(app).post(API_COURSES).send(DEFAULT_COURSE) // insere um curso no banco
})

afterAll(async () => {
  await sequelize.close() // fecha a conexão com o banco
})

describe('Testando a rota de curso', () => {
  test('Deve adicionar um novo curso com sucesso!', async () => {
    const newCourse = {
      name: 'Curso 2',
      ch: 3020
    }
    const response = await request(app).post(API_COURSES).send(newCourse)
    expect(response.statusCode).toBe(201)
  })
})

In the example above, you see an integration test for adding a new course with success. The test is performed from the HTTP request in the course route, passing the data of the new course to the insertion in the database, and verification of the HTTP response. The full example is in this GitHub repository.

7 - Take care of your API security

When we talk about web security, the use of HTTPS (TLS/SSL) is fundamental and mandatory. ALWAYS USE HTTPS!

When using dependencies, check the origin, updates, possible security flaws, etc. For that, I recommend using Snyk Open Source Security Management, a tool that connects with your GitHub repository and checks for possible security breaches in all dependencies of your Node.js application.

In applications with Express.js, use Helmet. The helmet is a library for Express.js that adds several middlewares, responsible for setting some headers in HTTP messages, making your application more secure.

Start by taking care of your application's security from the beginning of development, no matter if it's an application just for academic purposes.

8 - Create configuration files for each environment

Create configuration files (with database configs, external API addresses, security keys) for each environment (development, test, production). And load specific file according to each environment in an automated way. Here's an example:

Index.js file, which loads the settings according to the environment.

config/index.js
const config = require(`./env/${process.env.NODE_ENV || 'development'}.js`)
module.exports = config

Arquivos de cada ambiente

src/
  ├── config/
      ├── index.js
      ├── env
          ├── development.js
          ├── test.js
          ├── production.js

Each file (development, test, production) has configuration information specific to that environment. As a result, there is no need to make manual changes to the settings for each environment.

You can also use .env files for each environment instead of js files.

9 - Use Integration and Continuous Delivery

Continuous Integration (CI) is a software development practice where developers regularly merge their code changes into a central repository and run automated tests.

Continuous Delivery (CD) is an extension of continuous integration, that it automatically deploys the application to a staging environment after running the build process and running all automated tests successfully.

It is important to highlight that for the use of Integration and Continuous Delivery it is essential that your application uses automated tests, mainly integration tests.

Config and using the Integration and Continuous Delivery features have been quite simple in projects stored on GitHub and Gitlab. Here is an example of configuration files for GitHub Actions (see details below the example):

name: Node.js CI and DI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:10.8
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: school_test
          POSTGRES_PORT: 5432
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

    strategy:
      matrix:
        node-version: [14.x]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm run build --if-present
      - run: npm test
        env:
          DATABASE_HOST: localhost
          DATABASE_PORT: 5432
          DATABASE: school_test
          DATABASE_USERNAME: test
          DATABASE_PASSWORD: test
          # DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
      - uses: akhileshns/heroku-deploy@v3.12.12 # This is the action
        with:
          heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
          heroku_app_name: 'app-name' #Must be unique in Heroku
          heroku_email: 'email-user-heroku@email.com'

The above file is configured to execute the following steps in sequence whenever there is a push or pull request on the main branch of the project:

  1. Use an Ubuntu machine;
  2. Install Node.js 14.x;
  3. Install and configure a Postgres database instance;
  4. Build the project;
  5. Run the tests;
  6. Deploy the project on Heroku.

NOTE: If any of the steps fails, the flow is interrupted and the next steps are not executed.

So which of these practices do you already use? which ones are new to you? Comment, suggest, talk to me!

The complete code used in this post is available at GitHub repository.

Did you like the tips? Which of these best practices did you already know? What are the best practices you use and recommend?

Others best practices references