What I'd like to point out in this post is pretty common issue I had to deal with over and over again and share some patterns that have worked well on small to large sized projects for me.
I recently came across a route handler that was 2k lines of code long. Some changes had to be made but each time I tried to understand the control flow, 2-3 mouse scrolls down I was feeling lost. Below is just a hint (not an actual project code):
const express = require('express');
const validId = require('./validId');
const router = express.Router();
router.post('/some-route', async (req, res, next) => {
if (!req.body.account_id) {
return res.status(412).json({
code: 412,
message: 'missing body params',
});
}
if (!validId(req.body.account_id)) {
return res.status(400).json({
statusCode: 400,
message: 'invalid id',
});
}
let account;
try {
account = await req.db.Account.findByPk(req.body.account_id);
} catch (err) {
return res.status(500).json({
statusCode: 500,
error: err,
});
if (!req.body.currency) {
return res.status(412).json({
code: 412,
message: 'missing body params',
});
}
// getting more db records
// some operations
// more body params checks
// database transaction
// conditions
// conditions
// 2k LOK later
return res.status(200).json({
statusCode: 200,
message: 'some success message',
});
}
}
module.exports = router;
It might not look so scary from the example above, but trust me, it's just a fraction of whole handler logic.
In such case it becomes very difficult to write unit tests as it would require mocking insane amount of resources. So how to approach this? I broke it into small chunks and assembled back together in form of middleware.
I added some level of security in form of e2e test coverage so I was good to go knowing that crucial parts should work as expected and it couldn't get worse than it is.
I started from extracting request body checks into separate middleware as it felt that this is important initial step which is needed to continue serving the request:
// middleware/validateBodyParams.js
const validId = require('./validId');
module.exports = (req, res, next) => {
const {
account_id: accId,
currency,
} = req.body;
if (!accId || !currency) {
const err = new Error('missing body params');
err.status = 412;
return next(err);
}
if (!validId(accId)) {
const err = new Error('invalid id');
err.status = 400;
return next(err);
}
next();
}
Now this middleware does one thing and does it well. It's easy to reason about and write unit tests for. In case of failed check I pass error to next middleware callback. This step assumes that server is set to capture and handle errors:
// app.js
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
message: err.message,
})
})
Now that I'm sure all body parameters are present and valid I can continue to next step of getting account:
// middleware/getAccount.js
module.exports = async (req, res, next) => {
try {
const account = await req.db.Account.findByPk(req.body.account_id);
res.locals.account = account;
next();
} catch (err) {
next(err);
}
}
It can't get much simpler than this, right? It's safe to refer to
req.body.account_id
in getAccount middleware since I took care of validations in our previous middleware. I've assigned account
to res.locals
. This is a recommended way of sharing data between middleware in express apps.Let's see how it fits inside the handler:
// routes/{version}/{domain|action}/index.js
const express = require('express');
const validateBodyParams = require('./middleware/validateBodyParams');
const getAccount = require('./middleware/getAccount');
const router = express.Router();
router.post('/some-route',
validateBodyParams,
getAccount,
(req, res) => {
const { account } = res.locals;
// getting more db records
// some operations
// more body params checks
// database transaction
// conditions
// conditions
// now less than 2k LOK later :)
return res.status(200).json({
statusCode: 200,
message: 'some success message',
});
}
}
module.exports = router;
Handler is now shorter, it's easier to understand what's happening and the remainder of logic can be split in similar manner. Important to note here that order of middleware matters as it's executed sequentially and getAccount middleware depends on valid body parameters to be in place.
My folder structure usually looks like this:
app/
app.js
models/
routes/
v1/
index.js
someroute/
middleware/
index.js
v2/
index.js
someroute/
middleware/
index.js
This was just a basic example to share some thoughts and maybe help someone make better decisions when organising their projects.
Learn, explore and share.