Step-by-Step Guide to Creating a NodeJS/ExpressJS API Using Controller Pattern
Introduction
Abstraction is a core objective of programming language frameworks, including LaravelPHP, NestJS, and ASP.NET. These frameworks implement the MVC (Model-View-Controller) pattern, effectively abstracting HTTP handling, routing, and database processes.
For a hands-on example of creating controllers and routers with vanilla PHP, check out this series: PHP Vanilla Blog.
In this article, we'll explore how to achieve similar functionality with NodeJS and ExpressJS. Additionally, we'll leverage TypeScript decorators extensively to build robust controllers.
NodeJS / ExpressJS Controllers Pattern API
Setup
Let's create a dummy API to implement this pattern. First, create a folder and initialize npm:
mkdir nodexp-controllers
cd nodeexp-controllers
npm init -y
Create package.json
file with the following content:
{
"name": "nodexp-route-controller",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"dev": "nodemon -r tsconfig-paths/register src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "NodeJS and ExpressJS API with Controllers pattern",
"dependencies": {
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"reflect-metadata": "^0.2.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
},
"devDependencies": {
"@types/body-parser": "^1.19.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"eslint": "^8.57.0",
"nodemon": "^3.1.4",
"prettier": "^3.3.3",
"typescript": "^5.5.4"
}
}
Now install the dependencies:
npm i
Create tsconfig.json
file:
{
"compilerOptions": {
"target": "es6",
"module": "CommonJS",
"sourceMap": false,
"strict": true,
"outDir": "./dist",
"esModuleInterop": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"experimentalDecorators": true, // Required to work with decorators
"emitDecoratorMetadata": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/**/*.ts"],
"exclude": ["node_modules", "public"]
}
Folder Structure
Inside the root directory, we will have the following files and directories:
.
├── src/
│ ├── config/
│ │ └── api.config.ts
│ ├── controllers/
│ │ ├── customer.controller.ts
│ │ └── index.controller.ts
│ ├── decorators/
│ │ └── controller.decorator.ts
│ ├── middlewares/
│ │ └── auth.middleware.ts
│ ├── services/
│ │ └── metadata.service.ts
│ ├── utils/
│ │ └── ControllerScanner.ts
│ ├── app.ts
│ └── server.ts
└── .env
ENV
Create a .env
file to include the port and a dummy auth key:
PORT=3000
AUTH_KEY=test
API Config
Create an api.config.ts
file:
const apiConfig = {
prefix: '/api',
};
export default apiConfig;
ExpressJS APP
In app.ts
, handle the ExpressJS app and the middlewares, including the controllers scanner.
import express, { Express, Request, Response } from 'express';
import bodyParser from 'body-parser';
import { config } from 'dotenv';
import { scanForControllers } from './utils/ControllerScanner';
import cors from 'cors';
config();
export class App {
app: Express;
constructor() {
this.app = express();
this.initMiddlewares();
this.setupControllers();
this.setupRouter();
}
listen() {
const PORT: number = parseInt(process.env.PORT as string, 10);
this.app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
}
private initMiddlewares() {
this.app.use(express.json());
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({ extended: true }));
const corsOptions = {
origin: process.env.APP_ENV == 'development' ? '*' : process.env.ORIGIN,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
optionsSuccessStatus: 204,
};
this.app.use(cors(corsOptions));
}
private setupControllers() {
scanForControllers(this.app);
}
private setupRouter() {
this.app.all('*', (_: Request, response: Response) => {
const notFoundMessage = {
error: 'Error 404 - Not Found',
};
response.status(404).json(notFoundMessage);
});
}
}
The key component in this file is the ControllerScanner
, which we'll detail later.
Server
In server.ts
, the goal is to launch the ExpressJS app:
import { App } from './app';
const server = new App();
server.listen();
Auth Middleware
This middleware serves as a dummy implementation to showcase middleware usage in controllers:
import { Response, Request, NextFunction } from 'express';
export function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['x-auth'];
if (authHeader) {
if (authHeader === process.env.AUTH_KEY) {
next();
} else {
res.status(403).json({ error: 'Access forbidden: Invalid token' });
}
} else {
res.status(401).json({ error: 'Access denied: No token provided' });
}
}
Controllers
Controller Scanner
This file scans the controllers directory and auto-detects controller classes:
import { join } from 'path';
import { Express } from 'express';
import 'reflect-metadata';
function scanForControllers(app: Express) {
const fs = require('fs');
// Read the directory and fetch the files matching *.controller.ts
function walkDir(currentDir: string) {
const files = fs.readdirSync(currentDir);
for (const file of files) {
const filePath = currentDir + '/' + file;
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walkDir(filePath); // Recursively walk through subdirectories
} else if (file.endsWith('.controller.ts') && !file.startsWith('_schema.')) {
// Check for “.controller.ts” files
try {
// Require the controller module. (It must be default exported!)
const controller = require(filePath).default;
if (controller && typeof controller === 'function' && controller.prototype) {
// Handle dependency injection. Until now, it support 1 layer dependency.
const params = Reflect.getMetadata('design:paramtypes', controller) || [];
const resolvedParams = params.map((param: any) => {
// Check if dependency matches parameter type
if (typeof param === 'function') {
return new param();
}
// Handle non-dependency parameters (e.g., primitive types)
return undefined;
});
// Initilize the controller base class (Check the controller decorator)
const instance = new controller(...resolvedParams);
instance._registerRoutes(app, instance);
} else {
console.warn(`Ignoring non-controller export in: ${filePath}`); // Handle non-constructible exports
}
} catch (err) {
console.error(`Error loading controller file: ${filePath}`, err);
}
}
}
}
walkDir(join(__dirname, '../controllers')); // Start the recursive walk from the specified directory
}
export { scanForControllers };
Controller Decorator
This file contains the necessary decorators and the base controller class.
Decorators: We will have three types of decorators:
HTTP Verbs decorators
Auth Guard Decorator
Controller Decorator
Base controller class: This class will be inherited by the controller classes and creates ExpressJS route handlers for each controller:
Inside controller.decorator.ts
, write the following code:
import { Router, RequestHandler, Express } from 'express';
import MetadataService from '@/services/metadata.service';
interface Route {
path: string;
methodName: string;
middlewares: RequestHandler[];
method: 'get' | 'post' | 'patch' | 'put' | 'delete';
}
// Store or update the route information given from HTTP Verbs decorators
function HTTPMethodDecorator(route: Route) {
// Get existing routes or create an empty array
const existingRoutes: Route[] = MetadataService.get('routes') || [];
// Store the path and method information
existingRoutes.push(route);
// Update the metadata with the combined routes
MetadataService.set('routes', existingRoutes);
}
// Decorator for defining Express GET routes
export function Get(path: string, middlewares: RequestHandler[] = []) {
return function (_: Object, propertyKey: string) {
HTTPMethodDecorator({ path, methodName: propertyKey, method: 'get', middlewares });
};
}
// Decorator for defining Express POST routes
export function Post(path: string, middlewares: RequestHandler[] = []) {
return function (_: Object, propertyKey: string) {
HTTPMethodDecorator({ path, methodName: propertyKey, method: 'post', middlewares });
};
}
// Decorator for defining Express PATCH routes
export function Patch(path: string, middlewares: RequestHandler[] = []) {
return function (_: Object, propertyKey: string) {
HTTPMethodDecorator({ path, methodName: propertyKey, method: 'patch', middlewares });
};
}
// Decorator for defining Express PUT routes
export function Put(path: string, middlewares: RequestHandler[] = []) {
return function (_: Object, propertyKey: string) {
HTTPMethodDecorator({ path, methodName: propertyKey, method: 'put', middlewares });
};
}
// Decorator for defining Express DELETE routes
export function Delete(path: string, middlewares: RequestHandler[] = []) {
return function (_: Object, propertyKey: string) {
HTTPMethodDecorator({ path, methodName: propertyKey, method: 'delete', middlewares });
};
}
// Decorator for Authentication Middleware
export function AuthGuard() {
return function (_: Object, propertyKey: string) {
const existingRoutes: Route[] = MetadataService.get('routes') || [];
// It gets the route object specific for the method that this decorator attached to.
const match = existingRoutes.find((route) => route.methodName === propertyKey);
if (match) {
match.middlewares.unshift(authenticateJWT);
}
// Update the metadata
MetadataService.set('routes', existingRoutes);
};
}
// The first parameter is the controller's name. I implemented it when using Swagger. Now it's not usefull for anything.
export function Controller(_: string, version?: string, prefix?: string) {
return function (target: Function) {
Reflect.defineMetadata('prefix', prefix ?? '', target);
Reflect.defineMetadata('version', version ?? '', target);
};
}
// Base Controller class
export class BaseController {
private router = Router();
public _registerRoutes(app: Express, controllerClass: any) {
// Get routes defined on the class
const routes: Route[] = MetadataService.get('routes') || [];
const prefix = Reflect.getMetadata('prefix', controllerClass.constructor) || '';
const version = Reflect.getMetadata('version', controllerClass.constructor) || '';
if (!routes) return;
// Loop through routes and register them with Express
routes.forEach((route: Route) => {
// Get only methodes declared in the instance
if (route.methodName in controllerClass) {
const handler: Function = controllerClass[route.methodName as keyof typeof controllerClass];
// Generate for example: router.get("/api/v1/customers", ...middleweres, handler)
this.router[route.method](route.path, ...route.middlewares, handler.bind(controllerClass));
}
});
app.use(apiConfig.prefix + '/' + version + prefix, this.router);
}
}
The Controller decorator handles the data of the route, like prefix and version (to get this for example: /api/v1
). This controller is simple and doesn't do much
I used this controllers in another project, where I implemented ZOD and Swagger. And used this decorator to generate the Swagger controller summary and tags. Also implemented the ZOD schema parser in each HTTP Verb to fetch the Request Object for Swagger metadata.
Metadata Service
This service allows us to get/set metadata:
// src/services/metadata.service.ts
import 'reflect-metadata';
export default class MetadataService {
static set(key: string, value: any) {
Reflect.defineMetadata(key, value, this);
}
static get(key: any) {
return Reflect.getMetadata(key, this);
}
}
Creating Controllers
Create two sample controllers:
Note: If a property isn't used in the scope, you can replace it with
_
. Typescript won't complain if you do that.
Index Controller
This controller defines a basic route:
import { Request, Response, NextFunction } from 'express';
import { AuthGuard, BaseController, Controller, Get } from '@/decorators/controller.decorator';
// The controller decorator takes the version prefix and route URI
// This controller will generate route: "/api/v1/"
@Controller('Customer', 'v1', '/')
export default class CustomerController extends BaseController {
// The methods are basic ExpressJS functions, you can use them as you use ExpressJS.
@Get('/')
public async getApiStatus(_: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({
name: 'API',
version: '1.0.0',
status: 'RUNNING',
});
} catch (err) {
next(err);
}
}
// Adding this decorator will inject the Auth middleware for this route
@AuthGuard()
@Get('/protected')
public async getProtectedApiStatus(_: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({
name: 'API',
secure: true,
version: '1.0.0',
status: 'RUNNING',
});
} catch (err) {
next(err);
}
}
}
You can remove the @AuthGuard
decorator and add the middleware in @Get
decorator as well. Like this:
@Get('/protected', [authenticateJWT])
public async getProtectedApiStatus(_: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({
name: 'API',
secure: true,
version: '1.0.0',
status: 'RUNNING',
});
} catch (err) {
next(err);
}
}
Customers Controller
This controller defines routes to manage customers:
import { Request, Response, NextFunction } from 'express';
import {
AuthGuard,
BaseController,
Controller,
Delete,
Get,
Post,
Put,
} from '@/decorators/controller.decorator';
@Controller('Customer', 'v1', '/customers')
export default class CustomerController extends BaseController {
@Get('/')
public async getCustomers(_: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({ message: 'Customer list' });
} catch (err) {
next(err);
}
}
@AuthGuard()
@Get('/protected')
public async getProtectedCustomers(_: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({ message: 'Protected Customer list' });
} catch (err) {
next(err);
}
}
// You can use the dynamic routes as well
@AuthGuard()
@Get('/:id')
public async getCustomer(request: Request, response: Response, next: NextFunction) {
try {
response.status(200).json({ message: 'Customer id ' + request.params.id });
} catch (err) {
next(err);
}
}
@AuthGuard()
@Post('/')
public async createCustomer(_: Request, response: Response, next: NextFunction) {
try {
response.status(201).json({ message: 'Customer created' });
} catch (err) {
next(err);
}
}
@AuthGuard()
@Put('/')
public async updateCustomer(_: Request, response: Response, next: NextFunction) {
try {
response.status(201).json({ message: 'Customer updated' });
} catch (err) {
next(err);
}
}
@AuthGuard()
@Delete('/')
public async deleteCustomer(_: Request, response: Response, next: NextFunction) {
try {
response.status(201).json({ message: 'Customer deleted' });
} catch (err) {
next(err);
}
}
}
Conclusion
This pattern improves the structure of your NodeJS and ExpressJS projects by organizing code into distinct layers and promoting reusability. While the decorators and the controller base class add some complexity, they provide a robust way to manage routes and middleware.
Benefits:
Organization: Clear separation of concerns.
Reusability: Middleware and handlers can be reused.
Scalability: Easier to add new features.
This article aimed to explore the implementation of design patterns and industry standards. While the provided code may not be optimal for production use, it serves as a valuable exercise in understanding the inner workings of frameworks and their abstractions.
For a more comprehensive and use-ready API, which includes enhanced features with ZOD and Swagger, check out the project on GitHub.
You will find also this code in this GitHub Repository
Subscribe to my newsletter
Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mehdi Jai
Mehdi Jai
Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.