A Mongoose Model is a class that provides a rich API for interacting with a MongoDB collection. Beyond the basic CRUD operations covered in Chapter 5, Mongoose models support static methods — class-level functions attached to the Model — and instance methods — functions available on individual document instances. These are the mechanism for encapsulating domain logic directly in the model: password validation, token generation, permission checks, and business-specific queries. Well-designed model methods keep your services lean and make common operations reusable across the entire codebase.
Mongoose CRUD Method Summary
| Method | Type | Returns | Notes |
|---|---|---|---|
Model.create(data) |
Static | Document(s) | Runs validators. Shorthand for new Model(data).save() |
Model.find(filter) |
Static | Query (array) | Returns all matching. Always add .lean() for read-only |
Model.findOne(filter) |
Static | Query (doc or null) | First match or null |
Model.findById(id) |
Static | Query (doc or null) | Shorthand for findOne({ _id: id }) |
Model.findByIdAndUpdate(id, update, opts) |
Static | Query (doc or null) | Use { new: true, runValidators: true } |
Model.findOneAndUpdate(filter, update, opts) |
Static | Query (doc or null) | Atomic find + update |
Model.findByIdAndDelete(id) |
Static | Query (doc or null) | Returns deleted document |
Model.updateOne(filter, update) |
Static | UpdateResult | Does not return document |
Model.updateMany(filter, update) |
Static | UpdateResult | Bulk update |
Model.deleteMany(filter) |
Static | DeleteResult | Bulk delete |
Model.countDocuments(filter) |
Static | number | Count matching documents |
doc.save() |
Instance | Document | Saves changes, runs validators |
doc.deleteOne() |
Instance | DeleteResult | Deletes this document |
doc.toObject() |
Instance | Plain object | Converts document to plain JS object |
doc.toJSON() |
Instance | Plain object | Used by JSON.stringify / res.json |
doc.isModified(path) |
Instance | boolean | Check if field has been changed |
Model.updateOne() and Model.findByIdAndUpdate(). updateOne() returns an UpdateResult object ({ acknowledged, modifiedCount, matchedCount }) but NOT the updated document. findByIdAndUpdate() with { new: true } returns the updated document. Use updateOne() when you do not need the updated document back (e.g. bulk updates). Use findByIdAndUpdate() when you need to return the updated document to the API client.User.findByEmail(email), Task.findOverdueForUser(userId), User.findAdmins(). These read like domain language and make service code more expressive. They also centralise query construction in one place, so if you add a new filter (like deletedAt: { $exists: false }), you update it in one method rather than every service that queries that collection.this. Arrow functions do not bind their own this — inside an arrow method, this will be undefined in strict mode or the outer context (module scope). Always use regular function keyword for schema methods, statics, and middleware that need to access the document or model via this.Complete Static and Instance Method Examples
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// ── Instance methods — called on document instances ───────────────────────
// userSchema.methods.methodName = function() { ... }
// Compare plain text password with stored hash
userSchema.methods.comparePassword = async function(candidatePassword) {
// 'this' refers to the document instance
// Note: password must be explicitly selected — it has select: false
return bcrypt.compare(candidatePassword, this.password);
};
// Generate a JWT access token for this user
userSchema.methods.generateAccessToken = function() {
return jwt.sign(
{ id: this._id, role: this.role, email: this.email },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '15m' }
);
};
// Generate a refresh token and save it to the user
userSchema.methods.generateRefreshToken = async function(device = 'unknown') {
const token = crypto.randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
// Keep only the last 5 refresh tokens per user
this.refreshTokens = this.refreshTokens.slice(-4);
this.refreshTokens.push({ token, device, expiresAt });
await this.save();
return token;
};
// Remove a specific refresh token (logout)
userSchema.methods.revokeRefreshToken = async function(token) {
this.refreshTokens = this.refreshTokens.filter(t => t.token !== token);
await this.save();
};
// Lock account after too many failed login attempts
userSchema.methods.incrementLoginAttempts = async function() {
const lockTime = 2 * 60 * 60 * 1000; // 2 hours
// Reset if previous lock expired
if (this.lockUntil && this.lockUntil < Date.now()) {
return this.updateOne({ $set: { loginAttempts: 1 }, $unset: { lockUntil: '' } });
}
const updates = { $inc: { loginAttempts: 1 } };
// Lock account if 5+ attempts
if (this.loginAttempts + 1 >= 5 && !this.isLocked) {
updates.$set = { lockUntil: new Date(Date.now() + lockTime) };
}
return this.updateOne(updates);
};
// Reset login attempts after successful login
userSchema.methods.resetLoginAttempts = function() {
return this.updateOne({
$set: { loginAttempts: 0 },
$unset: { lockUntil: '' },
});
};
// Safe JSON representation — strips sensitive fields
userSchema.methods.toPublicJSON = function() {
const obj = this.toObject({ virtuals: true });
delete obj.password;
delete obj.refreshTokens;
delete obj.loginAttempts;
delete obj.lockUntil;
delete obj.__v;
return obj;
};
// ── Static methods — called on the Model class ────────────────────────────
// userSchema.statics.methodName = function() { ... }
// Find user by email with password field included
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() })
.select('+password +loginAttempts +lockUntil');
};
// Find all admin users
userSchema.statics.findAdmins = function() {
return this.find({ role: 'admin', isActive: true }).lean();
};
// Task static methods
taskSchema.statics.findForUser = function(userId, options = {}) {
const { status, priority, page = 1, limit = 10, sort = '-createdAt' } = options;
const filter = { user: userId, deletedAt: { $exists: false } };
if (status) filter.status = status;
if (priority) filter.priority = priority;
return this.find(filter)
.sort(sort)
.skip((page - 1) * limit)
.limit(limit)
.lean();
};
taskSchema.statics.countForUser = function(userId) {
return this.countDocuments({ user: userId, deletedAt: { $exists: false } });
};
taskSchema.statics.findOverdue = function(userId) {
return this.find({
user: userId,
dueDate: { $lt: new Date() },
status: { $nin: ['completed'] },
deletedAt: { $exists: false },
}).sort({ dueDate: 1 }).lean();
};
// Task instance methods
taskSchema.methods.complete = async function() {
this.status = 'completed';
this.completedAt = new Date();
return this.save();
};
taskSchema.methods.reopen = async function() {
this.status = 'pending';
this.completedAt = undefined;
return this.save();
};
taskSchema.methods.softDelete = async function() {
this.deletedAt = new Date();
return this.save();
};
// ── Query helpers — chainable query methods ───────────────────────────────
taskSchema.query.byUser = function(userId) {
return this.where({ user: userId });
};
taskSchema.query.active = function() {
return this.where({ deletedAt: { $exists: false }, status: { $ne: 'completed' } });
};
taskSchema.query.highPriority = function() {
return this.where({ priority: 'high' });
};
// Usage:
// Task.find().byUser(userId).active().highPriority().lean()
How It Works
Step 1 — Instance Methods Operate on a Single Document
Instance methods are defined on schema.methods and become available on every document instance. Inside the method, this refers to the specific document — all its fields and Mongoose methods like this.save() are available. Instance methods are ideal for operations that read or modify the document itself: comparing passwords, generating tokens, incrementing counters, and computing derived values.
Step 2 — Static Methods Are Called on the Model Class
Static methods are defined on schema.statics and called directly on the Model: User.findByEmail('alice@example.com'). Inside the static, this refers to the Model class — you can call this.findOne(), this.aggregate(), this.find(), etc. Static methods are ideal for custom query patterns that apply to the collection as a whole rather than a specific document.
Step 3 — Query Helpers Add Chainable Methods to Queries
Query helpers are defined on schema.query and can be chained onto any Mongoose query: Task.find().byUser(userId).active().sort('-createdAt'). Inside the helper, this is the query object — you call this.where(), this.find(), or other query methods and return the modified query. This is syntactic sugar for common filter combinations, making queries more readable and keeping filter logic in one place.
Step 4 — doc.save() Runs Validators and Middleware
Calling doc.save() after modifying document fields triggers the full Mongoose pipeline: validators run, pre-save middleware executes (e.g. password hashing), the document is written to MongoDB, and post-save middleware fires. This is the correct way to update a document when you need all these hooks to run. Use findByIdAndUpdate() when you want to bypass middleware and validators for performance (bulk operations).
Step 5 — doc.isModified() Enables Conditional Logic in Middleware
doc.isModified('password') returns true if the password field was changed since the last save. This is used in pre-save hooks to hash the password only when it changes: if (!this.isModified('password')) return next(). Without this check, the password would be double-hashed every time the user document is saved — even for unrelated changes like updating their name.
Real-World Example: User Model in Production
// models/user.model.js — production-ready User model
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userSchema = new mongoose.Schema({ /* ... fields ... */ }, { timestamps: true });
// STATICS
userSchema.statics.findByCredentials = async function(email, password) {
const user = await this.findOne({ email }).select('+password +loginAttempts +lockUntil');
if (!user) throw new Error('Invalid credentials');
if (user.isLocked) throw new Error('Account temporarily locked');
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
await user.incrementLoginAttempts();
throw new Error('Invalid credentials');
}
await user.resetLoginAttempts();
return user;
};
// INSTANCE METHODS
userSchema.methods.generateTokenPair = function() {
const accessToken = jwt.sign(
{ id: this._id, role: this.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ id: this._id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
};
// QUERY HELPERS
userSchema.query.active = function() { return this.where({ isActive: true }); };
userSchema.query.verified = function() { return this.where({ isVerified: true }); };
// Usage in service:
// const user = await User.findByCredentials(email, password);
// const tokens = user.generateTokenPair();
// const activeAdmins = await User.find().active().verified().where({ role: 'admin' });
module.exports = mongoose.model('User', userSchema);
Common Mistakes
Mistake 1 — Using arrow function for instance methods
❌ Wrong — this is undefined inside arrow function:
userSchema.methods.comparePassword = async (password) => {
return bcrypt.compare(password, this.password); // TypeError: this is undefined
};
✅ Correct — use regular function to access this:
userSchema.methods.comparePassword = async function(password) {
return bcrypt.compare(password, this.password); // this = document instance
};
Mistake 2 — Calling instance methods on .lean() results
❌ Wrong — lean() returns plain objects with no Mongoose methods:
const user = await User.findById(id).lean();
await user.comparePassword('secret'); // TypeError: user.comparePassword is not a function
✅ Correct — only use instance methods on full Mongoose documents:
const user = await User.findById(id).select('+password'); // no .lean()
await user.comparePassword('secret'); // works correctly
Mistake 3 — Not using isModified in password hash middleware
❌ Wrong — password hashed on every save even for unrelated changes:
userSchema.pre('save', async function(next) {
this.password = await bcrypt.hash(this.password, 12); // always hashes!
// User updates their name → password double-hashed → login broken
next();
});
✅ Correct — only hash when password is actually modified:
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
Quick Reference
| Task | Code |
|---|---|
| Add instance method | schema.methods.methodName = function() { ... } |
| Add static method | schema.statics.methodName = function() { ... } |
| Add query helper | schema.query.helperName = function() { return this.where({...}) } |
| Call instance method | doc.methodName(args) |
| Call static method | Model.methodName(args) |
| Chain query helper | Model.find().helperName().lean() |
| Check if field changed | doc.isModified('fieldName') |
| Save document changes | await doc.save() |
| Convert to plain object | doc.toObject({ virtuals: true }) |