Creating a Node.js/TypeScript REST API, Part 2: Developing Models, Middleware, and Services

This article builds on the first in our REST API series, where we set up a back-end project using npm, TypeScript, the debug module, Express.js, and Winston logging. If you’re comfortable with those concepts, you can clone the project repository (this), check out the toptal-article-01 branch using git checkout, and continue reading.

REST API Services, Middleware, Controllers, and Models

This article dives deeper into these modules:

  • Services: These functions encapsulate business logic, making code cleaner and easier for middleware and controllers to utilize.
  • Middleware: These functions validate requests before they reach the appropriate controller, ensuring prerequisites are met.
  • Controllers: Using services, controllers process requests and formulate responses to send back to the requester.
  • Models: These define the structure of our data and assist with compile-time type checking.

This part also introduces a simplified, temporary database unsuitable for production. It exists solely to illustrate concepts and prepare for a later article focusing on MongoDB and Mongoose integration.

Hands-on: First Steps with DAOs, DTOs, and Our Temporary Database

Instead of persistent storage, this tutorial’s database will temporarily hold user data in an array, meaning data is lost when Node.js exits. It only supports fundamental create, read, update, and delete (CRUD) operations.

Two key concepts we’ll use are:

  • Data access objects (DAOs)
  • Data transfer objects (DTOs)

Despite the subtle difference in acronyms, their roles are distinct: DAOs handle database interactions (CRUD), while DTOs hold the data exchanged between the DAO and the database.

Essentially, DTOs are objects adhering to data model types, and DAOs are the services that utilize them.

While DTOs can become more complex (e.g., representing nested database entities), in this tutorial, a DTO instance will correspond to an action on a single database row.

Why DTOs?

Using DTOs to align TypeScript objects with data models enforces architectural consistency. However, it’s crucial to understand that neither DTOs nor TypeScript alone provide automatic user input validation, which must occur at runtime. User input received at an API endpoint might:

  • Contain extraneous fields
  • Lack required fields (those not marked optional with ? in TypeScript)
  • Have fields with data types different from those defined in the model

TypeScript (and the resulting JavaScript) won’t catch these issues at runtime (won’t magically check this). Validations are vital, especially for public APIs. Libraries like ajv can help but often rely on their own schema definitions instead of native TypeScript types. (Mongoose, covered in the next article, will serve a similar purpose in this project.)

You might wonder about the necessity of both DAOs and DTOs. As experienced developer Gunther Popp offers an answer, DTOs might be overkill for smaller Express.js/TypeScript projects unless significant scaling is anticipated.

Even if you’re not immediately using them in production, this example project offers valuable practice in:

  • Utilizing TypeScript types for data modeling (in additional ways)
  • Working with DTOs to understand their benefits and drawbacks compared to simpler approaches when adding components and models

Our User REST API Model at the TypeScript Level

Let’s define three DTOs for users. Create a dto folder within the users folder, and within it, create create.user.dto.ts:

1
2
3
4
5
6
7
8
export interface CreateUserDto {
    id: string;
    email: string;
    password: string;
    firstName?: string;
    lastName?: string;
    permissionLevel?: number;
}

This code defines a CreateUserDto interface, specifying the required fields (ID, password, email) and optional fields (firstName, lastName) when creating a user, regardless of the database used. These requirements can vary based on project needs.

For PUT requests (complete object updates), optional fields become required. In the same folder, create put.user.dto.ts:

1
2
3
4
5
6
7
8
export interface PutUserDto {
    id: string;
    email: string;
    password: string;
    firstName: string;
    lastName: string;
    permissionLevel: number;
}

For PATCH requests (partial updates), TypeScript’s Partial utility comes in handy. It creates a new type with all fields of the original type marked optional. Create patch.user.dto.ts:

1
2
3
import { PutUserDto } from './put.user.dto';

export interface PatchUserDto extends Partial<PutUserDto> {}

Now, let’s set up the temporary in-memory database. Create a daos folder inside users and add users.dao.ts.

Begin by importing the DTOs:

1
2
3
import { CreateUserDto } from '../dto/create.user.dto';
import { PatchUserDto } from '../dto/patch.user.dto';
import { PutUserDto } from '../dto/put.user.dto';

