How to Implement Mongoose Middleware for Improved Data Handling

In Mongoose, middleware is a way to define custom logic to run before or after certain actions in your MongoDB schema, like saving a document, updating, deleting, or even finding. Middleware functions allow you to add specific behavior to these operations, making it easier to enforce rules, perform validations, log activities, or modify data automatically

Types of Middleware

1. Document Middleware

  • Description: Document middleware applies directly to individual documents and affects operations such as saving, validating, and removing.

  • Common Methods: save, validate, remove, init.

  • Example:

      const mongoose = require('mongoose');
      const bcrypt = require('bcrypt');
    
      // Define the User schema
      const userSchema = new mongoose.Schema({
        username: {
          type: String,
          required: [true, 'Username is required'],
          unique: true,
          minlength: [3, 'Username must be at least 3 characters long']
        },
        password: {
          type: String,
          required: [true, 'Password is required'],
          minlength: [8, 'Password must be at least 8 characters long']
        },
        email: {
          type: String,
          required: [true, 'Email is required'],
          unique: true,
          match: [/\S+@\S+\.\S+/, 'Please use a valid email address']
        }
      });
    
      // Pre-save middleware to hash the password
      userSchema.pre('save', async function (next) {
        // Only hash the password if it has been modified (or is new)
        if (this.isModified('password')) {
          try {
            // Generate a salt
            const salt = await bcrypt.genSalt(10);
            // Hash the password with the salt
            this.password = await bcrypt.hash(this.password, salt);
            next();
          } catch (err) {
            next(err); // Pass the error to the next middleware
          }
        } else {
          next();
        }
      });
    
      // Custom validation before save: Check if password contains at least one number
      userSchema.pre('save', function (next) {
        const passwordRegex = /[0-9]/; // Password must contain at least one digit
        if (!passwordRegex.test(this.password)) {
          return next(new Error('Password must contain at least one number.'));
        }
        next();
      });
    
      // Create the User model
      const User = mongoose.model('User', userSchema);
    
      module.exports = User;
    
  • Use Cases: Modifying or validating data before saving it to the database, like hashing passwords before save or performing additional validation checks.

2. Model Middleware

  • Description: Model middleware, also known as static middleware, applies to model-level operations that do not directly involve a specific document instance.

  • Common Methods: insertMany.

  • Example:

      userSchema.pre('insertMany', function(next, docs) {
        console.log('Model middleware for insertMany');
        next();
      });
    
  • Use Cases: Useful for handling bulk operations, like validating data or adding default values when inserting multiple documents.

3. Aggregate Middleware

  • Description: Aggregate middleware is specific to MongoDB aggregation pipelines. It allows you to modify or inspect the aggregation pipeline before it’s executed.

  • Common Methods: aggregate.

  • Example:

      userSchema.pre('aggregate', function(next) {
        // Modify the aggregation pipeline before execution
        this.pipeline().unshift({ $match: { isActive: true } });
        console.log('Aggregate middleware');
        next();
      });
    
  • Use Cases: Modifying the aggregation pipeline for things like filtering out inactive documents or adding additional stages based on dynamic conditions.

4. Query Middleware

  • Description: Query middleware applies to operations that retrieve, update, or delete documents based on queries.

  • Common Methods: find, findOne, findOneAndUpdate, update, deleteOne, and many more.

  • Example:

      userSchema.pre('find', function(next) {
        this.where({ isActive: true }); // Only find active users
        console.log('Query middleware for find');
        next();
      });
    
  • Use Cases: Adding query conditions automatically (e.g., applying a default filter), logging query operations, or modifying query results before returning.

There are two main types of middleware in Mongoose:

  1. Pre Middleware: Executes before a specific action.

    • Often used for actions like validation, data transformation, or logging before saving, updating, or finding documents.

    • Example: Encrypting a password before saving a user document.

When the pre('save') Hook is Called

  1. Document Creation or Update:

    • The pre('save') hook is triggered whenever a document is saved using the save() method. This can happen either when a new document is being created or an existing document is being modified and saved.
  2. Only on Explicit save() Calls:

    • The pre('save') middleware is only called when you explicitly call .save() on a document. For example:

        const user = new User({ username: 'testuser', password: 'securePassword1' });
        await user.save();  // This triggers the pre('save') middleware
      
    • It does not trigger for other methods like updateOne, findOneAndUpdate, or insertMany. For those, you would need to use different hooks, such as pre('updateOne') or pre('insertMany').

  3. Conditionally Based on Document Modifications:

    • The pre('save') hook is only triggered if certain fields in the document are modified (or if it’s a new document).

    • For example, in the context of hashing passwords, you often only want to rehash the password if it has been modified. You can check this with this.isModified('fieldName'):

        userSchema.pre('save', function (next) {
          if (this.isModified('password')) {
            // Only rehash the password if it’s modified
          }
          next();
        });
      

