5 minute read

The baseline project

This post assumes you are following along with the baseline project tutorial, but the concepts apply to any Typescript project.

The User service

We now have a solid foundation for building an express server. Next, we will create a simple User service that will illustrate one possible architecture for implementing features in express. We are going to create a directory located at src/user that will contain all of the User-specific logic for our application:

src/
├─ user/
│  ├─ controller.ts
│  ├─ service.ts
│  ├─ repo.ts
│  ├─ types.ts
├─ util/
│  ├─ error.ts
│  ├─ load-config.ts
│  ├─ logger.ts
├─ app.ts
├─ config.ts
├─ index.ts

Let’s go ahead and create the src/usr directory and the files that we will be working with:

$ mkdir src/user
$ cd src/user
$ touch controller.ts service.ts repo.ts types.ts
  • controller.ts

Handles all of the express-specific logic of defining routes, applying middleware, calling the service layer, and responding to requests.

  • service.ts

Defines the business logic for any user operations, independent of express or our particular database implementation.

  • repo.ts

Provides an interface for the service layer to query and mutate data in our particular database implementation.

  • types.ts

Provides type definitions for the User entity.

User types

For now, create a simple User interface - we can extend it later on to implement authentication, etc.

We will also create a UserSignupPayload interface that describes the payload that will be expected by the service layer when we create a new User:

// src/user/types.ts

export interface User {
  id: string;
  username: string;
  email: string;
  created_at: Date;
}

export interface UserSignupPayload {
  username: string;
  email: string;
}

User controller

For each feature we add, we will create a new express router instance and export it.

Each route callback is a controller that is responsible for calling the service layer and returning the result as a JSON object. For now, we’ll create a POST route to create a User, and a GET route to retrieve a collection of all users, both at the router’s route path:

//  src/user/controller.ts

import { Router } from "express";

import { userService } from "./service";

const router = Router();

// signup
router.post("/", (req, res) => {
  const user = userService.signup({ ...req.body });
  res.status(201).json({ user });
});

// list users
router.get("/", (req, res) => {
  const users = userService.list();
  res.status(200).json({ users });
});

export { router as userRouter };

Next, we can implement the service layer methods we called in the controllers.

User service

We are going to use a package called nanoid to generate unique ids for our User entities. Install it as a project dependency:

$ yarn add nanoid

Now, let’s implement the signup and list methods for the service layer:

// src/user/service.ts

import boom from "@hapi/boom";
import { nanoid } from "nanoid";

import { userRepo } from "./repo";
import type { User, UserSignupPayload } from "./types";

export class UserService {
  signup({ username, email }: UserSignupPayload): User {
    if (userRepo.findByUsername(username))
      throw boom.badRequest(
        `A user with the username "${username}" already exists.`
      );
    if (userRepo.findByEmail(email))
      throw boom.badRequest(`A user with the email "${email}" already exists`);

    const user = userRepo.insert({
      id: nanoid(),
      username,
      email,
      created_at: new Date(),
    });

    return user;
  }
  list(): User[] {
    const users = userRepo.list();
    return users;
  }
}

export const userService = new UserService();

The signup method simply checks whether a user already exists in the repository before calling the data access layer’s insert to create a new record.

Meanwhile, the list method simply returns the result of the data layer’s list method.

User repo

In future posts, we will implement the data access layer using postgresql, but for now, let’s create a simple in-memory store as our User repository and provide methods for writing and retrieving data from it:

// src/user/repo.ts

import { User } from "./types";

const userDb: User[] = [];

class UserRepo {
  list(): User[] {
    return userDb;
  }
  find(id: string): User | undefined {
    return userDb.find((usr) => usr.id === id);
  }
  findByUsername(username: string): User | undefined {
    return userDb.find((usr) => usr.username === username);
  }
  findByEmail(email: string): User | undefined {
    return userDb.find((usr) => usr.email === email);
  }
  insert(user: User): User {
    userDb.push(user);
    return user;
  }
}

export const userRepo = new UserRepo();

With that, we’ve implemented a very simple, but extensible, User feature for our application!

Adding the User controller to our app

The final step is to register the express router exported by our User controller to our app definition. The User routes will be served at the /api/user path:

// src/app.ts

import "express-async-errors";

import express from "express";
import pinoHttp from "pino-http";

import { userRouter } from "./user/controller";
import { errorMiddleware, notFoundMiddleware } from "./util/error";
import { logger } from "./util/logger";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(pinoHttp({ logger }));

app.get("/api/health", (req, res) => res.send({ message: "OK" }));

app.use("/api/user", userRouter); // add the userRouter here

app.use([notFoundMiddleware, errorMiddleware]);

export { app };

Testing the new routes

Start the server using the yarn scripts we set up in a previous post:

$ yarn dev

Using an API testing tool like Postman, make a POST request to the /api/user route we created, with a JSON request body containing a username and email field:

POST http://localhost:5000/api/user
{
  "email": "[email protected]",
  "username": "jon"
}

You should receive a response that looks like this:

{
  "user": {
    "id": "EhGV8IQWjjseNkhGqFweq",
    "username": "jon",
    "email": "[email protected]",
    "created_at": "2021-05-26T17:01:05.149Z"
  }
}

Now, test the business logic in our service layer by sending the same request again, with no changes:

{
  "error": {
    "statusCode": 400,
    "error": "Bad Request",
    "message": "A user with the username \"jon\" already exists."
  }
}

Our User service is preventing us from creating User entities with duplicate usernames, and our error handling middleware is returning a formatted JSON response.

Finally, make a GET request to /api/user:

GET http://localhost:5000/api/user

We should receive a users object which is an array containing all of the users in our in-memory store:

{
  "users": [
    {
      "id": "EhGV8IQWjjseNkhGqFweq",
      "username": "jon",
      "email": "[email protected]",
      "created_at": "2021-05-26T17:01:05.149Z"
    }
  ]
}

Our User feature is working! In later posts, we will connect our data access layer to postgresql, validate user input, implement authentication, and more.

Commit

Go ahead and stage your changes:

$ git add .

And commit them to source control:

$ git commit