Install the shortid library for generating unique IDs:

1
2
npm i shortid
npm i --save-dev @types/shortid

In users.dao.ts, import shortid:

1
2
3
4
import shortid from 'shortid';
import debug from 'debug';

const log: debug.IDebugger = debug('app:in-memory-dao');

Now, create the UsersDao class:

1
2
3
4
5
6
7
8
9
class UsersDao {
    users: Array<CreateUserDto> = [];

    constructor() {
        log('Created new instance of UsersDao');
    }
}

export default new UsersDao();

This class uses the singleton pattern, ensuring that importing it from different files always returns the same instance with the same users array. This is due to Node.js’s module caching—the first instance of UsersDao is cached and reused.

This pattern will be evident as we use this class later in the article. It’s a common practice in TypeScript/Express.js projects for most classes.

Note: Singletons can be harder to unit test. This won’t be a major concern for many of our classes, as they lack member variables requiring resetting. For those that do, consider techniques like dependency injection (dependency injection).

Next, add basic CRUD operations as functions to the class. The create function:

1
2
3
4
5
async addUser(user: CreateUserDto) {
    user.id = shortid.generate();
    this.users.push(user);
    return user.id;
}

Read operations: one for fetching all users, another for retrieving by ID:

1
2
3
4
5
6
7
async getUsers() {
    return this.users;
}

async getUserById(userId: string) {
    return this.users.find((user: { id: string }) => user.id === userId);
}

Similarly, update can be a full replacement (PUT) or a partial modification (PATCH):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async putUserById(userId: string, user: PutUserDto) {
    const objIndex = this.users.findIndex(
        (obj: { id: string }) => obj.id === userId
    );
    this.users.splice(objIndex, 1, user);
    return `${user.id} updated via put`;
}

async patchUserById(userId: string, user: PatchUserDto) {
    const objIndex = this.users.findIndex(
        (obj: { id: string }) => obj.id === userId
    );
    let currentUser = this.users[objIndex];
    const allowedPatchFields = [
        'password',
        'firstName',
        'lastName',
        'permissionLevel',
    ];
    for (let field of allowedPatchFields) {
        if (field in user) {
            // @ts-ignore
            currentUser[field] = user[field];
        }
    }
    this.users.splice(objIndex, 1, currentUser);
    return `${user.id} patched`;
}

Importantly, despite using UserDto in function signatures, TypeScript doesn’t enforce these types at runtime. This means:

  • putUserById() has a potential issue—it doesn’t prevent storing data for fields not defined in the DTO.
  • patchUserById() relies on a separate list of field names that need to be kept in sync with the model. Without this, it might silently ignore fields defined in the DTO but missing from the object being updated.

These scenarios will be handled correctly at the database level in the next article.

The final operation, delete, is straightforward:

1
2
3
4
5
6
7
async removeUserById(userId: string) {
    const objIndex = this.users.findIndex(
        (obj: { id: string }) => obj.id === userId
    );
    this.users.splice(objIndex, 1);
    return `${userId} removed`;
}

Additionally, let’s add a “get user by email” function, a common requirement before creating a user (to prevent duplicates):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async getUserByEmail(email: string) {
    const objIndex = this.users.findIndex(
        (obj: { email: string }) => obj.email === email
    );
    let currentUser = this.users[objIndex];
    if (currentUser) {
        return currentUser;
    } else {
        return null;
    }
}

Note: In real-world scenarios, you’d typically use database libraries like Mongoose or Sequelize to abstract away these basic operations. This tutorial skips those details for simplicity.

Our REST API Services Layer

With a basic DAO, we can create a service that utilizes its CRUD functions. Since these operations are fundamental, let’s define a CRUD interface that services connecting to databases can implement.

Many IDEs now offer code generation to reduce boilerplate. For example, in WebStorm IDE:

A screenshot of WebStorm showing an empty definition for a class called MyService that implements an interface called CRUD. The name MyService is underlined in red by the IDE.

The IDE might suggest:

A screenshot similar to the previous one, but with a context menu listing several options, the first of which is "Implement all members."

