Why I chose routing-controllers over tsoa for an existing Express API
Table of Contents
Why I compared them #
I was adding type-safe routing and OpenAPI documentation to an Express API that already had its own server setup, middleware, and controllers. The two options I considered were routing-controllers and tsoa.
Nest.js was outside the scope of this comparison. It would mean choosing a larger application framework, while I wanted to keep the existing Express application.
The main difference #
Both libraries use TypeScript decorators, but they place the boundary in different places.
| routing-controllers | tsoa | |
|---|---|---|
| Route registration | Registers decorated controller classes at runtime | Generates route code from decorated controllers |
| Existing Express app | Connects through useExpressServer |
Registers generated routes with RegisterRoutes |
| Middleware | Can reuse Express middleware and add class middleware | Uses generated route middleware and tsoa runtime handling |
| OpenAPI | Uses integrations such as routing-controllers-openapi |
Generates a spec through the tsoa CLI |
| Setup cost in this project | Add decorators and routing-controllers options | Add tsoa config, generation commands, and generated output handling |
The choice was not about which library is universally better. I cared more about how much of the existing application I had to move.
Routing and middleware #
routing-controllers keeps the controller close to the existing Express application. In this project, useExpressServer receives the already-created app and adds a route prefix, controllers, middleware, interceptors, and an authorization checker.
useExpressServer(app, {
routePrefix: '/api',
controllers: [/* controller paths */],
middlewares: [HttpErrorHandler, LoggingHandler],
interceptors: [/* interceptor paths */],
authorizationChecker
});
That boundary also made it possible to keep using Request and Response where the application needed them. Middleware did not need to be rewritten only because the route declaration changed.
With tsoa, the application needs a generation step. A typical setup calls the CLI before TypeScript compilation or during development.
{
"scripts": {
"build": "tsoa spec-and-routes -c=tsoa.production.json && tsc -p ./",
"dev": "nodemon -x tsoa spec-and-routes -c=tsoa.json"
}
}
This is a reasonable model for a project that wants generated route files. In the existing Express server, however, it added another output and another step to keep in sync.
Documentation #
Both choices can generate OpenAPI documentation. The integration points were different.
For routing-controllers, I used routing-controllers-openapi and class-validator-jsonschema with the metadata already created by the controllers and DTOs.
const spec = routingControllersToSpec(
getMetadataArgsStorage(),
routingControllerOptions,
additionalProperties
);
For tsoa, the spec and route files are generated from a configuration file. The configuration describes the entry file, controller paths, output directory, base path, and compiler options.
{
"entryFile": "src/server.ts",
"controllerPathGlobs": ["src/**/*.controller.ts"],
"spec": {
"outputDirectory": "src",
"basePath": "/api",
"specVersion": 3
},
"routes": {
"routesDir": "src",
"basePath": "/api"
}
}
The generated approach was convenient once the configuration was in place. It was less attractive here because the server already had a route and documentation setup to extend.
My choice #
I chose routing-controllers for this project because it fit the existing Express boundary. I could move route definitions to decorated controllers, keep the middleware flow, and build the OpenAPI document from the same controller metadata.
That decision does not make tsoa a poor choice. If generated route files, a clear compile-time generation step, and a tsoa-centered project structure were the priority, the trade-off could be different.