Mongoose Models, Validation and Relationships

Intermediate
14 min

Mongoose Models, Validation and Relationships

Connecting to MongoDB is the first step; modelling data well is what keeps an Express API consistent as it grows. Mongoose schemas declare field types, validation rules, defaults, indexes and relationships in one place. After this lesson you will be able to design schemas with validators, reference documents across collections with populate(), add hooks and computed fields, and map Mongoose errors to proper HTTP responses.

Schema types and validators

javascript
import mongoose from "mongoose"; const userSchema = new mongoose.Schema({ name: { type: String, required: [true, "Name is required"], trim: true }, email: { type: String, required: true, unique: true, lowercase: true, match: [/^\S+@\S+\.\S+$/, "Invalid email"], }, password: { type: String, required: true, minlength: 8, select: false }, role: { type: String, enum: ["user", "admin"], default: "user" }, age: { type: Number, min: 13, max: 120 }, }, { timestamps: true }); export const User = mongoose.model("User", userSchema);

| Option | Applies to | Effect | | --- | --- | --- | | required, enum, min/max, minlength/maxlength, match | validation | Reject invalid values on save() and create() | | default | any | Value used when the field is missing | | trim, lowercase, uppercase | String | Normalise before saving | | select: false | any | Exclude from query results unless .select("+password") | | unique | any | Creates a unique index; it is not a validator | | timestamps: true | schema | Adds createdAt and updatedAt automatically |

Custom rules go in validate: { validator: (v) => v.length <= 5, message: "Max 5 tags" }. Validators run on save() and create(). For findOneAndUpdate() and updateMany() they only run if you pass { runValidators: true }.

References and populate

MongoDB has no joins, but Mongoose can store an ObjectId that points at another collection and resolve it on demand:

javascript
const postSchema = new mongoose.Schema({ title: { type: String, required: true }, author: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, comments: [{ type: mongoose.Schema.Types.ObjectId, ref: "Comment" }], }, { timestamps: true }); export const Post = mongoose.model("Post", postSchema); // In a service const post = await Post.findById(id) .populate("author", "name email") // only these fields .populate({ path: "comments", options: { limit: 20, sort: { createdAt: -1 } } });

populate() runs a second query per path, which is fine for a document or a page of results. For data always read together and rarely changed (an address on an order), embed a sub-document instead.

Hooks, methods and virtuals

Hooks run before or after lifecycle events. Hashing a password in a pre("save") hook guarantees it happens no matter which service creates the user:

javascript
import bcrypt from "bcrypt"; userSchema.pre("save", async function () { if (this.isModified("password")) { this.password = await bcrypt.hash(this.password, 12); } }); userSchema.methods.checkPassword = function (plain) { return bcrypt.compare(plain, this.password); }; userSchema.virtual("initials").get(function () { return this.name.split(" ").map((p) => p[0]).join(""); }); userSchema.set("toJSON", { virtuals: true, transform: (doc, ret) => { delete ret.password; return ret; } });

Use regular function syntax in hooks and methods so this refers to the document (schema.statics works the same way for model-level helpers). Virtuals are computed on read and never stored; toJSON options control what res.json(user) sends.

Performance and error mapping

  • .lean() returns plain objects instead of full documents, several times faster for read-only endpoints.
  • Declare indexes on fields you filter or sort by: postSchema.index({ author: 1, createdAt: -1 }).
  • Mongoose throws specific error types; translate them once in your error middleware:
javascript
export function errorHandler(err, req, res, next) { if (err.name === "ValidationError") { const details = Object.values(err.errors).map((e) => e.message); return res.status(400).json({ error: "Validation failed", details }); } if (err.name === "CastError") return res.status(400).json({ error: `Invalid ${err.path}` }); if (err.code === 11000) { return res.status(409).json({ error: `${Object.keys(err.keyValue)[0]} already exists` }); } res.status(err.statusCode ?? 500).json({ error: err.message }); }

CastError appears when /posts/abc reaches findById, and code 11000 is MongoDB's duplicate-key error from a unique index.

Quick Quiz
Question 1 of 3

What does `unique: true` do in a Mongoose schema?

Key Takeaways

  • Schemas declare types, validators, defaults and normalisation; validation runs on save()/create() and on updates with runValidators.
  • ref + populate() model relationships across collections; embed sub-documents for data always read together.
  • pre("save") hooks, instance methods, statics and virtuals keep behaviour next to the data it belongs to.
  • Use .lean() for read-only queries and indexes for filtered or sorted fields.
  • Map ValidationError, CastError and duplicate-key code 11000 to 400/409 responses in one error handler.

Next lesson: SQL Databases with Prisma — use the same Express structure with PostgreSQL or SQLite through Prisma's type-safe client and migrations.

Mongoose Models, Validation and Relationships - Express.js | CodeYourCraft | CodeYourCraft