Mongoose Models and Schema Design
Mongoose Schema is the most popular MongoDB ODM in the Node.js ecosystem—mastering Schema design is the foundation for building robust applications.
This course provides a systematic overview of Mongoose Schema field types, the difference between models and documents, virtual fields, and middleware.
ODM vs. Driver: Why Choose Mongoose: There are two mainstream approaches in the MongoDB Node.js ecosystem—1. Native driver (the mongodb package): lightweight, flexible, and without abstraction; it directly manipulates BSON documents and is suitable for scenarios with extreme performance and control requirements; 2. Mongoose (ODM): Provides advanced features such as schema definition, automatic type conversion, validators, middleware, and populate, making it suitable for business application development. Key reasons for choosing Mongoose: 1. Schema-as-document (self-describing field types and constraints); 2. Automatic validation blocks invalid data; 3. The middleware mechanism handles cross-cutting concerns (password hashing, soft deletes, logging); 4. populate replaces $lookup to simplify join queries. Scenarios where the native driver is preferred: 1. Performance-sensitive scenarios (Mongoose incurs abstraction overhead); 2. Unfixed schemas (logs, IoT data); 3. When you already have your own validation or middleware system.
1. What You'll Learn
- Mongoose Schema: 12+ Field Types
- The Fundamental Difference Between a Model and a Document
- virtual: A virtual field (not persisted but queryable)
- Middleware (pre/post hooks)
- Instance Methods and Static Methods
- Schema Inheritance and the Plugin Mechanism
2. A Review of Getting Started with Mongoose
mongoose = MongoDB + ORM Enhancement:
- Schema defines data structures (equivalent to DDL)
- The model provides a CRUD API
- Document instance methods (save, validate)
- Middleware intercepts requests before and after operations
Mongoose’s Core Abstraction Layers: Mongoose provides three layers of abstraction—1. Schema (structure definition): Declares field types, validation rules, default values, indexes, virtual fields, and middleware; this is equivalent to SQL’s DDL and constraints; 2. Model (collection operations): Compiled from the Schema, it provides class methods such as find, create, update, and delete; this is equivalent to SQL’s CRUD interface; 3. Document (Document Instance): An instance object created by the Model, featuring instance methods such as save, validate, and remove, as well as virtual fields; this is equivalent to a single record in an ORM. These three layers of abstraction allow developers to interact with document databases using an object-oriented approach.
Choosing Between Mongoose and Native Drivers: When to use Mongoose, and when to use the native MongoDB driver? Mongoose is suitable for: 1. Complex business logic (requiring validation, middleware, and virtual fields); 2. Team collaboration (schema-as-document, with type safety reducing bugs); 3. Stable data structures (controlled schema changes). The native driver is suitable for—1. Pursuing peak performance (Mongoose’s document wrapping incurs a 10–20% performance overhead); 2. Highly dynamic data structures (schemas can actually limit flexibility); 3. Simple read and write operations (CRUD without validation, direct manipulation of BSON). For most Node.js projects, using Mongoose is the right choice—development efficiency is more important than minor performance differences.
const mongoose = require('mongoose');
// Connect
await mongoose.connect('mongodb://localhost:27017/shopdb');
// Definition Schema
const UserSchema = new mongoose.Schema({...});
// Create Model
const User = mongoose.model('User', UserSchema);
// Usage Model CRUD
const user = await User.create({...});
graph TB
A[mongoose Schema] --> B[SchemaType<br/>Field Type]
A --> C[Model<br/>Constructor]
A --> D[Document<br/>Examples]
A --> E[virtual<br/>Virtual Fields]
A --> F[middleware<br/>Middleware]
B --> B1[String/Number/Date]
B --> B2[ObjectId/Decimal128]
B --> B3[Mixed/Map/Array]
C --> C1[find/create]
D --> D1[save/validate]
F --> F1[pre/post hooks]
style C fill:#d4edda
style D fill:#cce5ff
3. Schema Field Types
Concept Explanation: A schema is Mongoose’s definition layer for MongoDB document structures. It declares the type, validation rules, default values, and indexing strategies for each field. Although MongoDB itself is schema-less, Mongoose enforces typing and validation at the application layer, providing Node.js applications with data security similar to that of traditional ORMs.
How It Works: Mongoose Schema maintains field metadata (SchemaType objects) in application memory. When a document is created or updated, Mongoose performs type conversion and validation field by field; values that do not conform to the rules will throw ValidationError before save(). The schema does not affect MongoDB storage—when an old document is missing a new field, Mongoose returns undefined (which can be filled with default).
Implicit Behavior of Schema: Schema has several easily overlooked implicit behaviors—1. Every document automatically includes _id: ObjectId (even if not declared in the Schema); 2. By default, _id is not included in the toJSON output (unless toJSON: {virtuals: true} is set); 3. Type conversion is implicit—passing '123' to a Number field automatically converts it to 123 (in strict mode, strict: true); passing null to a field with required: true fails validation (null ≠ undefined, and required only checks for undefined); 4. Default values for nested objects must be returned by a function (default: () => ({})); otherwise, all documents will share the same reference (a classic JavaScript pitfall).
graph LR
A[Schema Definition] --> B[SchemaType<br/>Field Metadata]
B --> C[Type Conversion<br/>String/Number/Date...]
B --> D[Validation Rules<br/>required/min/max/enum]
B --> E[Default value<br/>default/immutable]
B --> F[Indexing Strategies<br/>index/unique]
C --> G[Document.save]
D --> G
E --> G
G --> H{Verification Passed?}
H -->|Yes| I[MongoDB insertOne]
H -->|No| J[ValidationError]
style I fill:#d4edda
style J fill:#f8d7da
| Type Classification | Mongoose Type | Typical Scenarios |
|---|---|---|
| Basic Types | String/Number/Boolean/Date | Name, Price, Toggle, Timestamp |
| Binary | Buffer | Image thumbnails, file contents |
| Reference | ObjectId + ref | Foreign key relationship (e.g., categoryId → Category) |
| Exact Value | Decimal128 | Currency Amount (to avoid floating-point errors) |
| Nesting | Schema Nesting | Fixed structures such as addresses and specifications |
| Array | [Type] | List of tags, collection of images |
| News | Mixed/Map | Metadata with Uncertain Structure, Multilingual Translation |
(1) 12+ types of SchemaType
SchemaType Selection Decisions: Choosing the correct SchemaType is the first step in data modeling—an incorrect choice can lead to data quality issues and performance risks. Core principles: 1. Currency amounts must use Decimal128 rather than Number (to avoid the floating-point trap where 0.1 + 0.2 ≠ 0.3); 2. Use ObjectId + ref for foreign key relationships instead of String (Mongoose’s populate method relies on ObjectId); 3. Use Mixed for fields with uncertain structures instead of Object (Mixed allows any value, while Object may trigger unexpected validation); 4. Use Map instead of Object for large numbers of key-value pairs (Map keys can be of any type and support forEach and map).
| Type | Mongoose Definition | BSON Type | Example |
|---|---|---|---|
| String | String |
String | String |
| Number | Number |
Double | Number |
| Boolean | Boolean |
Boolean | Boolean |
| Data | Date |
Data | Date |
| Buffer | Buffer |
Binary | Buffer |
| ObjectId | mongoose.Schema.Types.ObjectId |
ObjectId | ObjectId |
| Decimal128 | mongoose.Schema.Types.Decimal128 |
Decimal128 | Decimal128 |
| Map | Map |
Object | Map |
| Schema | new mongoose.Schema({...}) |
Object | embedded |
| Array | [Type] |
Array | [String] |
| Mixed | mongoose.Schema.Types.Mixed |
Object | Mixed |
Design Principles: Schema field definitions follow the "constraints as documentation" philosophy—the options for each field (required, min, max, enum, match) are not only runtime validation rules but also explicit declarations of the data contract. Well-defined fields make the Schema itself an executable documentation standard, allowing team members to understand the constraints of each field without having to consult the Wiki.
Architectural Decisions: Choosing field options requires striking a balance between strictness and flexibility. Excessively strict constraints (such as too many required fields) can hinder the system’s ability to evolve—new fields should be optional by default, with restrictions tightened only after the system has stabilized. Conversely, overly lax constraints lead to the accumulation of technical debt. Recommended Strategy: Apply strict constraints to core identifier fields (email, sku); treat auxiliary fields (nickname, avatar) more leniently; and use enums to restrict business status fields (role, status).
(2) Field Definition Options
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email'],
minlength: 5,
maxlength: 100,
index: true
},
age: {
type: Number,
required: true,
min: [0, 'Age cannot be negative'],
max: 150,
default: 18
},
role: {
type: String,
enum: {
values: ['customer', 'admin', 'moderator'],
message: 'Invalid role: {VALUE}'
},
default: 'customer'
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now,
immutable: true // Cannot be modified after creation
}
});
Best Practices: Guide to Selecting Schema Types for Production Environments—1. Always use Decimal128 for currency amounts instead of Number; floating-point errors are unacceptable in financial calculations; 2. Use ObjectId + ref for reference relationships instead of embedding full documents to avoid data redundancy and update inconsistencies; 3. Use Mixed or Map for metadata with uncertain structures, but be aware of the risk of validation failures; 4. Always use the Date type with timestamps: true for date fields to avoid time zone confusion with string-based dates.
Production Configuration of Schema Options: The Mongoose Schema options object controls global behavior—1. timestamps: true: Automatically manages createdAt/updatedAt; set createdAt to immutable: true to prevent accidental modifications; 2. toJSON: {virtuals: true}: JSON serialization includes virtual fields (not included by default); 3. toJSON: {virtuals: true}: toJSON() also includes virtual fields; 4. minimize: false: Do not compress empty objects (by default, Mongoose removes empty fields, which may cause expected fields to be missing in the front end); 5. strict: true: Rejects fields not defined in the schema (enabled by default; must remain enabled in production). These options should be determined early in the project, as later modifications may affect existing data and behavior.
Embedding vs. Referencing Trade-offs: This is the most critical decision in MongoDB schema design. Embedding stores related data within the same document, allowing all data to be retrieved in a single query, but it faces issues such as document bloat (16MB limit) and update complexity. Referencing uses ObjectId to establish relationships; it offers greater data independence and no size limits, but requires additional populate/$lookup queries. Decision Criteria: 1. Is the data always read together? Yes → Embedding; 2. Will the associated data grow indefinitely? Yes → Referencing; 3. Does the associated data need to be updated independently? Yes → Referencing.
| Dimension | Embedding | Referencing |
|---|---|---|
| Query Performance | High (single read) | Low (requires populate) |
| Data Consistency | Weak (redundant updates) | Strong (single-point updates) |
| Document size | ⚠️ May exceed 16MB | ✅ Separate for each document |
| Use Case | 1:N—Small and Fixed | 1:N—Large or Growing |
▶ Example 1: Practical Application of Composite Schema Types
const ProductSchema = new mongoose.Schema({
// Basic Types
sku: { type: String, required: true, unique: true },
title: { type: String, required: true },
price: { type: mongoose.Schema.Types.Decimal128, required: true },
stock: { type: Number, default: 0, min: 0 },
isActive: { type: Boolean, default: true },
// Date
releaseDate: { type: Date, required: true },
expiryDate: { type: Date },
// Binary
thumbnail: { type: Buffer },
// Quote(Foreign Key)
categoryId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category',
required: true
},
// Nested Documents
specs: {
screen: String,
battery: String,
weight: Number
},
// Array
tags: [String],
images: [{
url: String,
alt: String
}],
// Map(Dynamic key-value pairs)
translations: {
type: Map,
of: String
},
// Mixed(Any type)
metadata: mongoose.Schema.Types.Mixed
}, { timestamps: true });
4. Model and Document
Concept Explanation: A Model is Mongoose’s abstraction of a MongoDB collection—it is a constructor (class) that provides static methods such as find, create, and updateOne. A Document is an instance of a Model that represents a record in the database and provides instance methods such as save, validate, and remove. Understanding the difference between Model and Document is the foundation for using Mongoose correctly.
How It Works: mongoose.model('User', schema) does two things: (1) Compiles the schema into a model constructor; (2) Registers it with the Mongoose connection and maps it to the users collection (automatically pluralizing the name). new User({...}) creates a Document instance; at this point, the data exists only in memory and is written to MongoDB only when save() is called. User.create({...}) is equivalent to new User() + save().
sequenceDiagram
participant App as Application Code
participant Model as User Model
participant Doc as User Document
participant DB as MongoDB
App->>Model: User.create({email, age})
Model->>Doc: new User(data)
Doc->>Doc: validate()
Doc->>DB: insertOne()
DB-->>Doc: _id, createdAt
Doc-->>App: Back Document
App->>Model: User.find({role: 'admin'})
Model->>DB: find().toArray()
DB-->>Model: Array of original documents
Model->>Doc: hydrate(docs)
Doc-->>App: Document Array
style Model fill:#d4edda
style Doc fill:#cce5ff
(1) Key Differences
| Dimension | Model | Document |
|---|---|---|
| Essence | Constructor (class) | Model instance |
| Create | mongoose.model('User', schema) |
new User({...}) or User.create() |
| Quantity | 1 per collection | 1 per document |
| Method | Static methods (find, create) | Instance methods (save, validate) |
Type System Design Principles: Mongoose’s type system provides type safety at the application layer that is not natively available in MongoDB. The SchemaType object performs type conversion when a document is created (e.g., the string "42" is automatically converted to the number 42); if the conversion fails, a CastError is thrown. While this implicit conversion is convenient, it can also mask data issues—in production environments, it is recommended to enable strict mode strict: true in the schema (enabled by default) to reject undefined fields.
Risks and Precautions of Implicit Type Conversion: Mongoose’s implicit type conversion is a double-edged sword— 1. Convenience: When the front end passes {age: "25"}, it is automatically converted to the Number value 25, so developers do not need to perform the conversion manually; 2. Risks: "abc" becomes NaN (CastError) when converted to a Number, but if the schema is defined as a String and MongoDB stores a Number, the query may return "not found" (due to type mismatch); 3. Prevention Strategies: Explicitly define types in the schema (do not store Numbers where the schema specifies String), validate types at the application layer (using joi validation before Mongoose performs the conversion), and sanitize input at the routing layer (remove redundant fields). The most dangerous implicit conversion: The ObjectId field throws a CastError when it receives a non-24-digit hexadecimal string (e.g., req.params.id is passed "abc"); the ObjectId format should be validated at the routing layer.
(2) Document Instance Methods
// === Create Document ===
const user = new User({ email: 'alice@example.com' });
// === Document Properties ===
user.email; // 'alice@example.com'
user._id; // ObjectId
user.createdAt; // Date
user.isNew; // true(Not saved)
// === Document Instance Methods ===
await user.save(); // Save
await user.validate(); // Verification(Do not save)
user.toJSON(); // Convert to JSON
user.toObject(); // Convert to a regular object
user.remove(); // Delete (Obsolete, use deleteOne)
await user.deleteOne(); // Delete(Recommendations)
await user.populate('orders'); // Associative Filling
▶ Example 2: Hands-On Document Operations
Document Lifecycle Management: A document goes through four stages from creation to destruction—1. new User(data) creates an in-memory instance (isNew: true, not yet written to the database); 2. await user.save() persists the data to MongoDB (triggering the pre-save middleware and validation); 3. user.property = newValue modifies an in-memory property (marking the modified field with dirty tracking); 4. await user.deleteOne() deletes the document. Key difference: new + save is a two-step operation (data can be modified before saving), while User.create() is a one-step operation (writes directly to the database).
// === Create and Save ===
const user = new User({
email: 'alice@example.com',
username: 'alice',
passwordHash: '...'
});
await user.save();
// === Save after making changes ===
user.lastLoginAt = new Date();
user.loginCount += 1;
await user.save();
// === Convert to an object ===
const userObj = user.toObject();
delete userObj.passwordHash;
// === populate Relationship ===
const user = await User.findById(userId).populate({
path: 'orders',
options: { sort: { createdAt: -1 } }
});
5. virtual fields
Concept Explanation: virtual is a "computed field" mechanism provided by Mongoose—it defines getters and setters in the schema but is not persisted to MongoDB. Virtual fields are computed when a document is transformed by toJSON() or toObject(), making them ideal for derived properties (such as fullName = firstName + lastName) and association statistics (such as orderCount).
How It Works: A virtual getter is a function that performs a calculation each time doc.fullName is accessed. A virtual setter accepts a value and splits it into multiple fields. A relational virtual field (ref + localField + foreignField) is essentially a shorthand declaration for populate(); when queried, populate('orders') must still be called to trigger the association population.
graph TB
A[virtual Field] --> B[Computational<br/>fullName = firstName + lastName<br/>discountedPrice = price * 1-discount]
A --> C[Associative<br/>orders: ref Order<br/>orderCount: count true]
B --> D[Non-persistent<br/>In-Memory Computing]
C --> E[Requires populate<br/>Triggering a Joined Query]
D --> F[toJSONReal-time Output<br/>Needs to be set virtuals true]
E --> F
style D fill:#d4edda
style E fill:#cce5ff
| virtual type | Definition method | Is it persistent? | Does it require populate? |
|---|---|---|---|
| Computed getter | schema.virtual('x').get(fn) |
No | No |
| Computational setter | schema.virtual('x').set(fn) |
No | No |
| Associative (Document) | schema.virtual('x', {ref, localField, foreignField}) |
No | Yes |
| Associative (Count) | Same as above + count: true |
No | Yes |
// === Definition virtual ===
UserSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
UserSchema.virtual('isAdult').get(function() {
return this.age >= 18;
});
// === virtual setter(Reverse Settings)===
UserSchema.virtual('fullName').set(function(name) {
const parts = name.split(' ');
this.firstName = parts[0];
this.lastName = parts[1];
});
// === Enable virtual ===
UserSchema.set('toJSON', { virtuals: true });
UserSchema.set('toObject', { virtuals: true });
▶ Example 3: Practical Use of virtual
Performance Characteristics of virtual: A virtual getter is computed (not cached) on every access—if a field on which the virtual depends is modified, the next access will automatically return the new value. This means: 1. In list queries, calling a virtual field on each Document will execute the calculation function once; 2. virtual fields cannot be used in $match filtering (MongoDB is unaware of the existence of virtual fields); 3. virtual fields cannot be used for sorting (same as above); 4. When virtuals: true is enabled in toJSON, all virtual fields are calculated during JSON serialization and included in the output.
Choosing Between Virtual and Calculated Fields: When to use virtual fields and when to store calculated fields in the schema—1. Virtual fields are suitable when: the calculation result depends on fields in the current document (e.g., fullName = firstName + lastName), the result is not needed for queries, sorting, or aggregations, and the calculation cost is low (simple string concatenation or mathematical operations); 2. Stored fields are suitable when: they are needed for queries or sorting (e.g., discountedPrice needs to be sorted by discounted price), the calculation cost is high (e.g., cross-collection aggregations), or persistence is required (e.g., redundant counts like commentCount). Selection principle—"If it needs to be used in MongoDB queries, it must be stored; if it’s only displayed at the application layer, using a virtual field is cleaner."
// === Calculated Fields ===
ProductSchema.virtual('discountedPrice').get(function() {
if (!this.discount) return this.price;
return this.price * (1 - this.discount);
});
// === Related Fields(Non-persistent)===
UserSchema.virtual('orders', {
ref: 'Order',
localField: '_id',
foreignField: 'userId'
});
// Usage:
const user = await User.findById(userId).populate('orders');
console.log(user.orders); // Array of associated orders
// === Inverse Correlation ===
UserSchema.virtual('orderCount', {
ref: 'Order',
localField: '_id',
foreignField: 'userId',
count: true // Count only the number,Does not return a document
});
const user = await User.findById(userId).populate('orderCount');
console.log(user.orderCount); // 25
6. Middleware
Concept Explanation: Mongoose middleware are hook functions that automatically execute before and after specified database operations. Pre middleware runs before an operation (e.g., password hashing, data cleansing), while post middleware runs after an operation (e.g., audit logging, push notifications). Middleware is Mongoose’s most powerful extension mechanism, allowing business logic to be decoupled from data operations.
How It Works: Mongoose middleware uses the "onion model"—multiple pre middleware functions are executed in the order they were registered, followed by the actual operation, and finally, the post middleware functions are executed in the order they were registered. Each pre middleware function must call next() or return a Promise; otherwise, the operation is suspended. post middleware functions receive the operation result as a parameter and cannot modify the operation’s behavior.
sequenceDiagram
participant App as Application Code
participant Pre1 as pre save #1<br/>Password Hash
participant Pre2 as pre save #2<br/>Email (lowercase)
participant DB as MongoDB
participant Post1 as post save #1<br/>Audit Log
participant Post2 as post save #2<br/>Welcome Email
App->>Pre1: doc.save()
Pre1->>Pre2: next()
Pre2->>DB: insertOne()
DB-->>Post1: Success
Post1->>Post2: next(doc)
Post2-->>App: Back doc
(1) Types of Middleware
Middleware Execution Chain Design: Mongoose middleware uses the onion model—requests travel from the outer layer to the inner layer, and responses travel from the inner layer to the outer layer. pre hooks are executed sequentially in the order they were registered, and each must call next() to pass control; after the actual operation is executed, post hooks are executed in the order they were registered. This design naturally separates concerns: password hashing, data cleansing, and audit logging each occupy a separate middleware, with no coupling between them.
| Type | Trigger Condition | Purpose |
| pre('save') | Before saving | Password hash, timestamp |
| post('save') | After saving | Logs, Notifications |
| pre('validate') | Pre-validation | Data cleaning |
| pre('find') | Before query | Filter criteria |
| pre('remove') | Before Deleting | Clear Associated Data |
(2) pre-save middleware
Best Practices: The three most common uses of the pre-save middleware are password hashing (using isModified to prevent duplicate hashing), data normalization (converting email addresses to lowercase, trimming strings), and timestamp maintenance. The key point is that this refers to the current Document instance, so the pre-save middleware cannot be triggered during batch operations such as Model.updateOne()—for batch operations, use the Query middleware pre('updateOne') instead.
// === Password Hash(Classic Scenes)===
UserSchema.pre('save', async function(next) {
if (!this.isModified('passwordHash')) return next();
// The password has been changed.,Re-hash
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
next();
});
// === Timestamp ===
UserSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
(3) pre-find middleware
Query Middleware vs. Document Middleware: pre find is a Query middleware (where this refers to the Query object rather than the Document object) and is suitable for controlling global query behavior—such as automatically filtering out deleted documents, populating associations by default, and sorting by default. The regular expression format pre(/^find/) matches all query operations, including find, findOne, and findById, ensuring consistent behavior. Note: Query middleware cannot access document data (because the query has not yet been executed); it can only modify query conditions.
// === Automatically filter deleted documents ===
UserSchema.pre(/^find/, function(next) {
this.find({ isDeleted: { $ne: true } });
next();
});
// === Automatic populate Relationship ===
UserSchema.pre('find', function(next) {
this.populate('categoryId');
next();
});
// === Default Sort Order ===
UserSchema.pre('find', function(next) {
this.sort({ createdAt: -1 });
next();
});
(4) The "post save" middleware
Design of Side Effects in the Post Middleware: The post middleware executes after the operation is complete and cannot modify document data (which has already been written to the database); it is suitable for triggering side effects—such as audit logs, push notifications, and cache updates. Key Features: 1. post_save accepts a doc parameter (the saved document); 2. this.wasNew determines whether the operation is a create or an update; 3. this.modifiedPaths() retrieves a list of modified fields; 4. The error-handling middleware uses the 4-parameter version (err, doc, next) and is specifically designed to catch operation exceptions.
// === Send the welcome email after saving ===
UserSchema.post('save', function(doc, next) {
if (this.wasNew) {
sendWelcomeEmail(doc.email);
}
next();
});
// === Record an audit log after saving ===
UserSchema.post('save', function(doc) {
AuditLog.create({
action: 'user.updated',
userId: doc._id,
changes: this.modifiedPaths()
});
});
▶ Example 4: Hands-On Practice with Integrated Middleware
// === User Schema Middleware ===
UserSchema.pre('save', async function(next) {
// 1. Password Hash
if (this.isModified('passwordHash')) {
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
}
// 2. Email (lowercase)
if (this.isModified('email')) {
this.email = this.email.toLowerCase();
}
next();
});
UserSchema.pre(/^find/, function(next) {
// By default, deleted users are not returned.
this.find({ isDeleted: { $ne: true } });
next();
});
// === Error-handling middleware ===
UserSchema.post('save', function(error, doc, next) {
if (error.name === 'MongoServerError' && error.code === 11000) {
next(new Error('Email already exists'));
} else {
next(error);
}
});
7. Instance Methods and Static Methods
Concept Explanation: Mongoose allows you to define two types of custom methods on a schema: instance methods, which are attached to each document and operate on individual documents (e.g., user.comparePassword()); and static methods, which are attached to a model and operate on the entire collection (e.g., User.findByEmail()). These two types of methods allow business logic to be encapsulated within the data model, following the "fat model" design principle.
How It Works: Instance methods are defined via Schema.methods. At new Model(), Mongoose binds the method to the Document prototype chain, and within the method, this points to the current Document. Static methods are defined via Schema.statics and mounted onto the Model constructor; within the method, this refers to the Model itself, allowing direct calls to this.find() and so on.
graph TB
A[Schema Methods] --> B[Instance Methods<br/>Schema.methods]
A --> C[Static Methods<br/>Schema.statics]
B --> D["user.comparePassword(pwd)<br/>this = Currently Document"]
B --> E["user.generateToken()<br/>this = Currently Document"]
B --> F["user.softDelete()<br/>this = Currently Document"]
C --> G["User.findByEmail(email)<br/>this = User Model"]
C --> H["User.findActive()<br/>this = User Model"]
C --> I["User.getStatistics()<br/>this = User Model"]
style B fill:#cce5ff
style C fill:#d4edda
| Comparison Criteria | Instance Methods | Static Methods |
|---|---|---|
| Define Location | Schema.methods |
Schema.statics |
| Attachment Object | Document Prototype | Model Constructor |
| this points to | the current Document | the Model itself |
| Call Method | doc.method() |
Model.method() |
| Typical Uses | Password comparison, soft deletion | Conditional searches, aggregate statistics |
Boundaries of Method Design: The distinction between instance methods and static methods follows the Single Responsibility Principle—1. Instance methods: Operate on the data of a single document itself (e.g., comparePassword for password comparison, softDelete for marking deletion, toJSON for anonymized output), without querying the database (or querying only its own associated data); 2. Static methods: Operate on collection-level data (e.g., findByEmail for cross-document queries, getStatistics for aggregated statistics, and bulkImport for bulk imports), and require the Model’s query capabilities. Blurring these boundaries leads to design confusion—placing findByEmail in instance methods (since an instance must exist before a search can be performed, creating a logical contradiction) or placing comparePassword in static methods (which requires passing in both the document and the password, making it redundant).
(1) Instance Methods
// === Define an instance method ===
UserSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.passwordHash);
};
UserSchema.methods.generateAuthToken = function() {
return jwt.sign({ id: this._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
};
// === Usage ===
const user = await User.findOne({ email: 'alice@example.com' });
const isValid = await user.comparePassword('password123');
const token = user.generateAuthToken();
(2) Static Methods
// === Defining Static Methods ===
UserSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() });
};
UserSchema.statics.findActive = function() {
return this.find({ isActive: true });
};
// === Usage ===
const user = await User.findByEmail('ALICE@example.com');
const activeUsers = await User.findActive();
▶ Example 5: Putting the Integrated Approach into Practice
Fat Models vs. Lean Models: Mongoose advocates for "fat model" design—where business logic is encapsulated within Model/Document methods, so that Controllers simply call user.comparePassword() instead of implementing bcrypt.compare themselves. Advantages of fat models: 1. Code reuse (multiple Controllers share the same method); 2. Encapsulation (external code is unaware of how passwords are verified); 3. Testability (Model methods can be unit-tested independently). With thin models, Controllers are filled with duplicate code; changing a single validation rule requires modifying N Controllers.
// === Complete User Model ===
const UserSchema = new mongoose.Schema({...});
// Instance Methods
UserSchema.methods = {
comparePassword: async function(candidate) {
return await bcrypt.compare(candidate, this.passwordHash);
},
softDelete: async function() {
this.isDeleted = true;
this.deletedAt = new Date();
return await this.save();
}
};
// Static Methods
UserSchema.statics = {
findByEmail: function(email) {
return this.findOne({ email: email.toLowerCase() });
},
getStatistics: async function() {
return await this.aggregate([
{ $group: { _id: '$role', count: { $sum: 1 } } }
]);
}
};
❓ FAQ
virtuals: true in toJSON for them to be returned in the API.async function or return a Promise. You must call next() or return a Promise; otherwise, it will be suspended.pre phase next(error) and in the post phase next(error) are passed to Mongoose's error handling.discriminators (discriminator): const AdminUser = User.discriminator('admin', AdminSchema), where all discriminators share the same set.📖 Summary
- mongoose 12+ SchemaTypes: String/Number/Date/ObjectId/Decimal128/Map/Mixed
- A
Modelis a constructor (class), and aDocumentis an instance - virtual fields are not persisted, but can be returned in toJSON
- Middleware: pre (before the operation) + post (after the operation)
- Instance methods: Exclusive to each Document (e.g., comparePassword)
- Static methods: Exclusive to each Model (e.g., findByEmail)
📝 Exercises
- Basic Exercise (⭐): Define the User schema (including email, username, age, and role), and create the User model.
- Basic Question (⭐): Use
virtualto define thefullNamefield (firstName+lastName). - Advanced Exercise (⭐⭐): Implement password hashing (isModified check) using the
pre-savemiddleware. - Advanced Problem (⭐⭐): Use the
pre_findmiddleware to automatically filter out deleted users. - Challenge (⭐⭐⭐): Implement a complete User model (including password hashing, virtual fields, instance methods, and static methods) that supports registration, login, soft deletion, and reporting features.



