- Published
9 best practices for building Node.js API
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.
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:
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:
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:
├── 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!):
// 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!).
// arquivo de tratamento de rotas na camada de routes
router.post('/courses', (req, res) => coursesService.create(req, res));
// 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:
// 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)
}
})
// 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.
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
andres
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 coursesPOST /courses
- add new coursePUT /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.
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.
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:
- Use an Ubuntu machine;
- Install Node.js 14.x;
- Install and configure a Postgres database instance;
- Build the project;
- Run the tests;
- 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