Intro
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.
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:
- Create a class that implements a specific interface (`Route`)
- Decorate the class with a `@Get()` decorator and pass the base for the route (`users`)
- Implement the required method `getEndpoints()` that will return a list of endpoints to register for this route
- Write your own callbacks with specific business logic
The final endpoint will look like this:
@Get("users") class UserGetRoute implements Route { getEndpoints = (): Endpoint[] => [ { url: "/", callback: this.getAllUsers }, { url: "/:id", callback: this.getUserById }, ]; getAllUsers: RequestHandler = async (req, res) => { /* RETURN USERS */ }; getUserById: RequestHandler = async (req, res) => { /* RETURN SINGLE USER */ }; }
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:
{ "compilerOptions": { "lib": ["ES5", "ES6"], "target": "ES6", "module": "commonjs", "moduleResolution": "node", "experimentalDecorators": true } }
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`.
{ "start": "ts-node --transpile-only src/index.ts" }
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.
// src/app/types.ts export type App = { start: () => void; stop: () => void; };
Now, let’s finally implement the factory:
// src/app/index.ts import * as express from "express"; import { App } from "./types"; import { Server } from "http"; export default async function createApp(): Promise{ const app = express(); const port = 3000; let server: Server; function start(): void { server = app.listen(port, () => console.debug(`App started on port ${port}`) ); } function stop(): void { server.close(); } return { start, stop, }; }
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.
// src/index.ts import createApp from "./app"; createApp().then((app) => app.start());
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.
// src/route/registry import { Express } from "express"; export async function registerRoutes(app: Express): Promise{}
Then, add it to our factory:
// src/app/index.ts import { registerRoutes } from "../route/registry"; export default async function createApp(): Promise{ const app = express(); const port = 3000; let server: Server; await registerRoutes(app); function start(): void { server = app.listen(port, () => console.debug(`App started on port ${port}`) ); } function stop(): void { server.close(); } return { start, stop, }; }
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:
@MyCustomDecorator() class DecoratedClass {}
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:
function MyCustomDecorator() { return (decoratedClass) => { /* DO SOMETHING WITH DECORATED CLASS HERE */ } }
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.
// src/route/types.ts import { RequestHandler } from "express"; export interface Route { getEndpoints(): Endpoint[]; } export type Endpoint = { url: string; callback: RequestHandler; }; export type RouteClass = { new (): Route }; export type RouteType = "get" | "post" | "patch" | "delete"; export type RouteDataHolder = { routeName: string; routeClass: RouteClass; routeType: RouteType; }; export type RouteHandler = (routeClass: RouteClass) => void;
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 a `RouteClass` injected when called.
`RouteType` and `RouteDataHolder` are helper types – we will see them in action later on.
Now, we can write our decorator.
// src/route/registry.ts import { RouteDataHolder, RouteHandler } from "./types"; const routes: RouteDataHolder[] = []; export function Get(routeName: string): RouteHandler { return (routeClass) => addRoute({ routeClass, routeName, routeType: "get" }); } function addRoute(routeData: RouteDataHolder): void { const { routeName } = routeData; const doesRouteExist = !!routes.find((r) => r.routeName === routeName); !doesRouteExist && routes.push(routeData); }
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.
// src/route/user.get.ts import { Endpoint, Route } from "./types"; import { RequestHandler } from "express"; import { Get } from "./registry"; @Get("users") class UserGetRoute implements Route { getEndpoints = (): Endpoint[] => [ { url: "/", callback: this.getAllUsers }, { url: "/:id", callback: this.getUserById }, ]; getAllUsers: RequestHandler = async (req, res) => { res.json([{ name: "User1" }, { name: "User2" }, { name: "User3" }]); }; getUserById: RequestHandler = async (req, res) => { res.json({ name: "User1" }); }; }
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`):
- The `addRoute` method will fire up
- The below object will be pushed into the `routes` array.
{ routeClass: UserGetRoute; routeName: "users"; routeType: "get"; }
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.
// src/route/registry.ts import { readdirSync } from "fs"; import { basename, extname } from "path"; async function importRoutes(): Promise{ const files = readdirSync(__dirname); const modules = files .filter((file) => !["registry.ts", "types.ts"].includes(file)) .map((file) => `./${basename(file, extname(file))}`); for (const module of modules) { await import(module); } }
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.
// src/route/registry.ts export async function registerRoutes(app: Express): Promise{ await importRoutes(); }
Now, after we run the app, we should have one object in the `routes` array.
Finally, we can register the routes in Express.
// src/route/registry.ts export async function registerRoutes(app: Express): Promise{ await importRoutes(); routes.forEach((routeData) => registerRoute(routeData, app)); } function registerRoute(routeData: RouteDataHolder, app: Express): void { const { routeClass, routeName, routeType } = routeData; const route = new routeClass(); route .getEndpoints() .forEach((endpoint) => app[routeType](`/${routeName}${endpoint.url}`, endpoint.callback) ); }
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:
getEndpoints = (): Endpoint[] => [ { url: "/", callback: this.getAllUsers }, { url: "/:id", callback: this.getUserById }, ];
So here, `registerRoute()` function will register:
app[routeType](`/${routeName}${endpoint.url}`, endpoint.callback) app['get']('/users/', getAllUsers); app['get']('/users/:id', getUserById);
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.
// src/route/registry.ts export function Get(routeName: string): RouteHandler { return (routeClass) => addRoute({ routeClass, routeName, routeType: "get" }); } export function Post(routeName: string): RouteHandler { return (routeClass) => addRoute({ routeClass, routeName, routeType: "post" }); } export function Patch(routeName: string): RouteHandler { return (routeClass) => addRoute({ routeClass, routeName, routeType: "patch" }); } export function Delete(routeName: string): RouteHandler { return (routeClass) => addRoute({ routeClass, routeName, routeType: "delete" }); }
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:
// src/route/user.delete.ts import { Endpoint, Route } from "./types"; import { RequestHandler } from "express"; import { Delete } from "./registry"; @Delete("user") class UserDeleteRoute implements Route { getEndpoints(): Endpoint[] { return [{ url: "/:id", callback: this.deleteUser }]; } deleteUser: RequestHandler = (req, res) => { const userId = req.params.id; /* DELETE USER */ res.sendStatus(204); }; }
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.