DEV Community

Denis
Denis

Posted on • Updated on

πŸ’ͺ Express.js on steroids: an OOP way for organizing Node.js project [starring TypeScript]

WARNING! I won't be in charge of anything that you would put in production code. Use the following techniques at your own risk, the code isn't intended for production environment.

Table of contents

  1. Intro
  2. Divide it into layers
  3. Add some OOP
  4. Under the hood
  5. Example

Intro

Well, I like Express.js for its minimalism and beginner-friendliness - this framework is really easy to use. But when code grows, you need a way to organize it somehow. Unfortunately, Express.js doesn't provide any convenient way to do it, so we developers must organize it by ourselves.

Divide it into layers

For convenience, let's divide our server application into separate layers.

A flowchart

  1. Controller - a server unit that receives particular data from the client and passes it to the Service layer
  2. Service - business logic, i.e. pieces of code that are responsible for handling and manipulating data
  3. Model - data from our database, which is well organized by ORM

Add some OOP

Imagine there's a controller that is responsible for authenticating a user. It has to provide login logic and some other.

class AuthController extends Controller {
    path = '/auth'; // The path on which this.routes will be mapped
    routes = [
        {
            path: '/login', // Will become /auth/login
            method: Methods.POST,
            handler: this.handleLogin,
            localMiddleware: []
        },
        // Other routes...
    ];

    constructor() {
        super();
    };

    async handleLogin(req: Request, res: Response, next: NextFunction): Promise<void> {
        try {
            const { username, password } = req.body;    // Get credentials from client
            const userService = new UserService(username, password);
            const result = await userService.login();   // Use login service
            if (result.success) {
                // Send success response
            } else {
                // Send error response
            }
        } catch(e) {
            // Handle error
        }
    };
    // Other handlers...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, routes now look like an array of objects with the following properties:

  • path
  • method: HTTP method
  • handler: particular handler for the path
  • localMiddleware: an array of middleware that is mapped to path of each route

Also, login logic is encapsulated into the service layer, so in the handler, we just pass the data to the UserService instance, receive the result, and send it back to the client.

Under the hood

import { Response, Request, NextFunction, Router, RequestHandler } from 'express';

// HTTP methods
export enum Methods {
    GET = 'GET',
    POST = 'POST',
    PUT = 'PUT',
    DELETE = 'DELETE'
};

// Route interface for each route in `routes` field of `Controller` class.
interface IRoute {
    path: string;
    method: Methods;
    handler: (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
    localMiddleware: ((req: Request, res: Response, next: NextFunction) => void)[]
};

export default abstract class Controller {
    // Router instance for mapping routes
    public router: Router = Router();
    // The path on which this.routes will be mapped
    public abstract path: string;
    // Array of objects which implement IRoutes interface
    protected abstract readonly routes: Array<IRoute> = [];

    public setRoutes = (): Router => {
    // Set HTTP method, middleware, and handler for each route
    // Returns Router object, which we will use in Server class
        for (const route of this.routes) {
            for (const mw of route.localMiddleware) {
                this.router.use(route.path, mw)
            };
            switch (route.method) {
                case 'GET':
                    this.router.get(route.path, route.handler);
                    break;
                case 'POST':
                    this.router.post(route.path, route.handler);
                    break;
                case 'PUT':
                    this.router.put(route.path, route.handler);
                    break;
                case 'DELETE':
                    this.router.delete(route.path, route.handler);
                    break;
                default:
                    // Throw exception
            };
        };
        // Return router instance (will be usable in Server class)
        return this.router;
    };
};
Enter fullscreen mode Exit fullscreen mode

Well, everything seems pretty trivial. We have a Router instance which we use as an "engine" for every instance of a class that will be inherited from the abstract Controller class.

Another good idea is to look at how the Server class is implemented.

class Server {
    private app: Application;
    private readonly port: number;

    constructor(app: Application, database: Sequelize, port: number) {
        this.app = app;
        this.port = port;
    };

    public run(): http.Server {
        return this.app.listen(this.port, () => {
            console.log(`Up and running on port ${this.port}`)
        });
    };

    public loadGlobalMiddleware(middleware: Array<RequestHandler>): void {
        // global stuff like cors, body-parser, etc
        middleware.forEach(mw => {
            this.app.use(mw);
        });
    };

    public loadControllers(controllers: Array<Controller>): void {
        controllers.forEach(controller => {
            // use setRoutes method that maps routes and returns Router object
            this.app.use(controller.path, controller.setRoutes());
        });
    };