How the pre('save') Hook Works Internally

  • When you call .save() on a document, Mongoose goes through a series of steps to prepare the document for insertion or updating in MongoDB.

  • The pre('save') hook is called just before Mongoose begins interacting with the database. Any changes you make to the document in the pre('save') hook (such as modifying a field value) will be included in the final saved version.

  • If there’s an error in the pre('save') hook (for example, a validation failure), Mongoose stops the operation and does not save the document to the database.

Example Workflow of pre('save') Hook

  1. Define Document:

     const user = new User({
       username: 'exampleUser',
       password: 'plainTextPassword'
     });
    
  2. Trigger pre('save') Middleware:

    • When you call user.save(), Mongoose checks if there’s a pre('save') hook on the schema and runs it before saving.
  3. Execute Hook Logic:

    • If the pre('save') hook modifies user.password (e.g., hashes it), the modified version will be saved.

    • If the hook encounters an error (e.g., custom validation failure), the save process is interrupted, and the error is returned.

  4. Save Document:

    • If there are no errors, the document is saved with all modifications from the pre('save') middleware applied.

What Happens If You Don’t Call .save()

If you create or modify a document without calling .save(), the pre('save') middleware won’t run, and your changes won’t be saved to the database. For example:

const user = new User({
  username: 'exampleUser',
  password: 'plainTextPassword'
});

// If you don't call user.save(), the document won't be saved, and the pre('save') middleware won't run.

Triggers:

  • pre('save'): Runs before saving a document.

  • pre('validate'): Runs before Mongoose’s validation step.

  • pre('remove'): Runs before removing a document.

  • pre('find'): Runs before a find query.

  • pre('findOne'): Runs before a findOne query.

  • pre('findOneAndUpdate'): Runs before an update query that finds and updates a single document.

  • pre('updateOne'): Runs before updating a single document.

  • pre('deleteOne'): Runs before deleting a single document.

  • pre('insertMany'): Runs before bulk insertion of documents using insertMany.

  • pre('aggregate'): Runs before an aggregation pipeline is executed.

Summary Table

Pre Middleware TypeTrigger EventsCommon Use Case
Document Presave, remove, validateHash passwords, data validation, logging
Query Prefind, findOne, findOneAndUpdate, updateOne, deleteOneAdd filters, enforce rules, modify queries
Model PreinsertManyModify bulk insert documents, add defaults
Aggregate PreaggregateAdd stages to aggregation pipeline, logging
  1. Post Middleware: Executes after a specific action.

    • Useful for handling the result of a completed operation, like logging or sending a notification after a document is saved.

    • Example: Sending an email after a new user is created.

Types of Post Middleware

Mongoose supports post middleware for documents, models, queries, and aggregates. Let’s go over how these work and when they’re used.

1. Document Post Middleware

  • Description: Runs after actions like save, remove, and validate on a document instance.

  • Common Use Case: Logging actions, sending notifications, or performing follow-up actions after saving, deleting, or validating a document.

Example: post('save')

userSchema.post('save', function(doc) {
  console.log(`New user ${doc.username} was saved.`);
  // Additional logic, like sending a welcome email, can go here
});

In this example, the post('save') hook runs after a document is successfully saved.

2. Query Post Middleware

  • Description: Runs after query operations like find, findOne, findOneAndUpdate, updateOne, and deleteOne.

  • Common Use Case: Modifying or logging the result of a query after it completes, such as filtering results or logging query actions.

Example: post('find')

userSchema.post('find', function(docs) {
  console.log(`${docs.length} users found`);
  // Additional actions, like formatting data, can go here
});

In this example, the post('find') hook runs after a find query is executed and receives the array of matching documents (docs) as an argument.

3. Model Post Middleware

  • Description: Runs after model-specific operations like insertMany.

  • Common Use Case: Verifying or modifying the results of bulk operations, such as logging inserted documents or performing cleanup after bulk inserts.

Example: post('insertMany')

userSchema.post('insertMany', function(docs) {
  console.log(`${docs.length} users were inserted.`);
});

This post('insertMany') middleware runs after a bulk insert operation is completed.

4. Aggregate Post Middleware

  • Description: Runs after an aggregation pipeline is executed.

  • Common Use Case: Adjusting or logging the aggregation results before they’re returned to the application.

Example: post('aggregate')

userSchema.post('aggregate', function(result) {
  console.log('Aggregation result:', result);
});

In this example, the post('aggregate') hook runs after an aggregation pipeline and logs the final result.

Summary Table

Middleware TypeTrigger EventsExample Usage
Document Postsave, remove, validateLogging, sending notifications after saving or deleting
Query Postfind, findOne, updateOneModifying results or logging query results
Model PostinsertManyLogging, performing actions after bulk insertions
Aggregate PostaggregateAdjusting or logging aggregation results
0
Subscribe to my newsletter

Read articles from Priyanshu Pandey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Priyanshu Pandey
Priyanshu Pandey