Skip to main content

🛡️ Validation & Middleware

A schema describes what your data should look like. Validation is what actually stops garbage from getting in, and middleware is the logic that runs automatically at just the right moment — hashing a password before it's saved, stamping a timestamp, cleaning up related records. Together they turn a data model into a set of rules the database enforces for you.

Week 8 · Day 4 (Thursday: Mongoose ODM) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Apply built-in validators: required, min/max, enum, match, length
  • Write synchronous and asynchronous custom validators with clear messages
  • Catch and format a Mongoose ValidationError in an Express route
  • Enable validation on updates with runValidators
  • Write pre and post middleware for save, find, and update lifecycle events
  • Distinguish document middleware (this = document) from query middleware (this = query)

Estimated Time: 70 minutes

Practice: Add password-hashing and a slug-generating hook to a User/Post schema.

In This Lesson

Why Validate at the Model?

You could check every field in your route handlers — but you'd have to remember to do it in every handler that touches that data, forever. Put the rules in the schema instead and they run automatically on every save() and create(), no matter which part of your app is calling. The database becomes the single source of truth for what "valid" means.

Think of validation as a checkpoint at the door: the document must pass every rule before Mongoose will let it through to MongoDB. Middleware is the staff working the door — it can transform the document, log the visit, or turn it away entirely.

flowchart LR A[User input] --> B[new Doc / create] B --> C{Validation checkpoint} C -->|passes| D[pre-save middleware] D --> E[(MongoDB)] E --> F[post-save middleware] C -->|fails| G[ValidationError thrown]

Built-in Validators

Mongoose ships with validators for the most common rules. You've seen some already; here they are together, each with a custom message via the [value, 'message'] array form:

import mongoose from 'mongoose';
const { Schema } = mongoose;

const productSchema = new Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],  // presence
    trim: true,
    minlength: [2, 'Name must be at least 2 characters'],
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  price: {
    type: Number,
    required: true,
    min: [0, 'Price cannot be negative']           // numeric range
  },
  sku: {
    type: String,
    match: [/^[A-Z]{2}-\d{4}$/, 'SKU must look like XX-0000']  // regex
  },
  category: {
    type: String,
    enum: {                                          // allowed set
      values: ['Electronics', 'Clothing', 'Books', 'Home'],
      message: '{VALUE} is not a supported category'  // {VALUE} interpolates
    }
  }
});
ValidatorApplies toChecks
requiredAnyThe field is present
min / maxNumber, DateValue within range
minlength / maxlengthStringString length
enumString, NumberValue is in an allowed list
matchStringValue matches a regex

⚠️ unique is still not here

It's worth repeating: unique: true is not a validator. It builds a database index, and a duplicate raises an E11000 error rather than a ValidationError. Keep that in mind when you write your error handling below.

Custom & Async Validators

When the built-ins aren't enough, supply your own validate function. It returns true for valid and false for invalid. Use a regular function so this refers to the document (handy for cross-field checks).

const userSchema = new Schema({
  password: {
    type: String,
    required: true,
    validate: {
      validator(v) {
        // ≥8 chars, one lower, one upper, one digit
        return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(v);
      },
      message: 'Password needs 8+ chars with upper, lower, and a number'
    }
  },
  passwordConfirm: {
    type: String,
    required: true,
    validate: {
      // Cross-field: compare against another field via `this`
      validator(v) { return v === this.password; },
      message: 'Passwords do not match'
    }
  }
});

Asynchronous validators

If a rule needs to hit the database or an API, make the validator async (or return a promise). It passes when the promise resolves to true.

const signupSchema = new Schema({
  email: {
    type: String,
    required: true,
    validate: {
      async validator(email) {
        const existing = await mongoose.models.User.findOne({ email });
        return !existing;   // false → email already taken
      },
      message: 'That email is already registered'
    }
  }
});

💡 Async validators and updates

A "does this email already exist?" async validator works cleanly for brand-new documents. On updates it can wrongly flag the document against itself — the real defence against duplicates is still a unique index plus E11000 handling. Treat async validators as a friendly early warning, not your last line of defence.

