Understanding the Middleware Pattern
The term “middleware” is common parlance in modern JavaScript, and it makes for a wonderfully flexible, extensible, and easy-to-use API, as all APIs should be. Take Express, for example:
import express from 'express';
import middleware1 from 'express-middleware-1';
import middleware2 from 'express-middleware-2';
const app = express();
app.use(middleware1());
app.use(middleware2());
or Redux:
import { applyMiddleware, createStore } from 'redux';
import middleware1 from 'redux-middleware-1';
import middleware2 from 'redux-middleware-2';
const store = createStore(
reducer,
applyMiddleware(
middleware1(),
middleware2(),
),
);
Under the Hood: How Middleware Works
The developers of these middleware libraries deal with a little bit of complexity consuming the APIs exposed by the target platform:
// for Express:
function myMiddleware(req, res, next) {
// do something with req, res
next();
}
// for Redux:
function myMiddleware({ getState, dispatch }) {
return next => action => {
// do something with `action`
// call `getState()` to get new state after dispatch
// call the next dispatch method in the middleware chain.
const returnValue = next(action);
// do something with `returnValue`
// call `getState()` to get new state after dispatch
// dispatch a new top-level action with `dispatch()`
// this will likely be the action itself, unless a middleware further in chain changed it.
return returnValue;
}
}
This pattern is essentially a functional variant of the Chain of Responsibility object-oriented behavioral pattern: each middleware has the chance to inspect and modify the input and/or output data to the underlying system and control the execution of each successive layer. The input of each layer is the result of work done by the preceding layers, and vice-versa for the output (if supported).
Express takes the approach of having the middleware make changes to the input
arguments directly, but Redux offers the opportunity to keep data immutable by
explicitly passing the next layer’s input data to next()
. Of course, it’s up
to the middleware library to respect this, but immutable data carries with it
some nice guarantees in the functional programming world.
What’s next()
?
The choice of using a next()
callback to continue executing the chain is
brilliat for a couple of reasons.
-
it allows middleware functions to execute asynchronously.
You’re probably thinking, “But what about a
Promise
? It would allow for asynchronous execution and works nicely with modern JS’sasync
/await
support.” -
Promises occupy the return value of a function. By using a callback, the middleware’s return value (like in Redux) can be used to pass information to “outer” middleware layers and ultimately to the top-level library invocation.
-
next()
can be invoked 0 times, 1 time, or multiple times. This is useful for transparently “spreading” calls to the underlying API and “condensing” the results to a single output… or blocking calls altogether. I’m not sure what a good use case for this would be, but it’s a neat possibility.
What if I want to make my own middleware-able API?
The APIs for writing middleware for existing libraries look complex enough. What does it look like to write a library that consumes such a monadic monstrosity? Here’s the short answer (replace arguments and library computation section accordingly):
export default function createMyMiddlewareLibrary(config) {
let stack = (arg1, arg2, next) => next(arg1, arg2);
function myMiddlewareLibrary(arg1, arg2) {
// do library stuff in this function
// kick off the middleware at some point:
const result = stack(arg1, arg2, (arg1, arg2) => {
// "inner" computation...
return result;
});
// finish up stuff...
return result;
}
myMiddlewareLibrary.use = middleware => {
stack = (prev => (arg1, arg2, next) =>
prev(arg1, arg2, (arg1, arg2) => middleware(arg1, arg2, next))
)(stack);
return myMiddlewareLibrary;
}
return myMiddlewareLibrary;
}
or, if you prefer TypeScript (replace type annotations accordingly):
export type Next = (arg1: TArg1, arg2: TArg2) => TReturn;
export type Middleware = (arg1: TArg1, arg2: TArg2, next: Next) => TReturn;
export interface MyMiddlewareLibrary {
(arg1: TArg1, arg2: TArg2): TReturn;
use(next: Middleware): this;
}
export interface Configuration {
// declare library config params here
}
export default function createMyMiddlewareLibrary(config: Configuration): MyMiddlewareLibrary {
let stack: Middleware = (arg1, arg2, next) => next(arg1, arg2);
function myMiddlewareLibrary(arg1: TArg1, arg2: TArg2): TReturn {
// do library stuff in this function
// kick off the middleware at some point:
const result = stack(arg1, arg2, (arg1, arg2) => {
// "inner" computation
let result: TReturn;
return result;
});
// finish up stuff...
return result;
}
myMiddlewareLibrary.use = (middleware: Middleware) => {
stack = (prev => (arg1: TArg1, arg2: TArg2, next: Next) =>
prev(arg1, arg2, (arg1, arg2) => middleware(arg1, arg2, next))
)(stack);
return myMiddlewareLibrary;
}
return myMiddlewareLibrary;
}