Mongoose Middleware Gripes

Why they're difficult, how to use them, and when to maybe not use them

Mongoose / © Krzysztof / Adobe Stock

Jesse Pledger, Senior Engineer

Feb. 17, 2020

One of the hardest initial hurdles using Mongoose was understanding the library's middleware or “hook” system which allows you to hook into the database transaction lifecycle to perform operations. Transaction lifecycle hooks are a common feature of ODM’s and ORM’s and are a useful feature. For instance, if you have a User Schema with a firstName and a lastName attribute that are filled in by the user, you might use a “hook” before saving the record joining the two fields into a third field called fullName.

However, when using Mongoose we found that this functionality was actually a lot more nuanced than in previous ODM's and ORM's we had used. Sometimes, it was downright frustrating. This resulted mainly from the following aspects of Mongoose hooks:

“This” can mean different things depending on the Middleware

In Mongoose, there are four types of middleware depending on the context - query, document, model, and aggregate. Of the four, the two that we mixed up the most were query, and document. This post will focus mostly on those two. The four are documented by Mongoose here.

When writing middleware in Mongoose it’s important to remember which one you are referencing. For instance, when using the findOne middleware it might be expected that the this keyword references a document. This is not the case - it references query. More on the findOne middleware later. The hooks are not very intuitive and can lead to confusion. After searching through issues on the Mongoose Github repository we found we were certainly not alone in our confusion.

Reading the documentation will help, but it would be nice if things worked the way one expected. If you decide to use Mongoose, be sure to keep the documentation handy as it will be very necessary.

On to the specific gripes...

Granularity of Middleware

Mongoose middleware is very specific. For example, it might be expected that findOne and find are aliased. However, this is not the case. You will need to explicitly state all cases where you want your middleware applied. It could be argued this is a benefit but also leads to a lot of unnecessary duplication of code and verbose schemas.

Combining middleware when initializing Mongoose is possible but should be done discerningly. The combinations are not explicitly in the documentation and may confuse other developers on the project. Any combinations should be well-reasoned and well-documented. Here is an example from a project where we aliased the update hooks:

mongoose.plugin(schema => {
  const functions = [
    "findOneAndUpdate",
    "findByIdAndUpdate",
    "updateMany",
    "updateOne",
    "update",
  ];

  functions.forEach(name => schema.pre(name, setRunValidators));
});

function setRunValidators() {
  this.setOptions({ runValidators: true, context: "query" });
}

Edge Cases

There are a ton of edge cases with Mongoose middleware. Two of the most frustrating to us are mentioned above, however, there are many more. Again, the documentation is going to be your friend. This is a tricky library to intuit and you will need to reference things that may seem obvious. Ultimately, the amount of edge cases present in the middleware led us to consider other ODM options for future projects. Sorry, Mongoose.

Solutions

Because of these difficulties we ended up using Mongoose’s middleware very selectively. Usage was limited to simple transformation of data like the firstName lastName example earlier. Technically, it’s best practice to keep as much of your business logic out of asynchronous database lifecycle hooks as they can be difficult to debug and test. In that sense, this simplistic use of Mongoose hooks could arguably be a good thing. However, it can be cumbersome to fit cascade style deletes and other functionality into other areas of the codebase and can sometimes lead to repetition of code.

Something else to note is we relied heavily on Mongoose’s helper functions such as findOneAndUpdate. The use of these helper functions is what primarily lead to our middleware hook woes. If you are working in a codebase with Mongoose and know you would like to rely on the library's middleware hooks you might consider working primarily with documents instead like in the example below:

const user = User.findById(id);
user.name = “fred”;
user.save();

This style of code will leverage the Middleware in a more predictable manner. However, it comes at the cost of multiple transactions.

Other Options

Another library we’ve looked at which looks promising is Sails.js’s Waterline adapter. It does not have a middleware hook system for database transactions like Mongoose and has a simpler API. This is attractive because we ended up leveraging Mongoose mainly for it’s helper functions and the middleware portion seemed like bloat.

Alternatively, you could also make an argument to just use the MongoDB Node.js driver. Your transactional code might be more verbose, but would be a lot more predictable. Again, when we decided to mostly scrap the Middleware, using Mongoose seemed to add a lot of excess dependencies to our codebase. Additionally, new developers to the project would attempt to use the middleware which cost development time when errors would occur, need to be debugged, and the code subsequently refactored.

Conclusion

Mongoose is definitely here to stay and is one of the more popular ODM’s for MongoDB when working with Node.js. If you’re working on a project that does use it, we would highly recommend taking the time to understand the documentation. When understood and used properly, it can be effective. However, the learning curve is somewhat steep.

If you’re starting a new project and considering using Mongoose, we would recommend looking at other libraries first and weighing your options carefully. With Node.js and MongoDB's growing popularity, there are potentially better options that might suit your application.