Handling ValidationError

When validation fails, the promise rejects with an error whose name is 'ValidationError'. It carries an errors object keyed by field, each with a human-readable message. Catch it and turn it into a clean 400 response.

// Express route
app.post('/api/products', async (req, res) => {
  try {
    const product = await Product.create(req.body);
    res.status(201).json(product);
  } catch (err) {
    if (err.name === 'ValidationError') {
      // Flatten { field: { message } } → { field: message }
      const errors = Object.fromEntries(
        Object.entries(err.errors).map(([field, e]) => [field, e.message])
      );
      return res.status(400).json({ message: 'Validation failed', errors });
    }
    if (err.code === 11000) {  // duplicate key (from a unique index)
      return res.status(409).json({ message: 'That value is already taken' });
    }
    console.error(err);
    res.status(500).json({ message: 'Server error' });
  }
});

Shape of a ValidationError response

{
  "message": "Validation failed",
  "errors": {
    "price": "Price cannot be negative",
    "category": "Sports is not a supported category"
  }
}

Validation on Updates

Here's a trap that catches everyone: update operations skip validation by default. findByIdAndUpdate and friends talk almost directly to MongoDB and bypass your schema rules unless you opt in with runValidators: true.

// ❌ This can write price: -50 straight past your min:0 rule
await Product.findByIdAndUpdate(id, { price: -50 });

// ✅ Opt in to validation (and return the updated doc)
await Product.findByIdAndUpdate(
  id,
  { price: -50 },
  { new: true, runValidators: true }  // now the min:0 rule fires → ValidationError
);

⚠️ Update validators and this

During an update the full document isn't loaded, so custom validators that read other fields via this may not behave as expected. For complex cross-field rules, prefer the find → mutate → save() pattern, which loads the document and runs the complete validation + middleware pipeline.

Middleware (Hooks)

Middleware (also called hooks) are functions Mongoose runs automatically around lifecycle events. pre hooks run before the operation; post hooks run after. The single most important distinction is what this refers to:

graph TD A["schema.pre('save')"] -->|"this = the document"| B[Document middleware] C["schema.pre(/^find/)"] -->|"this = the query"| D[Query middleware] B --> E["Hash a password, set a field"] D --> F["Filter results, auto-populate"]

Document middleware — hashing a password

The canonical use: transform a document before it's saved. In document middleware, this is the document, and isModified() lets you skip work when nothing relevant changed.

import bcrypt from 'bcrypt';

