One way to slim down bloated Express controllers

If you've ever worked with Express code, you've likely had to read through controllers (also sometimes referred to as "route handlers") that are doing way too much.

So much so, that sometimes you can't even fit all the code for a method on the screen.

These types of controllers are referred to as "fat" / "bloated" controllers.

They're a problem because they're:

  • difficult to reason about
  • agonizing to write tests for
  • typically harder to reuse code from

And everytime you want to add a feature, you have to add that code and make the controller even larger. Or worse, you might even break existing code if you have to refactor what's already there to get your code in.

But by slimming these controllers down, you can make it much easier on yourself. And you can relieve a lot of pain for yourself and your team by establishing this pattern of cleanliness, because you're going to make development and refactoring work much easier going forward.

There are lots of ways to slim these down, but in this post we're going to look at one method of refactoring: getting error handling out of controllers.

Error handling in controllers/route handlers is bad because not only does it bloat them, it results in lots of duplicate code they can't be reused. Imagine you have a controller that's responsible for creating a user. This controller might need to handle several different types of errors, such as validation errors, database errors, and more. If you're handling all of these errors directly in the controller, not only is that controller going to bloat quickly, you might want that same error handling logic elsewhere.

Antipattern and cleaner pattern

So, how can we refactor controllers to remove the error handling? One solution is to use the express-async-errors library. This library monkey-patches the routing methods of Express to handle promise rejections. This means that you can write your controllers as async functions, and if they throw an error, express-async-errors will catch it and pass it to the next error handling middleware.

Here's an example of how you can use it:

// before - fat controller
router.post('/create', async (req, res) => { // this is the controller
    try {
        const user = await userService.createUser(req.body);
        res.json(user);
    } catch (err) {
        res.status(500).json({ message: err.message });
    }
});

// after - slimmed down controller
router.post('/create', async (req, res) => { // this is the controller
    const user = await userService.createUser(req.body);
    res.json(user);
});

This is a simplified example - the "before" code isn't doing too much complex error handling - to demonstrate a simple, quick win to refactor.

If you have other "common" error handling you can also pull this out into middleware you put before your controllers.

For example:

router.post('/create', async (req, res) => {
    try {
        const user = await userService.createUser(req.body);
        res.json(user);
    } catch (err) {
        if (err instanceof userService.errors.ValidationError) {
            res.status(400).json({ message: err.message });
        } else if (err instanceof userService.errors.DuplicateError) {
            res.status(409).json({ message: err.message });
        } else {
            res.status(500).json({ message: 'Internal server error' });
        }
    }
});

Now, if we're handling the above errors the same way in multiple routes, we could refactor it to look like:

// this is a middleware function that gets exported
module.exports = (err, req, res, next) => {
    if (err instanceof ValidationError) {
        res.status(400).json({ message: err.message });
    } else if (err instanceof DuplicateError) {
        res.status(409).json({ message: err.message });
    } else {
        res.status(500).json({ message: 'Internal server error' });
    }
};

// this is the route/controller code
const { errorHandlingMiddleware } = require("./middlewares");
router.post('/create', errorHandlingMiddleware, async (req, res) => {
    const user = await userService.createUser(req.body);
    res.json(user);
});

You could also put the errorHandlingMiddleware in your top-level file, usually something like app.js, where you have the rest of your middleware:

const { errorHandlingMiddleware } = require("./middlewares");
app.use(errorHandlingMiddleware);
app.use(routes);

Summary

Now you have a "code smell"/antipattern you can regonize and a refactoring pattern you can use to correct it. Reading the code will be much easier, code reuse will be much better, and tests will be so much easier to setup and write (setup is often the most difficult part so anything we can do to simplify that is valuable).

I'm writing a lot of new content to help make JavaScript and Node easier. Easier, because I don't think it needs to be as complex as it is sometimes. If you don't want to miss out on one of these new posts, be sure to subscribe below! And I'll be sending out helpful cheatsheets, great posts by other developers, etc. to help you on your journey.

Subscribe for new posts!

No spam ever. Unsubscribe any time.