Models — CRUD Methods, Static Methods, and Instance Methods

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
Note: There is an important distinction between 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.
Tip: Model static methods are ideal for complex, reusable query patterns that belong to the domain — not generic CRUD. 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.
Warning: Do not use arrow functions for instance methods or statics that reference 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 })

🧠 Test Yourself

Which type of Mongoose method is most appropriate for User.findByEmail(email) — a reusable query that searches the users collection by email?