I Love Middleware Now
If you think about the your application in terms of invariants, the power of middleware really comes out
Anyone who’s ever studied physics knows that the trick to almost any hard problem is to use an invariant to transform the problem into one that is in a simpler problem space. Using a conservation laws (momentum, energy, charge) can turn difficult physics problems into much simpler ones once we think in terms of the conserved quantity.
Modular arithmetic in discrete math, the current account in macroeconomics, double entry bookkeeping in accounting, etc are other examples of places where real or imagined “invariants” between values on one side of an equation and values on another side of an equation can help to simplify or reason about difficult problems.
The nice part about software is that we can create our own invariants based around how we want our app to function. Choosing the right invariants, enforcing those invariants concisely, and then milking the power of those invariants is probably the easiest formula for writing simple and easy-to-reason-about software.
Choosing invariants that you want your application to respect is usually the easy part. Of course you want application state between the frontend and backend to stay in sync. You’d love it if certain types of application changes could only be made if the user making those changes met certain conditions. You’d also love if you could create or connect certain types of activities on certain types of events — for example auto-inserting users into groups after they’re through with the sign up flow, or creating a notification whenever a user follows another user.
Enforcing these invariants becomes a problem, usually because there are so many places within a large application where you have to be vigilant about keeping this invariant in place. You might have 20 types of Redux actions that change the database state, each of them called 5 or more times throughout your app. The trivial but painful way of enforcing that the redux state stays in sync with the database would be to (1) figure out how each of these redux actions should affect the database, (2) create API routes to propagate changes from Redux to the database and (3) find each place in your app where you dispatch a redux event and make sure it’s coupled with a corresponding API call. Agh! Absolutely a nightmare!
Middleware is almost always the best place to enforce invariants, because middleware sits between two systems and processes all interactions between the two systems. Below is a snippet of a middleware function that dispatches a web socket call to the backend whenever any of the actions that update blocks in a redux store change. Putting this in place ensures that any time the redux state of blocks changes we can be sure that the database will immediately know about that change.
export const socketMiddleware: Middleware<AppState> = (storeAPI) => {
return (next) => (action) => {
switch (action.type) {
case "blockAdded":
case "blockUpdated":
case "blockRemoved":
socket.emit("page-update", {
action: "blockAction",
data: action.payload,
});
break;
}
}
Note: learn more about redux middleware here.
Once this is in place, developing on the frontend becomes as easy as figuring out which redux actions to dispatch, without the need to handle API calls within components (a harder than expected task, given that you’ll almost always want to also dispatch redux actions whenever an API call would also affect application state).
Another time to consider keeping application state in sync is when changes in one type of data should always be accompanied by changes in other data. Practical examples of this include:
Making a “transaction” whenever the balance of an account value changes.
Creating “activities” (possibly to be used in a notification service) whenever a specific type of action happens.
At first this type of flow doesn’t seem like it has anything to do with invariants. What is invariant over the course of a user action flow? It turns out you have to be a little creative (some would say contrived) to see it. The invariant is “the number of user actions that meet condition X net the number of changes to model Y”. If these two numbers are enforced to be the same, then you probably don’t have any hard-to-reason-about bugs.
Just like in the redux-to-API example above, the key is to find the highest leverage place to put code that enforces the condition you care about. Again, the best answer here is middleware. Prisma (a database ORM) has great support for middleware, and is hopefully soon going to allow for context (e.g. information about who the authenticated user is) to be available to middleware.
To implement (2) above, just write some conditions about database requests you want to trigger other actions…
const CONDITIONS = {
[ActivityType.FOLLOW]: ({ action, model, args }) =>
action === "update" && model === "User" && !!args.data.following?.connect,
[ActivityType.SIGNUP]: ({ action, model }) => action === "create" && model === "User",
// other conditions here
}
… and write some logic that you want to happen on each request
prisma.$use(async (params, next) => {
let activityCreateOptions: Prisma.ActivityCreateInput;
const result = await next(params);
if (CONDITIONS.FOLLOW(params)) {
activityCreateOptions = {
type: ActivityType.FOLLOW,
user: { connect: params.args.where },
userFollowed: params.args.data.following,
};
}
if (CONDITIONS.SIGNUP(params)) {
activityCreateOptions = {
type: ActivityType.SIGNUP,
user: { connect: { id: (result as User).id } },
};
}
// other conditions here
await prisma.activity.create(activityCreateOptions);
}
With this centralized logic in place at the middleware level (and no other code to create “activities”), it’s pretty easy to verify that the number of times these conditions are met will exactly equal the number of activities created. Invariant preserved.
To get the most out of thinking in terms of invariants, you might do well composing them together. To continue our example above, imagine you wanted to send a notification whenever certain types of activities happened. Sure, one way to do this is to send out notifications directly within the conditions that are hit in the code snippet above.
if (CONDITIONS.SIGNUP(params)) {
activityCreateOptions = {
type: ActivityType.SIGNUP,
user: { connect: { id: (result as User).id } },
};
// SUB OPTIMAL CODE BELOW! Duplicated for each condition
await sendNotification('signup', activityCreateOptions);
}
But given that we’re already capturing this condition with a state change (e.g. creating the “activity” in the database), we can isolate this code on middleware conditioned to activity creations.
// you can add as many middleware handlers as you want!
prisma.$use(async ({ action, model, args }, next) => {
if (action === "create" && model === "Activity") {
sendNotifications(args);
}
});
The advantage of this, besides fewer lines of code and somewhat simpler logic, is that it isolates each invariant constraint we care about into a specific and easy to reason about condition. We might eventually want to use the “activities” we’ve stored for more than just notifications (imagine we want to show a feed or perform compliance actions). Now we’ve got conceptual separation between notifications and activities.
Middleware is pretty cool.