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


2. A Review of Getting Started with Mongoose

mongoose = MongoDB + ORM Enhancement:

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.

JAVASCRIPT
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({...});

100%
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).

100%
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

JAVASCRIPT
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

JAVASCRIPT
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().

100%
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

JAVASCRIPT
// === 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).

JAVASCRIPT
// === 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.

100%
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
JAVASCRIPT
// === 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."

JAVASCRIPT
// === 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.

100%
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.

JAVASCRIPT
// === 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.

JAVASCRIPT
// === 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.

JAVASCRIPT
// === 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

JAVASCRIPT
// === 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.

100%
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

JAVASCRIPT
// === 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

JAVASCRIPT
// === 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.

JAVASCRIPT
// === 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

Q If the Mongoose schema changes, do I need to migrate the database?
A No. The Mongoose schema is at the application layer, and MongoDB is schema-less. New fields are added automatically, and old documents may be missing some fields.
Q Are "virtual" fields saved to the database?
A No. "Virtual" fields are computed fields and are not persisted. However, you must enable virtuals: true in toJSON for them to be returned in the API.
Q Can the pre-save middleware be asynchronous?
A Yes. Use async function or return a Promise. You must call next() or return a Promise; otherwise, it will be suspended.
Q How do I handle errors in middleware?
A Errors thrown in the pre phase next(error) and in the post phase next(error) are passed to Mongoose's error handling.
Q How is schema inheritance implemented?
A Using discriminators (discriminator): const AdminUser = User.discriminator('admin', AdminSchema), where all discriminators share the same set.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Define the User schema (including email, username, age, and role), and create the User model.
  2. Basic Question (⭐): Use virtual to define the fullName field (firstName + lastName).
  3. Advanced Exercise (⭐⭐): Implement password hashing (isModified check) using the pre-save middleware.
  4. Advanced Problem (⭐⭐): Use the pre_find middleware to automatically filter out deleted users.
  5. 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.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