Frameworkifying Express API with Typescript decorators

Michał Domagalski
Schibsted engineering
8 min readMar 21, 2022

Express is a fantastic framework to build APIs. However, when you want to build something more than just a simple small CRUD, you need to think about creating a sturdy and clean architecture. Because Express is not opinionated (which is great), you need to design this architecture by yourself.

Intro

When you want to have the code properly separated, based on the components’ responsibilities, and you have the routes defined in a different place than the Express app instance, you might not like the idea of passing the app instance here and there in order to just register the routes. Maybe you don’t even want to know anything about the app instance inside the module where you implement your routes. If so, I can show you how to implement the base clean architecture that will frameworkify your Express application. We will do that by leveraging Typescript decorators.

The Idea

Now, let’s review what we want to achieve. We want to have the code setup that allows us to create new routes based on a common template, without thinking about registering the routes in the Express app and even without noticing that we have the Express app underneath.

Basically, there’s no app.get(…), app.post(…) anymore.

So, in the final application, if we want to create a GET route for /users, we would just need to:

  1. Create a class that implements a specific interface (Route)
  2. Decorate the class with a @Get() decorator and pass the base for the route (users)
  3. Implement the required method getEndpoints() that will return a list of endpoints to register for this route
  4. Write your own callbacks with specific business logic

The final endpoint will look like this:

Once the app is running, we will have two routes registered:

  • GET /users/
  • GET /users/:id

The Implementation

Base structure

Let’s start with the base directory structure and app setup. Note that this is just the base setup for routes, and in a big, production-grade application, this architecture will be much bigger with modules such as persistence management (db), models, services, etc.

Therefore, here we will be creating just the core setup that you can build upon later on.


|
| — src
| | — app
| | | — index.ts
| | | — types.ts
| |
| | — route
| | | — registry.ts
| | | — types.ts
| |
| | — index.ts
|
| — package.json
| — tsconfig.json

As you can see, in the root of our application, we have the src directory and two config files: package.json and tsconfig.json.

The src directory will contain all of the business logic of our application. Inside it, we have two main directories:

  • app — here, in index.ts, we will have the main app factory
  • route — this directory will contain the implementation of our routes

We will get there later. Right now, we only have registry.ts — here, we will have all of the auto-registration magic.

We also have types.ts files — we will keep all of the types of annotations there.

Then, we have an index.ts file that will be an entry point to our application.

Installing dependencies

First, we need to install dependencies.

For this project, we will need:

  • express (production dependency) — well, obviously!
  • typescript (dev dependency) — another obvious one!
  • ts-node (production dependency) — we will use a ts-node as an execution engine for our app
  • @types/express (dev dependency) — type annotations for express

And that’s all!

Now, let’s install them:


npm i express ts-node
npm i -D ts-node @types/express

Configuration

Now, let’s add some configuration. First, we need to configure the typescript; in tsconfig.json, let’s add:

This configuration enables us to use module imports and turns on the decorators feature. Then, let’s set up start script in our package.json.

Now, we can finally start coding!

The App

We will start by creating our base express application. Actually, we’ll write a factory that creates the application.

Our created application will have a really simple API to interact with — it can only be started or stopped. That’s it. So let’s add the type annotations for it.

Now, let’s finally implement the factory:

This code is quite straightforward. We have an async factory function which creates an express application and encapsulates it by only returning the object of the type of App with its simple API: start() and stop().

Now, we can set up the entry point to our app.

Now, when we run npm start, we should have our application up and running on localhost:3000.

However, you probably have noticed two things.

First, the createApp() factory function doesn’t need to be async because it doesn’t perform any asynchronous actions and, second, we don’t have any routes yet.

So let’s fix that.

We will create the async method registerRoutes() in our registry.

Let’s leave it empty for now.

Then, add it to our factory:

Alright, so now we run registerRoutes() on initialization and inject the express app into it. You can see why the factory needs to be async because it runs registerRoutes() and this is an asynchronous function.

We will see why that is the case later on.

Routes registry

The idea behind the registerRoutes() function is that it takes the Express application and automatically registers our routes in it.

Before we jump into coding, we need to learn a little bit about Typescript decorators. According to documentation, decorators are a special type of declaration that can be attached to a class declaration, method, accessor, property or parameter. A decorator is a function that is called at runtime with information about the decorated entity, i.e. class, and it looks like this:

Here, MyCustomDecorator should be a function, which returns another function. This function will be called at runtime with decorated class declaration injected. So the implementation could look like this:

Now, we know how decorators work — they are run at runtime and allow us to do something with a decorated declaration.

So, let’s create one!

The Route

First, let’s add the type annotations that we’ll need.

Here, we are declaring an interface Route that will be implemented by every route class. You’ve already seen that at the beginning in the UserGetRoute example. It has only one method to implement by the class. This method returns the array of endpoints to register (of type Endpoint). Endpoint only has two properties: url and callback — our handler function of the type RequestHandler (from Express) and, therefore, we are assured that we’ll have the proper function signature with req, res, and so on. We will see this in action in a bit.

RouteClass is a type that means any class that implements the Route interface.

RouteHandler is a decorator method — it is a function that will have aRouteClass injected when called.

RouteType and RouteDataHolder are helper types — we will see them in action later on.

Now, we can write our decorator.

First, we are declaring an empty array of routes. Well, actually it is an empty array of RouteDataHolder elements that handles a little bit more than just a route class declaration — it also has a route name and route type that we will need while saving or passing the data (see the RouteDataHolder type).

Then, we will have an actual Get decorator. It accepts one string param — route base (we have to add it manually while decorating the class, i.e. @Get(“users”)). The Get method returns a function (RouteHandler) that will get the decorated class and construct an object of the type RouteDataHolder from the class, route name, and route type (in case of Get annotation, the type is get).

The addRoute method will add the route to the routes array. It will validate if this route does not exist in the array in case someone created two classes for the same base route decorated with Get.

Now, let’s create the actual route.

Add the user.get.ts file in the route directory. You should remember this one from the beginning of this article. I just made some minor touch ups.

Of course, this is just a placeholder implementation with fake data. In a production-grade application, we would get all of the users from db or search for one by id but you got the idea.

Now, when this module will load, the Get decorator will run and (in the registry.ts):

  1. The addRoute method will fire up
  2. The below object will be pushed into the routes array

However, it will not work yet.

Registration of routes

Remember when we said that decorators run at runtime? To make @Get() fire up, we need to actually run the user.get.ts module. To run it, we just need to import it somewhere and the decorator will be called. We could just import this route into the registry and then import the registry in the app/index.ts module and everything will work just fine, but eventually we will have many routes in our future application and importing every one of them into the registry wouldn’t be so automatic.

To automate the process, we will use dynamic imports. In Node, you can import the module dynamically in code by using the async import(“./path/to/module”) function.

Let’s jump into our registry and write some helper function that will import all of the route modules automatically.

This method is really straightforward. It reads the route directory, targets every file except registry.ts and types.ts, creates an array of relative paths for every file (./filename), and then iterates over those names and dynamically imports the files.

Now, we can run this function inside our empty registerRoutes() function that we have created earlier.

Now, after we run the app, we should have one object in the routes array.

Finally, we can register the routes in Express.

Here, for each RouteDataHolder object in the routes array, we are calling registerRoute(). This function creates an instance for the route class (remember that the routeClass is a class declaration collected by the decorator) and then registers every endpoint that we declared inside the getEndpoints() implementation. As you remember, for UserGetRoute we have:

So here, registerRoute() function will register:

Now, when you run the app, you should have those two endpoints registered as well as up and running.

Let’s add the other CRUD methods.

And finally

Now, we have a fully functional little framework on top of Express and everything that we need to do to add a new route is to create a file inside the /route directory, create a class there, decorate it with a proper decorator, implement the Route interface, and write your own callbacks to handle the endpoints.

We don’t even need to know the registry implementation. The idea behind the registry is that its logic is meant to be hidden before the developer. You can mess with it, of course, but you don’t have to since, in 99% of cases, you don’t need to.

Therefore, if we want to have a delete route for the user, we just have to add this:

That’s it! You have the basis. Now, of course, you can expand it and adjust it to your needs if you want to, e.g. add some validation middleware for every route, add one common error handler that runs after the callback, or wrap every callback in some custom higher order function, etc. The best thing is that you only need to add those features in one place, and they will work for every endpoint that you have currently or that you will create in the future.

PS: We’re hiring and have exciting positions in Poland. Check out our open positions at https://schibsted.pl/career/

--

--