    public async initDatabase(): Promise<void> {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

And in index.js:

const app = express();
const server = new Server(app, db, PORT);

const controllers: Array<Controller> = [
    new AuthController(),
    new TokenController(),
    new MatchmakingController(),
    new RoomController()
];

const globalMiddleware: Array<RequestHandler> = [
    urlencoded({ extended: false }),
    json(),
    cors({ credentials: true, origin: true }),
    // ...
];

Promise.resolve()
    .then(() => server.initDatabase())
    .then(() => {
        server.loadMiddleware(globalMiddleware);
        server.loadControllers(controllers);
        server.run();
    });
Enter fullscreen mode Exit fullscreen mode

Example

I used this organizing practice in my recent project, source code of which you can find here: https://github.com/thedenisnikulin/chattitude-app-backend

That's pretty much it, thank you for reading this article :).

Discussion (10)

Collapse
ilhamtubagus profile image
Fian • Edited on

Good article, very helpful. I wonder why i got error when i'm trying to access another function within my class controller. For example i have async handleLogin function that handle login request, inside this function i'm calling another function within the same class controller.

Collapse
mbisurgisoshace profile image
mbisurgisoshace • Edited on

That is because the functions are not being called like a regular class method, so for intance AuthController.method(). They are being called as callback functions by express, so the 'this' keyword reference is lost. The way to solve it is to bind the 'this' context. So for example in this case, where you have handler: this.handleLogin, you just need to do this.handleLogin.bind(this). Hope this was helpful!

Collapse
slawton3 profile image
Sean Lawton

Awesome article, thanks for sharing.

Collapse
tperrinweembi profile image
tperrin

Hi Denis, I like very much your way to organise all your code using class. About class, I am confused about your way to use some of them and brings one question:

For example with the AuthController, when you start the server and load all controllers, you create an instance with new AuthController(), is it this same instance that will be shared by every user calling the server, or does each user run the whole code on his side and will have his own instances?

Also, the class AuthController : you create a new instance of the class UserService in each handler of the AuthController :

class AuthController extends Controller {
        ...
        async handleLogin(req, res, next){
            try  {
                    const userService = new UserService(username, password);
                    const data = await userService.login();
                ...
    }

        async handleRegister(req, res, next){
            try  {
            const userService = new UserService(username, password);
            const data = await userService.register();
        ...
    }
Enter fullscreen mode Exit fullscreen mode

Couldn't we instanciate it just once before the class declaration and them use each time the same instance in every handler:

import UserService from '../services/UserService';
const userservice = new UserService()

class AuthController {
    async handleLogin(req, res, next){
            const data = await userService.login()...}

    async handleRegister(req, res, next){
        const data = await userService.Register();}
}
Enter fullscreen mode Exit fullscreen mode

Is it a proble to make each handler share the same instance of the UserService?

Thanks a lot for sharing this awsome work, I totaly refactored my code using class after seing your work.

Collapse
thedenisnikulin profile image
Denis Author

Hi, sorry for a late reply. You are right, the code that I've published lacks efficient instance usage. You can implement dependency injection for services, that's what I did later when noticed that drawback. Controllers have only 1 instance per program so yes, they are shared.

Collapse
tperrinweembi profile image
tperrin

I even took the reasoning further: still for the service class calls in controller classes, what do you think about creating the methods as static, so we don't even need to instanciate the class, we just need to import it once and then call it straight, that would be something like that:

Static methods in service class:

class UserService {
    constructor() {}
    static login = async(credentials) => { ... } 
    static setUser = async(user) => { ... } 
    ....
}
Enter fullscreen mode Exit fullscreen mode

And then in the controler class

import UserService from '../services/UserService'

class AuthController exends Controller {
  async handleLogin(req, res, next) {
      const data = await UserService.login() ... }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
tperrinweembi profile image
tperrin

Ok thanks a lot for your reply. Since I don't get what's under the hood, I see the difference ways to do this but I don't know at all the pro & cons of each... Thanks again for your reply.

Collapse
wardvisual profile image
Edward Fernandez

Thank you so much! This is great!

Collapse
bursatilboy profile image
El Bursa

Thank you so much!! this is what i was looking for to implement in my project!

Collapse
japhernandez profile image
John Piedrahita

Look at this post, please …dev.to/japhernandez/clean-architec...