userSchema.pre('save', async function (next) {
  // 'this' is the document about to be saved
  if (!this.isModified('password')) return next();  // only re-hash on change
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// Pair it with an instance method to check a login attempt:
userSchema.methods.comparePassword = function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

Query middleware — filtering every find

Query middleware fires around find, findOne, updates, and so on. Here this is the query, so you refine the query itself.

// Regex /^find/ matches find, findOne, findById, findOneAndUpdate...
userSchema.pre(/^find/, function (next) {
  // 'this' is the query — exclude soft-deleted users everywhere
  this.where({ active: { $ne: false } });
  next();
});

Post hooks — reacting after the fact

userSchema.post('save', function (doc, next) {
  console.log(`Saved user ${doc.email}`);  // send welcome email, audit log, etc.
  next();
});

// A post-save error hook can prettify the E11000 duplicate-key error:
userSchema.post('save', function (err, doc, next) {
  if (err.code === 11000) return next(new Error('Email already in use'));
  next(err);
});

✅ The two rules of middleware

  • Always call next() (or return a promise / use async) — forget it and the operation hangs forever.
  • Know your this: document hooks (save, validate) vs. query hooks (find*, update*). Using the wrong one is the #1 middleware bug.

Real-World Patterns

Auto-generating a URL slug

Run a pre('validate') hook so the generated slug is itself validated before saving.

import slugify from 'slugify';

postSchema.pre('validate', function (next) {
  if (this.isModified('title')) {
    this.slug = slugify(this.title, { lower: true, strict: true });
  }
  next();
});

Schema-wide validation with invalidate

When a rule spans several fields, a pre('validate') hook can flag any field with this.invalidate():

projectSchema.pre('validate', function (next) {
  if (this.endDate && this.startDate && this.endDate < this.startDate) {
    this.invalidate('endDate', 'End date must be after start date');
  }
  next();
});

Cascading cleanup on delete

// Remove a user's posts when the user is deleted
userSchema.pre('deleteOne', { document: true, query: false }, async function (next) {
  await mongoose.model('Post').deleteMany({ author: this._id });
  next();
});

💡 Reusable logic → plugins

When you find yourself pasting the same hooks into several schemas (timestamps, audit logging, soft-delete), package them as a plugin — a function (schema, options) => { … } you attach with schema.plugin(fn). Write it once, reuse everywhere.

Practice & Quiz

🏋️ Exercise 1: A validated account schema

Goal: Build an accountSchema where username is required and matches /^[a-zA-Z0-9_]+$/, email is required and lowercased, age is a number ≥ 13, and plan is one of free, pro, team (default free). Give each rule a custom message.

💡 Hint

Use match: [regex, 'message'] for the username, min: [13, 'message'] for age, and the enum: { values, message } object form for plan.

✅ Solution
const accountSchema = new Schema({
  username: {
    type: String,
    required: [true, 'Username is required'],
    match: [/^[a-zA-Z0-9_]+$/, 'Letters, numbers, and underscores only']
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    lowercase: true, trim: true
  },
  age: { type: Number, min: [13, 'Must be at least 13'] },
  plan: {
    type: String,
    enum: { values: ['free', 'pro', 'team'], message: '{VALUE} is not a valid plan' },
    default: 'free'
  }
});

🏋️ Exercise 2: Timestamp-on-publish hook

Goal: Add a pre('save') hook to postSchema that sets publishedAt to now the first time published flips to true — and never overwrites it afterward.

✅ Solution
postSchema.pre('save', function (next) {
  if (this.isModified('published') && this.published && !this.publishedAt) {
    this.publishedAt = new Date();
  }
  next();
});

🎯 Quick Quiz

Question 1: Why might findByIdAndUpdate(id, { price: -5 }) save a negative price despite a min: 0 rule?

Question 2: In schema.pre(/^find/, function(){...}), what does this refer to?

Question 3: What is the most common way to accidentally hang a Mongoose operation inside middleware?

Best Practices & Pitfalls

✅ Do

  • Put validation rules in the schema so they run on every write, everywhere
  • Give every rule a clear, user-facing message
  • Pass runValidators: true whenever you use findByIdAndUpdate
  • Guard pre('save') work with this.isModified(field)
  • Catch both ValidationError and E11000 in your route handlers

❌ Don't

  • Rely on async validators as your only duplicate defence — use a unique index too
  • Forget next() (or an async/promise return) in a hook
  • Use arrow functions for validators or hooks — they break this
  • Put heavy, slow work inside frequently-run middleware

⚠️ Don't double-hash

Without the if (!this.isModified('password')) return next(); guard, every save re-hashes the already-hashed password and logins silently break. The isModified check is not optional.

Summary

🎉 Key Takeaways

  • Validation lives in the schema and runs on every save()/create()
  • Built-ins cover most rules; custom validators (sync or async) handle the rest
  • A failed rule rejects with a ValidationError keyed by field
  • Updates skip validators unless you pass runValidators: true
  • Document middleware gets this = document; query middleware gets this = query
  • Always call next(); guard save hooks with isModified()

📚 Additional Resources

🚀 What's Next?

Your documents are now clean and safe. But most data is connected — a post has an author, an order has a customer. Next: Population & References, where you'll link documents across collections and pull related data together with populate().

🎉 Data integrity, locked in!

You can now reject bad input at the model and run logic automatically around every save. That's the backbone of a trustworthy API.