Choosing “Implement all members” would automatically generate the functions needed to conform to the CRUD interface:

A screenshot of the MyService class in WebStorm. MyService is no longer underlined in red, and the class definition now contains all of the TypeScript-typed function signatures (along with function bodies, either empty or containing a return statement) specified in the CRUD interface.

First, create the CRUD interface. Within the common folder, create an interfaces folder and add crud.interface.ts:

1
2
3
4
5
6
7
8
export interface CRUD {
    list: (limit: number, page: number) => Promise<any>;
    create: (resource: any) => Promise<any>;
    putById: (id: string, resource: any) => Promise<string>;
    readById: (id: string) => Promise<any>;
    deleteById: (id: string) => Promise<string>;
    patchById: (id: string, resource: any) => Promise<string>;
}

Now, create a services folder within users and add users.service.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import UsersDao from '../daos/users.dao';
import { CRUD } from '../../common/interfaces/crud.interface';
import { CreateUserDto } from '../dto/create.user.dto';
import { PutUserDto } from '../dto/put.user.dto';
import { PatchUserDto } from '../dto/patch.user.dto';

class UsersService implements CRUD {
    async create(resource: CreateUserDto) {
        return UsersDao.addUser(resource);
    }

    async deleteById(id: string) {
        return UsersDao.removeUserById(id);
    }

    async list(limit: number, page: number) {
        return UsersDao.getUsers();
    }

    async patchById(id: string, resource: PatchUserDto) {
        return UsersDao.patchUserById(id, resource);
    }

    async readById(id: string) {
        return UsersDao.getUserById(id);
    }

    async putById(id: string, resource: PutUserDto) {
        return UsersDao.putUserById(id, resource);
    }

    async getUserByEmail(email: string) {
        return UsersDao.getUserByEmail(email);
    }
}

export default new UsersService();

This code imports the in-memory DAO, the CRUD interface, and DTO types. UsersService is implemented as a singleton, mirroring the DAO pattern.

All CRUD functions simply delegate to their counterparts in UsersDao. If the DAO changes in the future, modifications are localized to this service file, thanks to the separation of concerns.

For instance, replacing the list() function wouldn’t require hunting down every usage throughout the codebase. This modularity comes at the cost of some initial boilerplate.

Async/Await and Node.js

The use of async in service functions might seem unnecessary at this point. They currently return values directly without utilizing Promises or await. This is intentional to prepare for future services that will incorporate asynchronous operations. Similarly, you’ll notice await used when calling these functions.

By the end of this article, you’ll have a functional project. It’s a good opportunity to experiment with introducing errors at various points and observing the behavior during compilation and testing. Errors in async contexts can behave unexpectedly. It’s worth exploring topics like error handling in asynchronous JavaScript and the event loop (exploring various solutions) beyond the scope of this article.

With the DAO and services in place, let’s move on to the user controller.

Building Our REST API Controller

Controllers separate route handling logic from route configuration. Ideally, validations occur before a request reaches the controller. The controller assumes a valid request and focuses solely on processing it. It achieves this by calling the appropriate service functions.

Before proceeding, install the bcrypt library for password hashing:

1
npm i argon2 

Create a controllers folder inside the users folder and add users.controller.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// we import express to add types to the request/response objects from our controller functions
import express from 'express';

// we import our newly created user services
import usersService from '../services/users.service';

// we import the argon2 library for password hashing
import argon2 from 'argon2';

// we use debug with a custom context as described in Part 1
import debug from 'debug';

const log: debug.IDebugger = debug('app:users-controller');
class UsersController {
    async listUsers(req: express.Request, res: express.Response) {
        const users = await usersService.list(100, 0);
        res.status(200).send(users);
    }

    async getUserById(req: express.Request, res: express.Response) {
        const user = await usersService.readById(req.body.id);
        res.status(200).send(user);
    }

    async createUser(req: express.Request, res: express.Response) {
        req.body.password = await argon2.hash(req.body.password);
        const userId = await usersService.create(req.body);
        res.status(201).send({ id: userId });
    }

