Stop writing massive monolithic obscure handlers, and start writing arrays of light, small and clean handlers.

The architecture of an ExpressJS relies on two powerful features:

  1. Middleware composition system, that people use to add plugins and characteristics shared by many routes, like authentication.
  2. Router composition system, that people use to keep their sources modularized and organized.

I bet you have seen, even wrote, route handlers that look like the next one:


function handler (req, res) {
    if (req.params.fooId) {
        mongo.collection('foo').findOne(req.params.fooId)
            .then(result => {
                if (result) {
                    res.json(result)
                } else {
                    res.json({error: 404})
                }
            })
            .catch(error => {
                res.json(error)
            })
    } else {
        res.json({error: 400})
    }
}

router.get('/:fooId', hanlder)

Please don't.

You should avoid this, because this kind of handlers are hard to test and scale poorly. They tend fast to spaghetti code, and worse.


More experienced developers extract break down the handler extraction responsibilities in functions, pretty much like this:


function getFoo (fooId) {
    return mongo.collection('foo').findOne(fooId)
}

function reponse (res, result) {
    if (result) {
        res.json(result)
    } else {
        res.json({error: 404})
    }
}

function handler (req, res) {
    if (req.params.fooId) {
        getFoo(req.params.fooId)
            .then(result => response(res, result))
            .catch(error => {
                res.json(error)
            })
    } else {
        res.json({error: 400})
    }
}

router.get('/:fooId', hanlder)

This approach is better than the other, It is more testable, better organized and thus it scales better, but it is not the best we can do.


This is the right way:

function validate (req, res, next) {
    if (!req.params.fooId) {
        return res.json({error: 404})
    }
    next()
}

function query (req, res, next) {
    req.foo = mongo.collection('foo').findOne(req.params.fooId)
    next()
}

async function getFoo (req, res, next) {
    req.foo = await foo.catch(error => ({ error }))
    next()
}

function response (req, res, next) {
    if (req.foo.error) {
        return res.json({error: 400})
    }
    res.json(foo)
}

router.get('/:fooId', [
    validate,
    query,
    getFoo,
    response
])

This code relies on express middleware and router capabilities to structure our handler as a list of sequential, independent, small, understandable, and testable steps.

All your handlers will look like pretty much the same, an array of straightforward functions that you can easily manage and work with.

  • Unit testing gets as easy as it comes.
  • Edge cases can be easily tested
  • Mocking databases and APIs gets easy too
  • It allows a pseudo-integration test inserting tests between the steps, and using any express-liked step runner.
it('pseudointegration test', done => {
    const req = {
        params: {
            fooId: 'someFooId'
       }
    }
    const res = {
        json: jest.fn()
    }
    someStepsRunner([
        step1,
        (req, res, next) => {
            expect(...) // expectations for the step1
            next()
        },
        step2,
        (req, res, next) => {
            expect(...) // expectations for the step2
            next()
        },
        step3,
        (req, res, next) => {
            expect(...) // expectations for the step3
            next()
        },
        () => done()
    ])(req, res)
})

It is amazing! You can test the handler step by step and as a whole at once.

Normally I write one (or two) pseudo-integration tests to cover the main scenario, and a bunch of unit-tests to cover all the cases in every step.

The step runner

It is pretty trivial to write a basic step runner, if you are using plain arrays of functions. It does not need to deal with all the cases that express manages.

I am currently using this kind of basic step-runner for my tests, until I finish a more complete one that I will publish and share.

The downside (or not)

This approach does not let you easily write complex ramified handlers. It is more suited for simple linear handlers.

The array-of-steps handlers can have early ends, but cannot have ramifications.

If there is some handler that you cannot picture as a linear sequence of steps, there are some hacks you can use, but It might be better to use try other approach.

On the other hand, If your handlers needs a lot of ramifications It could be a smell that your API design needs to be rethought.

Conclusion

The array-of-steps pattern is the KISS approach that improves the quality of our handlers. Try it and enjoy it.