When structuring Express apps, middleware play a crucial role in handling cross-cutting concerns like logging, security, and error handling. However, as our middleware chain grows, managing the installation order can quickly become messy. In this post, I’ll go over a few patterns for installing Express middleware - from simple to more advanced - and discuss the pros and cons of each approach. The goal is to provide some ideas and best practices to keep our middleware pipeline maintainable as our app grows.
Practice 1: Installing Middleware Sequentially
This approach installs middleware by specifying each one sequentially in code:
1 | app.use(cors()); |
This loads the middleware in a predefined order, which works for simple use cases.
However, this approach has some downsides:
- The middleware loading order is inflexible. Dependencies between middleware aren’t handled automatically.
- It makes it harder to share middleware configuration across projects.
- There’s no central place to see what middleware is in use.
- It doesn’t facilitate configuration per environment.
- Testing middleware in isolation is more difficult.
Practice 2: Installing Middleware by Priority
While installing middleware sequentially directly in code is simple, it can quickly become unmanageable for larger apps. To add more structure, we can define middleware priorities and install them in a sorted order based on those priorities. This allows us to control the middleware loading order more granularly.
This approach assigns a priority to each middleware and installs them sorted by priority:
Define middleware priorities:
1
2
3
4
5
6const MiddlewarePriority = {
Logging: 1,
BodyParser: 2,
Helmet: 3,
ErrorHandler: 4,
};Register middleware with priorities:
1
2
3
4
5
6const middlewares = new Map();
middlewares.set(MiddlewarePriority.Logging, loggingMiddleware);
middlewares.set(MiddlewarePriority.BodyParser, bodyParserMiddleware);
middlewares.set(MiddlewarePriority.Helmet, helmetMiddleware);
middlewares.set(MiddlewarePriority.ErrorHandler, errorHandlerMiddleware);Install middleware in priority order:
1
2
3
4
5
6
7
8
9function installMiddlewares(middlewaresMap) {
const sortedMiddlewares = [...middlewaresMap]
.sort((a, b) => a[0] - b[0])
.map((m) => m[1]);
sortedMiddlewares.forEach((middleware) => {
app.use(middleware);
});
}
Drawbacks:
- Hard to add new middleware between priorities (e.g. CORS after BodyParser). No unused priority values.
- Priorities could become out of sync if new middleware added.
- Still need to register middleware in code.
Practice 3: Topological Sorting for Middleware Ordering
While defining middleware priorities allows more control over ordering than installling sequentially, it still has limitations when new middleware needs to be added between existing priorities. This leads us to a more flexible approach - modeling middleware as a directed graph based on before/after dependencies and topologically sorting to resolve the order. This avoids priority conflicts altogether by letting us explicitly declare dependencies separately from the installation code. Now middleware can be added without worrying about priorities or order - the graph handles it automatically. Transitioning to this dependency graph approach allows our middleware configuration to be much more extensible.
This approach defines middleware dependencies and installs them in a valid order using topological sorting:
Define middleware and dependencies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20const middlewares = [
{
id: "logging",
before: [],
after: ["bodyParser"],
mw: loggingMiddleware,
},
{
id: "bodyParser",
before: ["logging"],
after: ["helmet"],
mw: bodyParserMiddleware,
},
{
id: "helmet",
before: ["bodyParser"],
after: ["routes"],
mw: helmetMiddleware,
},
];Build dependency graph:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18function getMw(id) {
return middlewares.find((mw) => mw.id === id);
}
const depGraph = new Graph();
middlewares.forEach((mw) => {
depGraph.addVertex(mw);
mw.after.forEach((afterId) => {
const afterMw = getMw(afterId);
depGraph.addEdge(afterMw, mw);
});
mw.before.forEach((beforeId) => {
const beforeMw = getMw(beforeId);
depGraph.addEdge(mw, beforeMw);
});
});Topologically sort graph and install middleware:
1
2
3
4
5const sortedMws = depGraph.topologicalSort();
sortedMws.forEach((mw) => {
app.use(mw.middleware);
});
This allows complete control over middleware ordering while avoiding priority conflicts. Middleware and dependencies are declared separately from app code.
The key is implementing the Graph
class with addVertex
, addEdge
and topologicalSort
methods.
There is an example of the directed graph implementation for the reference:
1 | interface IVertex { |
Summary
In summary, there are a few different ways to handle middleware installation. Doing it directly in code is quick but can get messy. Defining priorities helps control order but still has conflicts. Modeling dependencies as a graph and topology sorting gives the most flexibility as apps grow larger. The key is separating the middleware declarations from the installation logic. This allows new middleware to be added without fussing with order. Hope this gives some ideas on managing middleware setup!