Mongoose Schemas, Validation and Middleware

Intermediate
14 min

Mongoose Schemas, Validation and Middleware

Mongoose adds an application-level schema on top of MongoDB's flexible documents: typed fields, casting, validation rules with readable messages, and hooks that run before or after saves and queries. After this lesson you will be able to define schemas with the right SchemaTypes and options, write built-in and custom validators, understand when validation does and does not run, and use middleware for hashing, auditing and soft deletes.

SchemaTypes and Field Options

A schema maps each path to a SchemaType: String, Number, Date, Boolean, ObjectId, Decimal128, Buffer, Map, UUID, arrays, nested objects, or Mixed for anything. Field options refine behaviour:

| Option | Effect | |---|---| | required | value must be present; accepts a custom message or a function | | default | static value or function (Date.now, () => []) | | enum, min/max, minLength/maxLength, match | built-in validators | | lowercase, uppercase, trim | setters applied before validation | | select: false | excluded from query results unless +field is requested | | immutable: true | cannot change after creation | | index, unique, sparse | index definitions; not validators |

{ timestamps: true } adds createdAt/updatedAt; strict mode (the default) drops fields that are not in the schema.

Custom and Async Validators

javascript
const productSchema = new Schema({ sku: { type: String, validate: { validator: (v) => /^[A-Z]{2,5}-\d{3,}$/.test(v), message: (props) => `${props.value} is not a valid SKU` } }, price: { type: Number, min: [0, "Price cannot be negative"] }, discount: { type: Number, validate: { validator: function (v) { return v <= this.price; }, message: "Discount exceeds price" } } });

A validator may return a promise for asynchronous checks. Validation runs on save() and create(), and errors arrive as a ValidationError whose errors object is keyed by path:

javascript
try { await Product.create({ sku: "bad", price: -5 }); } catch (err) { Object.values(err.errors).map(e => e.message); // [ "bad is not a valid SKU", "Price cannot be negative" ] }

Two rules that surprise newcomers: unique builds an index and throws a driver E11000 error rather than a ValidationError, and update operations (updateOne, findOneAndUpdate) skip validation unless you pass { runValidators: true }.

Middleware (Hooks)

Middleware runs around an operation. Which this you get depends on the kind:

| Kind | Triggers | this is | |---|---|---| | Document | save, validate, deleteOne (with { document: true }) | the document | | Query | find, findOne, updateOne, findOneAndUpdate, deleteMany, ... | the Query | | Aggregate | aggregate | the Aggregate object | | Model | insertMany, bulkWrite | the Model |

javascript
// Soft delete: hide deleted documents from every find-style query userSchema.pre(/^find/, function () { this.where({ deletedAt: null }); }); // Audit: capture a change record after each save userSchema.post("save", async function (doc) { await AuditLog.create({ userId: doc._id, at: new Date() }); }); // Error middleware: turn a duplicate email into a readable message userSchema.post("save", function (err, doc, next) { next(err.code === 11000 ? new Error("Email already registered") : err); });

Hooks written as async functions do not need next(); throwing (or rejecting) aborts the operation. Use regular function syntax, not arrow functions, so this is bound. Register hooks before calling mongoose.model(), otherwise they are silently ignored. save hooks do not fire for updateOne or findOneAndUpdate; add query hooks for those paths when the logic must apply everywhere.

Indexes Defined in Schemas

javascript
userSchema.index({ email: 1 }, { unique: true }); userSchema.index({ name: "text" }); await User.syncIndexes(); // create missing, drop indexes not in the schema

In development Mongoose builds indexes automatically when a model is first used (autoIndex). In production set autoIndex: false in mongoose.connect() options and run syncIndexes() from a deploy step, so a large index build never starts at process boot.

Common Mistakes

  • Expecting unique to validate. Catch E11000 from the driver instead.
  • Updating without runValidators: true, letting invalid data through findOneAndUpdate.
  • Arrow functions in hooks and validators, which lose the this binding.
  • Mutating a Mixed or nested value in place without doc.markModified("path"), so save() sees no change.
Quick Quiz
Question 1 of 3

Which operation does NOT run schema validators by default?

Key Takeaways

  • Schemas declare SchemaTypes with options for defaults, casting, required fields, enums and ranges; timestamps: true adds audit dates.
  • Custom validators receive the value and can be asynchronous; errors come back as a ValidationError keyed by path.
  • unique is an index, not a validator, and updates need runValidators: true.
  • Document, query, aggregate and model middleware differ in what this refers to; register hooks before creating the model.
  • Declare indexes in the schema and run syncIndexes() at deploy time with autoIndex off in production.

Next lesson: Mongoose Population, Virtuals and Query Helpers — model relationships between collections and add computed fields and reusable query logic.

Mongoose Schemas, Validation and Middleware - MongoDB | CodeYourCraft | CodeYourCraft