Develop performant REST API with Node, Fastify and Objection.js

After developing for some years REST API-s with Express and Sequelize ORM I felt that it was the time to try something new. During my research for other NodeJS Web Frameworks I came across Fastify. Among other features, selling point that caught me off was their official benchmark which shows this framework being 4x faster than Express. Afterwards I was really curious what's the developer experience with this framework. For this reason I developed a simple REST API about task management which development stages will be detailed throughout this article.

Another focus of this article was the ORM selection. It is very important to avoid any performance bottleneck in the data layer which may be introduced by a particular ORM. For me as a developer, Sequelize provides a very friendly user interface but at what cost? Of course performance!

First things first

The first step in this journey is to create a fastify project. We can use the fastify-cli. For this project I decided to go ahead with strong type safety by using Typescript. The command used to create a project with typescript support is as following:

npx fastify-cli generate <app-name> --lang=ts

A database and and table will be required for this tutorial. The following SQL snippet of code defines required tasks table:

CREATE TYPE task_status AS ENUM('backlog', 'pending', 'failed', 'done');

CREATE TABLE tasks(
    id SERIAL PRIMARY KEY,
    title VARCHAR(30) NOT NULL,
    "description" VARCHAR(256) NOT NULL,
    "status" task_status NOT NULL,
    start_time TIMESTAMP NOT NULL,
    end_time TIMESTAMP NOT NULL,
    deleted BOOLEAN DEFAULT FALSE
);

Picking up an ORM

One of the most critical parts of a backend app is the Data Layer. This layer is mostly represented by ORM. With a poor choice, the ORM can negatively affect the app performance.

As a Go developer I used to work with Sqlboiler ORM. I was really amazed how it provided the functionalities by avoiding the reflection as much as possible. The ORM itself is schema which means that the models are generated from the database schema. This philosophy has the following benefits:

  • Work with existing databases: Don't be the tool to define the schema, that's better left to other tools.
  • ActiveRecord-like productivity: Eliminate all sql boilerplate, have relationships as a first-class concept.
  • Optimize hot paths by generating specific code for each schema model.

The biggest performance gain is due to the fact that for specific models, specific like hand-roled queries are generated. This allows for hot path optimization.

Unfortunately, in the NodeJS environment all ORM seems to be code-first. They are more focused on being user friendly than performant. In my opinion a balance between user-friendliness and performance of an ORM  must be established. However, during my ORM research I came across Objection.js which is built on top of a highly performanc query builder called Knex. This convinced me to some degree so I picked it.

Unfortunately I did no benchmarks to confirm my choice of ORM, but I was based on an external benchmark. After a close inspection of the benchmark, it can be concluded that the benchmark involves also the network overhead incurred by HTTP request and database connection. For a more fair experiment, both of them must be crossed out from the benchmark by mocking the database driver. Despite of that, Objections was more performant than Sequelize.

Setting up Data Layer

At first I was trying to reinvent the wheel by introducing some infrastructure services which are used among different API-s during the app lifecycle. After reading this article I realized that I was doing it in a wrong way. Fastify allows us to declare plugins to handle such kind of services. More specifically in the README of the plugins folder the following note is written:

Plugins define behavior that is common to all the routes in your application. Authentication, caching, templates, and all the other crosscutting concerns should be handled by plugins placed in this folder.

With that in mind, I created a database plugin with the following code:

// src/plugins/database.ts
import Knex = require('knex')
import { Model } from 'objection';
import config from '../config'
import fp from 'fastify-plugin'

export interface DBConfig {
    
}

export default fp<DBConfig>(async (fastify, opts) => {
    const knex = Knex(config.development.database)
    Model.knex(knex);

    await checkHeartbeat(knex);
    
    fastify.decorate('knex', knex)
});

async function checkHeartbeat(knex: Knex<any,unknown[]>) {
    await knex.raw('SELECT 1')
}

After the Knex instance is created, it does not check if the given configuration is correct. For that we need to make a random query by calling checkHeartbeat.

After that we need to define a model for our task entity. We create a folder called models under src folder.

// src/models/task.ts
import { Model } from 'objection'

export default class Task extends Model {
    id!: number
    title!: string
    description!: string
    status!: string
    startTime!: Date
    endTime!: Date
    deleted!: boolean
    
    static tableName = 'tasks'
}

Fastify philosophy on creating REST APIs

Before diving straight into API development, I think it is best to think a bit about the anatomy of a fastify route declaration. This would help us to see the big picture and plan a better organization of the code for the route declaration which must be clear and easy to maintain.

Traditionally, when we talk about structure of an API endpoint we are referring to a path which is string, and the handler which is the function that is executed when a request hits the endpoint's path. Usually, the handler contains following logic blocks:

  1. parse request body/parameters
  2. validate the obtained input
  3. process business logic

Fastify handlers are much more different. Due to the architecture enforced by the framework, a handler should not contain parsing or validation logic. When setting up a route, we can configure the model where the request input should be deserialized into, and a validation schema which should be satisfied in order to process the request, otherwise a status code of 400 which indicates a bad request will be sent to the client. S0 only the business logic is left to be processed in the handler. Let's see the example of creating a task:

// src/routes/api/tasks/index.ts
fastify.post<{Body: Task, Reply: Task}>(
  '/',
  {
    schema: {
      body: TaskSchema
    }, 
  },
  async (req, res) => {
    try {
      return TaskService.createTask(req.body)
    }
    catch(err: any) {
      return err;
    }
  }
)
  1. in line 2 a route at path POST /api/tasks is being declared
  2. generic types are used to specify the types of the body being received  and the reply that should be sent; in case we return in handler an object whose type is not task, an error is thrown; this guarantees strong type safety.
  3. in line 3 the path is specified; please see fastify-autoload to get more contexts how routes are registered with this plugin.
  4. in line 5 we setup the schema for validation; I have created a folder at src/schemas which contains all validation schemas.
  5. in line 9 the handler is declared