    async patch(req: express.Request, res: express.Response) {
        if (req.body.password) {
            req.body.password = await argon2.hash(req.body.password);
        }
        log(await usersService.patchById(req.body.id, req.body));
        res.status(204).send();
    }

    async put(req: express.Request, res: express.Response) {
        req.body.password = await argon2.hash(req.body.password);
        log(await usersService.putById(req.body.id, req.body));
        res.status(204).send();
    }

    async removeUser(req: express.Request, res: express.Response) {
        log(await usersService.deleteById(req.body.id));
        res.status(204).send();
    }
}

export default new UsersController();

Note: Sending a 204 No Content response without a body for successful PUT, PATCH, and DELETE requests aligns with common REST practices (RFC 7231).

The user controller is implemented as a singleton. Next, let’s work on user middleware, which also depends on the API object model and service.

Node.js REST Middleware with Express.js

Middleware is well-suited for validations. Let’s add some basic validation checks:

  • Verify the presence of required fields like email and password for user creation and updates.
  • Ensure that email addresses are unique.
  • Prevent modifications to the email field after user creation (treating it as a primary identifier for simplicity).
  • Check if a user with the given ID exists.

These validations need to be adapted into Express.js middleware functions using the next() function for control flow, as covered in the previous article. Create users/middleware/users.middleware.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import express from 'express';
import userService from '../services/users.service';
import debug from 'debug';

const log: debug.IDebugger = debug('app:users-controller');
class UsersMiddleware {

}

export default new UsersMiddleware();

After the singleton setup, add middleware functions to the class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
async validateRequiredUserBodyFields(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    if (req.body && req.body.email && req.body.password) {
        next();
    } else {
        res.status(400).send({
            error: `Missing required fields email and password`,
        });
    }
}

async validateSameEmailDoesntExist(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    const user = await userService.getUserByEmail(req.body.email);
    if (user) {
        res.status(400).send({ error: `User email already exists` });
    } else {
        next();
    }
}

async validateSameEmailBelongToSameUser(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    const user = await userService.getUserByEmail(req.body.email);
    if (user && user.id === req.params.userId) {
        next();
    } else {
        res.status(400).send({ error: `Invalid email` });
    }
}

// Here we need to use an arrow function to bind `this` correctly
validatePatchEmail = async (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) => {
    if (req.body.email) {
        log('Validating email', req.body.email);

        this.validateSameEmailBelongToSameUser(req, res, next);
    } else {
        next();
    }
};

async validateUserExists(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    const user = await userService.readById(req.params.userId);
    if (user) {
        next();
    } else {
        res.status(404).send({
            error: `User ${req.params.userId} not found`,
        });
    }
}

To simplify subsequent requests related to a newly created user, let’s add a helper function to extract the userId from request parameters (the URL) and add it to the request body (containing other user data).

This way, when updating user information, the ID is readily available in the request body, avoiding repeated retrieval from parameters. This logic is centralized in the middleware. Add the following function:

1
2
3
4
5
6
7
8
async extractUserId(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    req.body.id = req.params.userId;
    next();
}

The key difference between middleware and controllers lies in the use of next() to pass control along the middleware chain until it reaches the final destination (the controller).

Putting it All Together: Refactoring Our Routes

With the new architecture in place, revisit users.routes.config.ts from the previous article. Update it to use the middleware and controllers, which in turn rely on the user service and model.

The final version of the file should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { CommonRoutesConfig } from '../common/common.routes.config';
import UsersController from './controllers/users.controller';
import UsersMiddleware from './middleware/users.middleware';
import express from 'express';

export class UsersRoutes extends CommonRoutesConfig {
    constructor(app: express.Application) {
        super(app, 'UsersRoutes');
    }

    configureRoutes(): express.Application {
        this.app
            .route(`/users`)
            .get(UsersController.listUsers)
            .post(
                UsersMiddleware.validateRequiredUserBodyFields,
                UsersMiddleware.validateSameEmailDoesntExist,
                UsersController.createUser
            );

        this.app.param(`userId`, UsersMiddleware.extractUserId);
        this.app
            .route(`/users/:userId`)
            .all(UsersMiddleware.validateUserExists)
            .get(UsersController.getUserById)
            .delete(UsersController.removeUser);

        this.app.put(`/users/:userId`, [
            UsersMiddleware.validateRequiredUserBodyFields,
            UsersMiddleware.validateSameEmailBelongToSameUser,
            UsersController.put,
        ]);

        this.app.patch(`/users/:userId`, [
            UsersMiddleware.validatePatchEmail,
            UsersController.patch,
        ]);

        return this.app;
    }
}

