In most server-side web apps/APIs we’ll often use some form of middleware to enhance how our apps work. Typically, this is achieved by registering a special form of request handler upstream of our endpoints. This middleware is assigned to a specific route pattern. Hence, middleware are decoupled at the framework- or router-level from our endpoints.
// Middleware:
app.use("/authed/*", authMiddleware);
// ...
// ...
// Request handler:
app.get("/authed/me", meHandler);
Given software is complex, and this complexity is only ever increasing, middleware as a concept was inevitable: it allows us to establish complex heirarchies of functionality, instead of cramming a bunch of stuff into one place. Not to mention, we avoid repeating ourselves which helps codebase maintenance, reduces complexity and uplifts the general sanity and fulfilment of developers.
This approach of registering middleware separate to our routes indeed confers these benefits and has become the industry standard as a consequence. But I think it’s the incorrect abstraction.
All else being equal, decoupling, is a productive goal. In most cases, two components of software being independent means we avoid entangling ourselves in complexity as our applications grow.
By using the router as the central decision maker for both middleware and request handlers, we have decoupled the middleware from the request handlers. But in turn, we have coupled middleware to the router.
But why should middleware and request handlers be decoupled? It’s a good idea for the implementations of the middleware to be decoupled from what is downstream. But request handlers are often directly, strongly reliant on the middleware preceding it. The target of a web request is ultimately the endpoint, not the router or middleware.
Middleware often provides critical information, functionality or state to subsequent middleware or request handlers. This middleware may not care about what’s downstream, but what’s downstream often cares a lot about the upstream middleware. Request handlers often entirely depend on middleware executing. In most cases, middleware is far more coupled to the handler itself as opposed to some route path.
app.get("/authed/me", (req) => {
req.user; // <-- Is this defined?
});
Imagine a non-trivial version of the above request handler. Determining which middleware will execute, what it does and therefore what context the request handler has, or if it will run at all, is no small feat.
// Powerful... I guess
app.use("/*/post/[a-zA-Z0-9]+", alphaNumericPostMiddleware);
Router-based middleware enables us to define powereful and complex middleware pipelines. But in practice, this power is rarely used, let alone needed. On the other hand, we have effectively adopted a third-party custom abstraction for function calling. Consequently, language-level affordances like clicking through code definitions, type safety and compiler optimisations are now unavailable.
While writing router-based middleware is very simple, it’s cost becomes clearer during the reading, comprehension, maintenance and scaling of complex codebases. When we view middleware in this way, it prompts us to ask why we adopted this approach in the first place.
Middleware is essentially solving a composition problem, allowing us to compose more complex pipelines for web requests. Routing is just the medium with which this has traditionally been accomplished.
Middleware are basically request handers, right? Why not just attach them to the router as well?
While using the router as the source of truth has some benefits, it makes much more sense to “attach” middleware to our request handlers themselves. When we frame the problem this way, we’ll realise we already have existing patterns to accomplish this. But instead of routing patterns, these are just regular programming patterns. We can abandon one layer of abstraction entirely.
Without any concept of middleware, we might write:
app.get("/...", (req) => {
isAuthed(req);
req.user;
});
This is very easy to understand. All the information is in one place. It makes customisation much simpler and provides more context around what the request handler expects or requires. Of course, as our app grows, we might have many more pieces of “middleware” called.
Using this express
-like syntax, we could actually compose multiple middleware for our handler inline.
app.get(
"/...",
authedMiddleware,
cookieMiddleware,
moreMiddleware
/**...*/
);
While this approach is slightly cumbersome to write, it’s does have the benefit of explicitly defining what the handler requires, in the same place. And that becomes profoundly useful in complex codebases.
If we wanted, we could also group common middleware, but without resorting to bespoke router-specific APIs.
export const adminMiddleware = (req) => {
authedMiddleware(req);
roleMiddleware(req, "admin");
};
app.get("/...", adminMiddleware /**...*/);
Additionally, this approach also tells the programming language, in very idiomatic terms, what functionality relies on what. Using regular composition – at the language- instead of router-level – we regain many powerful features that make many aspects of our app better: debugging, comprehension, maintenance, type safety, compiler optimisations.
Conceptually, we have shifted the API topology from:
/path -> [Middleware]
/path -> [Middleware]
/path -> [Request handler]
where the router is figuring out what should run and in which order, to something like:
/path -> [Request handler with middleware]
Consequently, our app structure is flattened. Our request handlers are now opaque (to the router) functions with full, clear ownership.
By decoupling middleware from routers we not only gain more router flexibility, but in some cases we can avoid routers altogether. For example, by using some code-level composition, instead of router-based composition, we have portable routerless endpoints. This is particularly useful in serverless contexts or with filesystem routers, like nextjs.
As type safety is increasingly leaned into for system reliability and scalability, composition offers a clear advantage. As mentioned, router-based middleware ejects us from idiomatic code, and leans into a bespoke, although familiar, router-based interface.
Of course, further bespoke tooling could concievably be created to support such things with the router approach. But languages provide this out of the box, and with regular function composition we get it for free.
When building webroute
(GitHub), I leaned into this idea heavily. In conjunction with a builder-style route builder, creating atomic route handlers with type-safe middleware built in becomes trivial.
const authedRoute = route().use((req) => {
// <- .use() middleware on route
const user = getUser(req);
if (!user) throw new Error();
// Return state, type-safely
return { user };
});
app.get(
"/foo",
authedRoute.handle((req) => {
// <- Extend route, adding request handler
req.user;
// ^^^^ The type checker, compiler and human know
// this exists
})
);
By using this approach we can easily follow the code via reference to understand what’s going on. Our route handlers are now entirely decoupled from routers, meaning changing between frameworks or routers is much simpler, if not entirely trivial.
While router-based middleware is the common approach, I think we’ll see composition-based strategies come into fashion. As features like type-safety or concerns like comprehensibility are increasingly important, the router-based methods become too brittle to cope with the required complexity and reliability of modern systems. While this may seem pedantic, or just flat-out wrong, I would prompt you to consider various bugs in complex backends, and consider how making tracing which path a request flows through easier might have made squashing the bug easier too.
This “composition” approach is more aligned with traditional principles of good software development and should be the default approach. Only in cases that specifically require it, should we be reach for the more flexible, but harder to reason about, router-based methods.
A note on webroute
This post is one of several, spurring the creation of an open source project called webroute. It is a toolkit built on web-standards for building server side apps with Type/JavaScript. Webroute employs many of the ideas and philosophies presented in essays like this one. Please check it out, raise an issue or read the docs, if this sounds interesting.