Middleware and Hooks

Hooks are registered on the schema, before the model is compiled, and come in families that differ in what this is. Getting that wrong is the classic Mongoose 243,355 bug.

Two paths to the same collection, and the hooks each one runs
Two paths to the same collection, and the hooks each one runs
The same edit, twice: document hooks and query hooksJavaScript
const schema = new Schema({ title: String, slug: String, updatedBy: String });
schema.pre('save', function () {
  log(`pre  save       this is a ${this.constructor.modelName}`);
  this.slug = this.title.toLowerCase().replaceAll(' ', '-');
});
schema.post('save', function (doc) { log(`post save       slug=${doc.slug}`); });
schema.pre('findOneAndUpdate', function () {
  log(`pre  findOne... this is a ${this.constructor.name}, ` +
      `filter=${JSON.stringify(this.getFilter())}`);
  this.set({ updatedBy: 'hook' });
});
schema.post('findOneAndUpdate', function (r) {
  log(`post findOne... title=${r.title} slug=${r.slug} updatedBy=${r.updatedBy}`);
});
await new Post({ title: 'The Cyberiad' }).save();
await Post.findOneAndUpdate({ title: 'The Cyberiad' }, { title: 'Peace on Earth' },
                            { returnDocument: 'after' });
Output
pre  save       this is a Post
post save       slug=the-cyberiad
pre  findOne... this is a Query, filter={"title":"The Cyberiad"}
post findOne... title=Peace on Earth slug=the-cyberiad updatedBy=hook

Read the last line twice. The title is now "Peace on Earth" and the slug still says the-cyberiad, because pre('save') never ran: a query update does not load a document, so there is nothing for a document hook to act on. Data drifts out of sync exactly this quietly. Either derive the field in a query hook too — schema.pre(['findOneAndUpdate', 'updateOne'], ...) takes an array, and /^find/ matches a whole group — or forbid the query path for that model.

Inside query middleware this is the Query: use this.getFilter(), this.getUpdate() and this.set(). Inside document middleware this is the document. Either way a hook must be a function, never an arrow, which would capture the module's this. Two more families exist: aggregate middleware binds this to the Aggregate, and model middleware covers insertMany and bulkWrite. pre hooks run serially in registration order, and a thrown error aborts the operation. Register them before mongoose.model() compiles the schema; afterwards they are silently ignored, which is the second-most-common Mongoose bug and the reason a plugin (Plugins) is the right home for shared hooks.