This code defines routes with middleware for validation and controller functions for processing valid requests. The .param() method from Express.js extracts the userId.

The .all() method applies validateUserExists from UsersMiddleware to all GET, PUT, PATCH, and DELETE requests targeting /users/:userId. This eliminates the need to repeat it for each HTTP method.

Middleware reusability is demonstrated by using UsersMiddleware.validateRequiredUserBodyFields for both POST and PUT requests, streamlining common validation logic.

Disclaimers: This article covers basic validations. Real-world projects often demand more comprehensive checks. For simplicity, we assume email addresses cannot be changed.

Testing Our Express/TypeScript REST API

Now, compile and run the Node.js application. Once it’s running, use a REST client like Postman or cURL to test the API routes.

First, attempt to fetch all users:

1
2
curl --request GET 'localhost:3000/users' \
--header 'Content-Type: application/json'

The response should be an empty array, as expected. Now, try creating a user:

1
2
curl --request POST 'localhost:3000/users' \
--header 'Content-Type: application/json'

This request should result in an error from the middleware:

1
2
3
{
    "error": "Missing required fields email and password"
}

Fix the request by providing the required fields:

1
2
3
4
5
6
curl --request POST 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "marcos.henrique@toptal.com",
    "password": "sup3rS3cr3tPassw0rd!23"
}'

The response should resemble:

1
2
3
{
    "id": "ksVnfnPVW"
}

The id is unique to the created user. Store this ID in a variable for subsequent tests (assuming a Linux-like environment):

1
REST_API_EXAMPLE_ID="put_your_id_here"

Fetch the user using the stored ID:

1
2
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json'

Update the entire user resource using a PUT request:

1
2
3
4
5
6
7
8
9
curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "marcos.henrique@toptal.com",
    "password": "sup3rS3cr3tPassw0rd!23",
    "firstName": "Marcos",
    "lastName": "Silva",
    "permissionLevel": 8
}'

Test the email validation by attempting to change the email address, which should trigger an error.

Remember that PUT requests expect the entire object to be provided. To update only the lastName field, use a PATCH request instead:

1
2
3
4
5
curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
    "lastName": "Faraco"
}'

This distinction between PUT and PATCH is enforced by the route configuration and middleware.

PUT, PATCH, or Both?

It may sound like there’s not much reason to support PUT given the flexibility of PATCH, and some APIs will take that approach. Others may insist on supporting PUT to make the API “completely REST-conformant,” in which case, creating per-field PUT routes might be an appropriate tactic for common use cases.

In reality, these points are part of a much larger discussion ranging from real-life differences between the two to more flexible semantics for PATCH alone. We present PUT support here and widely-used PATCH semantics for simplicity, but encourage readers to delve into further research when they feel ready to do so.

Fetch the user list again:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[
    {
        "id": "ksVnfnPVW",
        "email": "marcos.henrique@toptal.com",
        "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM",
        "firstName": "Marcos",
        "lastName": "Faraco",
        "permissionLevel": 8
    }
]

Finally, test deleting the user:

1
2
curl --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json'

Fetching the user list once more should confirm that the user is no longer present.

This completes the implementation and testing of basic CRUD operations for the users resource.

Node.js/TypeScript REST API

This part of the series explored building a REST API using Express.js, focusing on services, middleware, controllers, and models. Each component plays a distinct role, from validation and business logic to request processing and response generation.

A simple in-memory database was used for demonstration purposes. In the next part, it will be replaced with a more robust solution using MongoDB and Mongoose.

The series will conclude by covering:

  • Integrating MongoDB and Mongoose for data persistence
  • Implementing JWT-based authentication for stateless security
  • Setting up automated testing for scalability

You can find the code for this part of the series here.

Licensed under CC BY-NC-SA 4.0