hckr.fyi // thoughts

Decoupling Express JavaScript Routes from Your Main Application File

by Michael Szul on

Express is a great web framework for Node.JS applications, and it provides exactly the type of model/view/controller (MVP) architecture that you would expect from a modern web application. With a traditional Express applications, routes are defined directly in the entry point of your application (the main file that starts your application server), but as applications grow, the logic in that file continues to balloon. Some of this logic is application and server setup code, while the rest tends to be route functions: An application with many routes, ends up being an application with a very large main file clogged by those same routes.

It stands to reason that you may want to abstract or decouple this logic a little further. The "C" in MVC stands for "controller" after all, and we've technically been throwing that "C" into the main application entry point for convenience.

Typically, we can clean this up by using a separate route file. For this example, let's assume route.ts is that file. Your route file could look something like this:

This example code is written in TypeScript, but it'll work just fine with JavaScript in absence of typings. Just be sure to follow the syntax you're accustomed to.

import { indexGetAction } from './controller/indexActions';
    import { exampleListAction, exampleFormAction, exampleDeleteAction } from './controller/exampleActions';
    
    export const routes = [
        {
            path: '/',
            method: 'get',
            action: indexGetAction
        },
        {
            path: '/Example/List', //List of Example Entries
            method: 'get',
            action: exampleListAction
        },
        {
            path: '/Example/Form', //New or Edit Example
            method: 'get',
            action: exampleFormAction
        },
        {
            path: '/Example/Form', //Submit Example
            method: 'post',
            action: exampleFormAction
        },
        {
            path: '/Example/Delete', //Remove an Example
            method: 'get',
            action: exampleDeleteAction
        }
    ];
    

You can see that this is just an array of objects that contain the route path, the method (GET or POST for standard web applications), and the function name to pass the Express request and response to. We'll get to these actions in a bit, but note that I'm pulling them from files in a "controller" folder in the application.

Now we can wipe out all of the routes in the index.ts file (or app.ts depending on what your entry point file is named), and instead, loop through the routes dynamically.

At the top, we'll need to import our routes:

import { routes } from './routes.ts';
    

Then we can perform the aforementioned loop:

routes.forEach((route: any): void => {
        app[route.method](route.path, (req: express.Request, res: express.Response, next: Function): void => {
            route.action(req, res)
                .then((): any => next)
                .catch((err: any): any => next(err));
        });
    });
    

There isn't a whole lot to say about this piece of code. We're simply looping over the routes, and doing what would have normally been an app.get() or app.post(), except we're dynamically determining which method to call based on the HTTP method specified in the routes. We're then calling the action that we specified in the route file and passing in the Express request and response objects that you would expect.

What about the controller file? Let's take a look:

export async function indexGetAction(req: express.Request, res: express.Response): Promise<any> {
        res.render('index');
    }
    

I'm purposely using the index page here without any real code--just a straight render--so that you can focus on the function itself. There is no real difference between this external function and the anonymous function that you would have if you did this in the main entry point:

app.get('/', async (req: express.Request, res: express.Response): Promise<any> => {
        res.render('index');
    });
    

The difference is that I took that anonymous function and externalized it into a controller file in order to decouple the logic from the main entry point, and better clean up the code.