The validation task schema used at line 6:

// src/schemas/task_schema.ts
import { Type } from "@sinclair/typebox";

export const RequiredTaskSchema = Type.Object({
    id: Type.Optional(Type.Integer()),
    title: Type.String(),
    description: Type.String(),
    status: Type.String(),
    start_time: Type.Optional(Type.String()),
    end_time: Type.Optional(Type.String()),
    deleted: Type.Optional(Type.Boolean())
});

In order to keep handlers as slim as possible I decided to extract all business logic in a separate layer called services. I went with static methods instead of creating a service object instance for each request. Here is the declaration of TaskService.createTask used at line 11:

// src/services/task_service.ts
static async createTask(task: Task): Promise<Task> {
    return await Task.query().insert(task);
}

With this philosophy we provide a better encapsulation of the business logic. At the same time, we prevent ending up with large route files which are difficult to read and maintain.

Other APIs

In this section I will talk about remaining APIs such as: list tasks, get, update and delete a particular task.

List Task API - [GET] /api/tasks

Route declaration for getting the list of tasks:

// src/routes/api/tasks/index.ts
fastify.get<{Querystring: ListQueryOptions, Reply: Task[]}>(
  '/',
  {
    schema: {
      querystring: ListQueryOptionsSchema,
    },
  },
  async (req, res) => {
    return await TaskService.getTaskList(req.query)
  }
  
)

This API accepts query parameters defined by ListQueryOptionsSchema. Here is the declaration of the type and the validation schema:

// src/types/list_query_options.ts
import { Type } from '@sinclair/typebox'

export interface ListQueryOptions {
    page: number
    count: number
    query: string
}

export const ListQueryOptionsSchema = Type.Object({
    page: Type.Integer(),
    count: Type.Integer(),
    query: Type.Optional(Type.String())
})

Reading ListQueryOptionsSchema definition we can see that both page and count are mandatory.  This is done just for demonstration purposes. It is pretty doable to have both these parameters as optional and fill them by default values.

However it is really important to put a limit in the count because the API can be exploited by hackers in a DDOS attack which can lead to a high consumption of the resources.

The business logic for getTaskList:

// src/services/task_service.ts
static async getTaskList(lso: ListQueryOptions): Promise<Task[]> {
    const offset = lso.count * (lso.page - 1)
    return await Task.query()
        .where('deleted', false)
        .limit(lso.count)
        .offset(offset);
}

This is an excellent example how the service is helping in keep the route declaration code as small as possible.

Get Task by Id - [GET] /api/tasks/:id\

Route declaration for getting a task by id:

// src/routes/api/tasks/index.ts
fastify.get<{ Params: PathIdParam, Reply: Task | Error}>(
  '/:id',
  {
    schema: {
      params: PathIdParamSchema,
    }
  },
  async (req, res) => {
    try {
      return await TaskService.getTask(req.params.id);
    }
    catch(err: any) {
      return err;
    }
  }
)

The API requires a path parameter which is used to specify the resource id.  PathIdParamSchema is the validation schema and PathIdParam is the type which declares the id parameter. Here is the relevant code:

// src/types/path_id_param.ts
import { Type } from "@sinclair/typebox";

export interface PathIdParam {
    id: number
}

export const PathIdParamSchema = Type.Object({
    id: Type.Integer()
})

Relevant code of service method getTask:

// src/services/task_service.ts
static async getTask(id: number): Promise<Task> {
    const task = await Task.query()
        .findById(id)
        .where('deleted', false);

    if (!task) {
        throw new httpErrors.NotFound()
    }

    return task;
}

Update Task by Id - [PUT] /api/tasks/:id

Route declaration for updating a task:

// src/routes/api/tasks/index.ts
fastify.put<{Params: PathIdParam, Body: Task, Reply: Task | Error}>(
  '/:id',
  {
    schema: {
      params: PathIdParamSchema,
      body: TaskSchema
    },
  },
  async (req, res) => {
    req.body.id = req.params.id;
    try {
      return await TaskService.updateTask(req.body)
    }
    catch(err: any) {
      return err
    }
  }
)

The route configuration is almost identical with the previous one. However, here we are accepting a body which uses task schema for validating the body.

The relevant code of service method updateTask:

// src/services/task_service.ts
static async updateTask(task: Task): Promise<Task> {
    const oldTask = await this.getTask(task.id);

    return await oldTask.$query().updateAndFetch(task);
}

Delete Task by Id - [DELETE] /api/tasks/:id

Route declaration for deleting a task:

// src/routes/api/tasks/index.ts
fastify.delete<{Params: PathIdParam, Reply: Task | Error}>(
  '/:id',
  {
    schema: {
      params: PathIdParamSchema,
    },
  },
  async (req, res) => {
    try {
      return await TaskService.deleteTask(req.params.id)
    }
    catch(err: any) {
      return err
    }
  }
)

The route configuration is identical with the configuration of the route of getting a task by id.

The relevant code of service method deleteTask:

// src/services/task_service.ts
static async deleteTask(id: number): Promise<Task> {
    const t = await this.getTask(id);

    await t.$query().updateAndFetch({
        deleted: true
    });

    return t;
}

Conclusion

Creating blazing fast REST API-s with NodeJS has never been easier and developer friendly than it is now. With fastify framework we can take advantage of declarative programming to setup route validation for input and output formats. However, we need to be careful about the usage of a particular ORM as it can dramatically affect the performance of the application. In the NodeJS environment, ORM-s are more focused on being user-friendly than performant. In my opinion a balance between them must be established.

The source code can be found here