Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets and other freebies.
When you're writing code dealing with the request lifecycle, what is the difference between middleware and controllers? What code goes in each? They both touch the "request object" and do things with it, so it seems like there might be overlap or controllers might be redundant to have.
But they are distinctly different, and understanding the differences in the use cases for each logic type is important in structuring maintainable and scalable applications. Let's examine the distinctions between the two and what code goes where, so the next time you are working on an API you can build it sustainably.
Note: this post focuses on middleware and controllers from a NodeJS perspective but is applicable to other languages and frameworks.
Middleware vs. Controllers
What's immediately confusing is that a controller can be considered a type of middleware. (And to add to the confusion, "controllers" are sometimes referred to as "route handlers".) But middleware - compared to controllers - handles HTTP-specific concerns as well as concerns that are common to every (or most) request. "HTTP concerns" are things like handling CORS, parsing the body, cookies, etc. "Common concerns" are things like request validation, sessions, request logging using something like morgan
, and authentication and authorization. Middleware is basically agnostic of application logic.
All of the above you don't want in your controllers. Why? Because controllers are responsible for routing a given request where it needs to go for "processing". They don't directly deal with application logic, but send the request to code that does. Controllers should be fairly "thin" and not do a whole lot. So, a "book order" controller would take the request object after it's already been "pre-processed" by middleware and "successfully passed" it, pull out what data it needs from either the query string or body and send it to the service layer/domain logic layer to execute the business logic.
Middleware functions are typically (although not always) passed through by all HTTP requests and do not fulfill the request, unless it's a case of returning an error response early. It's the controller that ultimately fulfills the request with a successful response. They are usually are specific to a single endpoint (like /items
, /orders
, etc).
Example code
Controller code will look something like this:
const express = require('express')
const router = express.Router()
// Controller
const createOrder = async (req, res, next) => {
// grab what we need from the request...
const {customerId, orderTotal, orderItems, paymentDetails} = req.body
try {
// ...then route it to the appropriate business-logic-processing functions...
const customerData = await getCustomerData(customerId)
await processOrder(orderTotal, orderItems, paymentDetails, customerData)
await sendConfirmationEmailToCustomer(customerId, orderItems)
// ...then fulfill the request with the response object
res.sendStatus(201)
return
} catch (err) {
res.sendStatus(500)
return
}
}
// Route definition
router.post('/order', createOrder)
module.exports = router
Above you will notice that the controller grabs what we need from the request, routes it to the appropriate business-logic-processing function(s), and finally fulfills the request via the response (res
) object.
Then the chain of middleware will look similar to this, usually called either app.js
or server.js
:
const express = require('express')
const app = express()
// middleware library imports
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const passport = require('passport') // for auth
const routes = require('./routes') // this is a .js file that contains all your route definitions mapped to their respective controllers
// here is the middleware being chained together
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieParser)
app.use(passport.initialize())
// routes defined last in the chain of middleware
app.use('/api', routes)
In the above, we chain the middleware together, using app.use()
, before we have the routes (which map to controllers) at the end of the chain, so that the request has to pass through the middleware in order before even hitting the controllers.
Note: if I need to build custom middleware I will generally add a directory called middleware/
to be at the same level as the controllers/
directory, and put my custom middleware functions there.
Summary
While it may seem confusing at first, the difference between these two "layers" / logic types are generally fairly clear, so it should make sense now as to what code goes where. Occasionally, you may run into a scenario where it's less black and white, but then you just have to decide what makes sense for where to put the code based on you specific application requirements.
Why does this matter? If it's not obvious, this separation of concerns is necessary for developing a maintainable codebase and API. You will save yourself a lot of painful refactoring in the future if you can achieve this separation, because as your code grows and you add more developers to the team things can start to get out of hand quickly.
Love JavaScript but still getting tripped up by unit testing, architecture, etc? I publish articles on JavaScript and Node every 1-2 weeks, so if you want to receive all new articles directly to your inbox, here's that link again to subscribe to my newsletter!