Mongoose Data Validation and Middleware
Data validation is the first line of defense at the application layer—Mongoose validators can block 80% of invalid data.
In this course, you'll learn about Mongoose's built-in validators, custom validators, validateBeforeSave, and middleware through hands-on exercises.
The Role of Validators in the Data Protection Framework: Comprehensive data protection consists of a three-tier structure—1. Database layer (MongoDB Schema Validation/unique indexes): The last line of defense, preventing dirty data from being inserted into the database due to application-layer vulnerabilities; 2. Application layer (Mongoose validators + middleware): The primary line of defense, intercepting most invalid data and providing user-friendly error messages; 3. Front-end layer (form validation): The user experience layer, providing immediate feedback but not secure (can be bypassed). The relationship among these three layers: Front-end validation ≠ security (can be bypassed), Mongoose validation = security baseline (cannot be bypassed), database validation = safety net (ensures no invalid data is stored even if the application layer fails). Never rely solely on front-end validation.
When Mongoose Validators Run: Mongoose validators run automatically when document.save() and document.validate() are called (with validateBeforeSave set to true by default). Note: 1. Update operations such as Model.updateOne() and updateMany() do not trigger validators—they operate directly on the database, bypassing the Mongoose document layer; 2. To validate during an update, use Model.findOneAndUpdate with the runValidators: true option; 3. Validators run before the pre('save') middleware—if validation fails, the pre('save') middleware will not execute (password hashing will not be performed on invalid data). Understanding this execution order is crucial for debugging validation issues.
1. What You'll Learn
- Built-in validators (required/min/max/enum/match)
- Custom validator functions
- The
validateBeforeSaveoption - async validator: Asynchronous validation
- Hands-On Middleware: Password Hashing, Soft Deletion, Auto-Populate
2. Built-in Validators
Concept Explanation: Mongoose's built-in validators are validation rules provided by SchemaType, allowing you to declare common constraints without writing custom functions. These include required (required), min/max (numeric range), minlength/maxlength (string length), enum (enumeration values), match (regular expression matching), and others. They are automatically enforced for save() and validate() and serve as the first line of defense for data quality at the application layer.
How It Works: Before a Document is saved (or when validate() is explicitly called), Mongoose iterates through the SchemaType of all fields and executes the built-in validators one by one. Each validator receives the field value and either returns a boolean or throws an error. If validation fails, Mongoose collects all errors into a ValidationError.errors object, which includes the field path, error type, and custom message.
Implicit Behavior of Built-in Validators: Built-in validators have several easily overlooked implicit behaviors— 1. The required validator checks whether a field exists and is not undefined, but an empty string '' passes the required check (you must use minlength: 1 to prevent empty strings); 2. min and max only work with the Number type (for String types, min and max are compared by lexicographical order rather than numerical value); 3. enum is case-sensitive ('Admin' and 'admin' are different values); 4. match only checks the format, not the content (for example, /\d+/ matches "abc123def"; use /^d+$/ for a strict match of pure digits).
Custom Error Messages for Validators: Each built-in validator supports custom error messages—required: [true, 'Username cannot be empty'], min: [0, 'Age cannot be negative'], enum: {values: ['customer', 'admin'], message: '{VALUE} is not a valid role'}. Message templates support variables such as {VALUE} (current value), {PATH} (field name), and {MIN}/{MAX} (boundary values). Good error messages should allow front-end developers to know how to fix the issue without consulting the schema—"Username must be 3–30 alphanumeric characters and underscores" is much more helpful than "Validator failed for path username."
graph TD
A[doc.save] --> B[validate Phase]
B --> C[Inspection required]
B --> D[Inspection min/max]
B --> E[Inspection enum]
B --> F[Inspection match]
B --> G[Inspection minlength/maxlength]
C --> H{All passed?}
D --> H
E --> H
F --> H
G --> H
H -->|Yes| I[pre save hooks]
H -->|No| J[ValidationError<br/>Collect all errors]
I --> K[MongoDB insertOne]
style K fill:#d4edda
style J fill:#f8d7da
| Validator | Applicable Type | Trigger Condition | Error Message Template |
|---|---|---|---|
required |
All | When a field is missing | '{PATH} is required' |
min/max |
Number, Date | Value out of range | '{PATH} must be >= {MIN}' |
minlength/maxlength |
String | When the length is invalid | '{PATH} must be >= {MINLENGTH} chars' |
enum |
String | When the value is not in the enumeration | '{VALUE} is not valid' |
match |
String | When the regular expression does not match | '{PATH} is invalid' |
unique |
All | When There Is an Index Conflict | E11000 duplicate key |
(1) Complete List
Validator Priority and Execution Order: Mongoose executes validators in a fixed order—1. Built-in type conversions (String→Number, etc.); 2. Required checks; 3. Built-in range checks (min/max/minlength/maxlength/enum/match); 4. Custom synchronous validators; 5. Custom asynchronous validators. This means that even if a custom validator passes, a built-in validator may still fail. Design principle: Prioritize built-in validators (better performance, standardized error messages); use custom validators only in scenarios not covered by built-in validators.
Designing Error Messages for Custom Validators: A good error message should include three elements—1. Which field has an error (Mongoose automatically provides the path); 2. Why the error occurred (e.g., “Username must start with a letter” rather than “Validation failed”); 3. What the expected format is (e.g., “Format: Must start with a letter, 3–30 alphanumeric characters or underscores”). The message supports template variables: {PATH} (field name), {VALUE} (current value), {MINLENGTH} (minimum length), etc. Error messages in production should allow front-end developers to fix issues without consulting documentation—this is more valuable than brief but vague messages.
| Validator | Applicable Type | Description |
|---|---|---|
required |
All | Required fields |
min/max |
Number, Date | Numeric Range |
minlength/maxlength |
String | String length |
enum |
String | Enumerated values |
match |
String | Regular expression match |
unique |
All | Unique Index (Database Layer) |
▶ Example 1: Practical Use of the Built-in Validator
Analysis of Validation Layer Strategies: Deciding at which layer to perform data validation is a critical architectural decision. Application-layer validation (Mongoose validators) is flexible and controllable—it supports custom logic, asynchronous validation, and user-friendly error messages—but is only effective for Node.js clients. Database-layer validation ($jsonSchema) is strict and reliable—it applies to all clients—but only supports static rules. The best strategy is dual validation: use Mongoose for primary validation (business rules, user-friendly prompts), and $jsonSchema as a fallback (basic structural protection to prevent bypassing the application layer).
Validation Execution Order: Mongoose validations are executed in a strict order: 1. Built-in SchemaType validations (required → type cast → min/max/minlength/maxlength/enum/match); 2. Custom synchronous validators; 3. Custom asynchronous validators; 4. pre-validate middleware; 5. pre-save middleware. A failure at any stage will halt subsequent validation and throw a ValidationError.
Choosing Between Synchronous and Asynchronous Validators: Mongoose validators come in two types: synchronous and asynchronous. Synchronous validators return a boolean (e.g., validator: v => v.length >= 3), while asynchronous validators return a Promise (e.g., validator: async function(v) { const existing = await User.findOne({email: v}); return !existing; }). Selection Guidelines: 1. Use synchronous validators for validations that do not require a database query (format checks, range checks, regular expression matching); 2. Use asynchronous validators for validation that requires a database query (uniqueness checks, referential integrity checks). Note: Asynchronous validators are 10–100 times slower than synchronous validators (each validation involves a database query), so their use should be minimized—uniqueness checks can be replaced with unique indexes (guaranteed at the database layer, which is more reliable and efficient than application-layer queries).
Combined Validator Patterns: Multiple validators can be combined to cover different scenarios—1. Required + Format: required: [true, 'Email is required'] + match: [/^.+@.+$/, 'Invalid email format'] (first checks for existence, then checks for format); 2. Range + Custom: min: [0, 'Cannot be a negative number'] + validator: v => v % 1 === 0 (first check the range, then check if it’s an integer); 3. Enum + Condition: enum: ['draft', 'published'] + a custom validator that checks that "when changing from 'draft' to 'published', the 'content' field must not be empty." The order of the validators is important—place required first (so subsequent rules are skipped if the field is empty), format checks in the middle, and business logic checks last.
| Verification Layer | Location | Advantages | Disadvantages |
|---|---|---|---|
| Built into Mongoose | Application layer | Zero configuration, automatic execution | Limited to common rules |
| Mongoose Custom | Application Layer | Flexible, Asynchronous | Increases Code Base |
| $jsonSchema | Database layer | Applies to all clients | Static rules only |
const UserSchema = new mongoose.Schema({
email: {
username: {
type: String,
required: true,
unique: true,
minlength: [3, 'Username at least 3 chars'],
maxlength: [30, 'Username at most 30 chars'],
match: [/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, underscores']
},
age: {
type: Number,
required: true,
min: [0, 'Age cannot be negative'],
max: [150, 'Age too large']
},
role: {
type: String,
enum: {
values: ['customer', 'admin', 'moderator'],
message: '{VALUE} is not a valid role'
},
default: 'customer'
},
passwordHash: {
type: String,
required: true,
minlength: 60 // bcrypt Hash Length
}
});
sequenceDiagram
participant App as Application Code
participant Schema as mongoose Schema
participant DB as MongoDB
App->>Schema: User.create({email, age})
Schema->>Schema: Verification required
Schema->>Schema: Verification match (emailFormat)
Schema->>Schema: Verification min/max (age)
alt Verification Passed
Schema->>DB: insertOne()
DB-->>Schema: Success
Schema-->>App: Back User Object
else Verification Failed
Schema-->>App: ValidationError
end
3. Custom Validator
Concept Explanation: When built-in validators cannot meet business rules (such as "username cannot start with a number" or "email domain blacklist"), Mongoose allows you to write custom validator functions in field definitions. Custom validators are divided into synchronous and asynchronous types: synchronous functions return boolean, while asynchronous functions return Promise<boolean>. Both are configured via the validate option.
How It Works: Custom validators run after built-in validation. Synchronous validators receive field values and return true for success or false for failure. Asynchronous validators receive field values and return a Promise: resolve(true) for success and resolve(false) for failure. In case of failure, an error message is generated using the template specified by the message option. Note: Asynchronous validators increase the delay for each save operation.
Validator Combination Pattern: Multiple validators can be combined—Mongoose executes them in the order they are declared, and validation is successful only if all pass. Common combinations: 1. required + match (required and correctly formatted); 2. minlength + custom password strength validation (checks both length and complexity); 3. min + custom range validation (e.g., for “discount rate between 0 and 1,” use min: 0 + max: 1; however, for “amount cannot be 0,” a custom validator is required: validator: v => v !== 0). When combining validators, pay attention to distinguishing error messages—each validator’s independent message template helps the front end pinpoint the problematic field accurately.
graph LR
A[doc.save] --> B[Built-in validators<br/>required/min/max/enum...]
B --> C{Built-in Pass?}
C -->|No| D[ValidationError]
C -->|Yes| E[Custom validators<br/>Synchronize/Asynchronous]
E --> F{Custom Pass?}
F -->|No| D
F -->|Yes| G[pre save hooks]
G --> H[MongoDB write]
style D fill:#f8d7da
style H fill:#d4edda
| Comparison Dimension | Synchronous Validator | Asynchronous Validator |
|---|---|---|
| Definition Method | validator: v => v >= 18 |
validator: async v => await check(v) |
| Execution Speed | Fast (no I/O) | Slow (may require a database query) |
| Typical Uses | Format validation, range checking | Uniqueness validation, blacklist checking |
| Error Handling | Return false | resolve(false) |
| Performance Recommendations | Use as a Priority | Use Only When Necessary |
(1) Synchronous validator
Design Patterns for Synchronous Validators: Synchronous validators are suitable for purely logical validation—rules that do not involve database queries. Common patterns: 1. Format validation (email addresses must not contain the “+” symbol; usernames must not start with a number); 2. Range validation (age >= 18; discount rate between 0 and 1); 3. Length validation (password >= 8 characters; phone number 11 digits); 4. Combined validation (endDate >= startDate). Design principles: The validator function should return only a boolean (true for success, false for failure) and should not perform any side effects (do not modify this or throw exceptions).
Customizing Error Messages for Custom Validators: A good error message should let developers know what the problem is at a glance—validator: {validator: v => /^[a-z]/.test(v), message: 'Username must start with a lowercase letter (current value: {VALUE})'}. Message templates support {VALUE} (current value), {PATH} (field name), {MIN}/{MAX} (boundary values), and {LENGTH} (current length). Examples of Chinese error messages: 'Age must be between {MIN} and {MAX}; current value {VALUE}', 'Password must be at least {MINLENGTH} characters long; current length {LENGTH}'. The more specific the message, the more efficient front-end integration testing becomes—"Username format is incorrect" is 100 times more helpful than "Validator failed."
The Boundary Between Validators and Business Logic: Mongoose validators should only validate the validity of the data itself (“Is the data correct?”) and should not include business logic (“Is the operation allowed?”). Criteria for distinction: 1. Data validation (part of the schema): Email format, password length, amount range—these rules do not change with business scenarios; 2. Business logic (belongs to the Controller/Service): whether a user has permission to make changes, whether the balance is sufficient, whether inventory is available—these rules vary by scenario. Consequences of mixing the two: Password changes and resets require different validation rules, but the schema has only a single set of validators, resulting in password length validation being skipped or hard-coded during password resets.
const UserSchema = new mongoose.Schema({
email: {
type: String,
validate: {
validator: function(v) {
// Email addresses cannot contain + sign (Email addresses with tags are not accepted.)
return !v.includes('+');
},
message: 'Email cannot contain + character'
}
},
age: {
type: Number,
validate: {
validator: function(v) {
return v >= 18;
},
message: 'Must be at least 18 years old'
}
}
});
(2) async validator (Asynchronous)
Choosing Between Synchronous and Asynchronous Validators: Synchronous validators are suitable for in-memory computations (numeric comparisons, regular expression matches, enumeration checks), while asynchronous validators require database lookups or calls to external APIs. Selection principles—1. Use synchronous validators whenever possible (better performance, no side effects); 2. Use asynchronous validators only when a database lookup is required (e.g., checking username uniqueness, sensitive word detection); 3. Performance overhead of asynchronous validators: Each save operation requires an await for the database query; during batch operations, N validations = N database queries; 4. Alternative to asynchronous validators: Place uniqueness checks in the pre-save middleware (which can be optimized for batch processing) rather than using an async validator for individual fields.
Risks and Mitigation Strategies for Asynchronous Validators: Asynchronous validators trigger a database query every time data is saved, which can become a performance bottleneck in high-concurrency scenarios. Mitigation strategies: 1. Use them only when necessary (e.g., uniqueness checks, blacklist checks); 2. Replace asynchronous uniqueness checks with unique indexes (more efficient at the database layer); 3. Downgrade non-critical validations to periodic batch checks; 4. Cache the results of frequent queries (e.g., blacklist entries can be cached for 5 minutes).
Validator Composition Pattern: Complex business rules often require a combination of multiple validators—for example, a username must simultaneously meet the following criteria: contain no sensitive words (asynchronous), not start with a number (synchronous), and be 3–30 characters long (built-in). Mongoose executes all validators sequentially; if the first one fails, the process stops. It is recommended to place lightweight synchronous validations first (to fail fast) and heavyweight asynchronous validations last (to avoid unnecessary I/O).
const UserSchema = new mongoose.Schema({
username: {
type: String,
validate: {
validator: async function(v) {
// Check if the username contains sensitive words
const banned = await BannedWords.findOne({ word: v });
return !banned;
},
message: 'Username contains banned word'
}
},
email: {
type: String,
validate: {
validator: async function(v) {
// Check if the email domain has been blocked
const domain = v.split('@')[1];
const blocked = await BlockedDomains.findOne({ domain });
return !blocked;
},
message: 'Email domain is blocked'
}
}
});
(3) The validateBeforeSave option
// === Default behavior:save Front Auto validate ===
const user = new User({ email: 'invalid' });
await user.save(); // ValidationError
// === Skip validate(Not recommended)===
const user = new User({ email: 'invalid' });
await user.save({ validateBeforeSave: false });
// === Manual validate ===
const user = new User({ email: 'invalid' });
try {
await user.validate();
} catch (err) {
console.error(err.message); // ValidationError
}
4. Hands-On Middleware
Concept Overview: Mongoose middleware are hook functions that are automatically triggered during the data operation lifecycle. This section focuses on the most commonly used middleware patterns in real-world development: password hashing (pre-save), timestamp management (pre-save / built-in timestamps), soft deletes (pre-find filtering + instance methods), and auto-population (pre-find populate). These patterns cover 80% of middleware use cases.
How It Works: Middleware is registered on a Schema, and Mongoose automatically invokes it when performing the corresponding operations. pre('save') is executed before a write operation and can modify document data; pre(/^find/) is executed before a query and can modify query conditions; post('save') is executed after a write operation and can trigger side effects (logging, notifications). Middleware is executed in a chained manner, and each function must call next() to pass control.
graph TB
A[Middleware Patterns] --> B[Password Hash<br/>pre save<br/>isModifiedTesting]
A --> C[Timestamp<br/>pre save / timestamps option]
A --> D[Soft Delete<br/>pre findFilter<br/>softDeleteMethods]
A --> E[Auto-fill<br/>pre find populate]
B --> F["Only when changing the password<br/>Re-hash"]
C --> G["Automatic Maintenance<br/>createdAt/updatedAt"]
D --> H["Query Auto-Exclusion<br/>isDeleted: true"]
E --> I["Query Autocomplete<br/>Related Documents"]
style B fill:#d4edda
style C fill:#cce5ff
style D fill:#fff3cd
style E fill:#e2d5f1
| Middleware Pattern | Triggering Conditions | Core API | Typical Uses |
|---|---|---|---|
| Password Hash | pre-save | isModified('password') |
Hash stored only when the password is changed |
| Timestamp | pre-save / timestamps | isNew, Date.now |
Automatically maintain time fields |
| Soft Delete | pre /^find/ | this.find({isDeleted:{$ne:true}}) |
Automatically exclude deleted items from search |
| Auto-fill | pre find | this.populate(path) |
Query auto-association loading |
| Cascading Deletion | post findOneAndDelete | Model.deleteMany() |
Clean up associations when deleting the main document |
(1) Password Hashing Middleware
Password Security Design Principles: Password hashing is the cornerstone of a secure system. bcrypt is the industry-standard choice—it includes built-in salting, an adjustable cost factor (10–12), and resistance to GPU/ASIC attacks. Key design points: 1. Use isModified() to avoid rehashing with every save; 2. By default, password fields are not returned using select: false; 3. Pre-hash proof length (to prevent a 60-character password from bypassing the minlength requirement after hashing); 4. Use the asynchronous version to avoid blocking the event loop.
UserSchema.pre('save', async function(next) {
// Re-hash only when the password field is modified
if (!this.isModified('passwordHash')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.passwordHash = await bcrypt.hash(this.passwordHash, salt);
next();
} catch (err) {
next(err);
}
});
(2) Timestamp Middleware
"timestamps" Option vs. Manual Middleware: Mongoose’s timestamps: true option automatically manages createdAt and updatedAt, eliminating the need to manually write pre-save middleware—this is the recommended approach. Manual middleware should only be used when there are specific requirements: 1. Custom timestamp field names (e.g., created_at instead of createdAt); 2. When timestamps need to be associated with a user ID (e.g., updatedBy); 3. When additional logic needs to be triggered when the timestamp is updated. In 95% of cases, simply using timestamps: true is sufficient.
// === mongoose Built-in timestamps option ===
const schema = new mongoose.Schema({...}, { timestamps: true });
// Auto-add createdAt/updatedAt
// === Custom Timestamp Middleware ===
schema.pre('save', function(next) {
this.updatedAt = new Date();
if (this.isNew) {
this.createdAt = new Date();
}
next();
});
(3) Soft-Delete Middleware
Soft Delete Architecture Decision: Physical deletion (hard delete) is irreversible and violates data compliance requirements (e.g., the GDPR’s “right to be forgotten” requires anonymization rather than deletion). Soft delete implements logical deletion using the isDeleted flag—the pre-find middleware automatically filters out deleted documents, making the process transparent to business code. The key reasons for choosing soft delete are: 1. Data recoverability (accidentally deleted data can be restored); 2. Audit requirements (retaining operation history); 3. Reference integrity (references from other documents remain intact).
Storage Costs and Cleanup Strategies for Soft Deletion: The cost of soft deletion is that "zombie data" continues to occupy storage and index space—1. Storage bloat: Assuming 30% of the data is soft-deleted, the primary collection’s size expands by 43% (100 / 70 ≈ 1.43), and the index expands similarly; 2. Query Impact: The pre-find middleware adds the condition isDeleted: {$ne: true} to every query; although indexes are available, this increases query complexity; 3. Cleanup Strategy: A cron job migrates data that has been soft-deleted for more than 90 days to an archive collection (physically deleting the records from the main collection); the archive collection is retained for one year before being permanently deleted; 4. GDPR Compliance: When a user requests deletion, personal information fields are replaced with [REDACTED] (anonymized), rather than simply marking them as isDeleted—this meets the legal requirement that the data be “unidentifiable,” while retaining the data for statistical analysis.
Comparison of Soft Deletion Implementation Models:
| Pattern | Implementation | Query Impact | Recovery Difficulty |
|---|---|---|---|
| Boolean flag | isDeleted: Boolean | pre-find automatic filtering | simply set to false |
| Timestamp | deletedAt: Date | deletedAt: {$ne: null} | unset field |
| Content Replacement | content: '[Deleted]' | No filtering required | Original text cannot be restored |
// === Soft-Delete Field ===
const schema = new mongoose.Schema({
isDeleted: { type: Boolean, default: false },
deletedAt: Date,
deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
});
// === pre find Filter: Deleted ===
schema.pre(/^find/, function(next) {
this.find({ isDeleted: { $ne: true } });
next();
});
// === softDelete Instance Methods ===
schema.methods.softDelete = async function(deletedBy) {
this.isDeleted = true;
this.deletedAt = new Date();
this.deletedBy = deletedBy;
return await this.save();
};
// === restore Instance Methods ===
schema.methods.restore = async function() {
this.isDeleted = false;
this.deletedAt = undefined;
this.deletedBy = undefined;
return await this.save();
};
(4) Auto-populate middleware
Design Trade-offs of Auto-Populate: Auto-populate in pre find enhances the development experience—related data is automatically retrieved with every query, eliminating the need to manually call populate in each controller. However, this convenience comes at a cost: 1. Additional I/O is performed with every query (even when related data isn’t needed); 2. Nested populate calls lead to the N+1 query problem; 3. It’s difficult to selectively populate data based on specific scenarios. Recommended Approach: Use setOptions() to trigger populate conditionally, rather than relying on unconditional auto-populate.
Performance Control for Auto-Populate: Performance issues with auto-populate can be managed in the following ways: 1. Sparse populate: Populate only when needed (triggered by req.query.populate=true, rather than the default auto-populate); 2. Field whitelisting: Auto-populate fills only key fields (e.g., author: 'username avatar', rather than all user information); 3. lean + manual $lookup: Replace populate with lean() and aggregation pipelines for performance-sensitive list queries; 4. Cache populate results: For associated data that rarely changes (such as user avatars or roles), cache the populate results in Redis. Production best practice: “Use automatic populate only for development efficiency, not for production performance.”
// === Default populate Related Fields ===
UserSchema.pre('find', function(next) {
this.populate({
path: 'profileId',
select: 'avatar bio'
});
next();
});
// === Conditions populate ===
UserSchema.pre('find', function(next) {
if (this.options.includeOrders) {
this.populate('orders');
}
next();
});
// Usage:
const user = await User.findById(userId); // Automatic populate profileId
const userWithOrders = await User.findById(userId).setOptions({ includeOrders: true });
▶ Example 2: A Complete Middleware Implementation
Middleware Chain Composition Pattern: In real-world projects, a single schema typically registers multiple middleware components to form a complete “processing chain.” Composition principles: 1. Place data transformation middleware at the beginning (e.g., converting email addresses to lowercase, trimming strings) to ensure subsequent validations receive standardized data; 2. Place validation middleware in the middle (e.g., checking plaintext password length before hashing); 3. Side-effect-based middleware is placed at the end (audit logs, notification pushes), at which point the data has already been verified as valid. Error-handling middleware (4-parameter version) serves as a safety net to catch all exceptions.
// === Complete User Model Middleware ===
UserSchema.pre('save', async function(next) {
if (this.isModified('passwordHash') && !this.passwordHash.startsWith('$2b$')) {
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
}
next();
});
UserSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
UserSchema.post('save', function(doc, next) {
if (this.wasNew) {
logger.info(`New user: ${doc.email}`);
}
next();
});
UserSchema.post('findOneAndDelete', function(doc) {
if (doc) {
// Cascading Deletion of Related Data
Session.deleteMany({ userId: doc._id });
Cart.deleteMany({ userId: doc._id });
}
});
5. Custom Error Messages
Concept Explanation: Mongoose allows you to customize error messages for each validator and supports template variables (such as {VALUE}, {PATH}, {MIN}), making the error messages more user-friendly. There are two ways to customize messages: (1) Array syntax [validator, message]; (2) Object syntax { validator, message }. We recommend using the object syntax, as it is more flexible and can include template variables.
Multilingual Support for Error Messages: Error messages in production applications must support multiple languages—1. Message templating: Define error messages as template strings (e.g., 'validation.{PATH}.min') rather than hard-coding them in Chinese or English; 2. Runtime substitution: The Controller layer selects a language pack based on the Accept-Language header and substitutes template variables; 3. Field-level customization: Use functions instead of strings for the message of each schema field—message: (props) => i18n.t('validation.age.min', { value: props.value }); 4. Unified error formatting middleware: Consistently convert Mongoose ValidationError to i18n format within the error-handling middleware. This mechanism enables a single API to serve users worldwide.
How It Works: When validation fails, Mongoose replaces the placeholders in the message with template variables. {VALUE} is replaced with the actual value, {PATH} is replaced with the field path, and {MIN}/{MAX} are replaced with the constraint values. This information helps the front end accurately display the cause of the error.
| Template Variable | Meaning | Sample Output |
|---|---|---|
{VALUE} |
Actual input value | 'Age must be at least 18, got 15' |
{PATH} |
Field Path | 'email is required' |
{MIN} / {MAX} |
Constraint boundary value | 'Age must be >= 0' |
| Length constraint |
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
match: [/\S+@\S+\.\S+/, 'Invalid email format: {VALUE}'],
unique: true
},
age: {
type: Number,
min: [18, 'Age must be at least 18, got {VALUE}'],
max: [150, 'Age cannot exceed 150']
},
password: {
type: String,
minlength: [8, 'Password must be at least 8 characters'],
validate: {
validator: function(v) {
return /[A-Z]/.test(v) && /[0-9]/.test(v);
},
message: 'Password must contain uppercase and digit'
}
}
});
6. Error Handling in validate
Error Handling Architecture Principles: ValidationError contains error information for all fields (not just the first one), which allows the front end to display all validation issues at once. Iterate over the err.errors object to retrieve error details for each field—field (field path), message (error message), value (actual value), and kind (validator type). In production, ValidationError should be converted to a unified error response format rather than directly exposing Mongoose’s internal structure.
Error Classification and Recovery Strategies: The three types of errors are handled very differently—ValidationError (4xx) indicates a user input issue and should specify the exact field causing the error; CastError (4xx) typically indicates an ID format error and should display the message “Invalid resource identifier”; E11000 (4xx) indicates a unique constraint conflict and should specify the duplicate field and value. 5xx errors should not reveal technical details; instead, they should uniformly return “Service temporarily unavailable” and trigger an alert.
graph TD
A[ValidationError] --> B[errors Object]
B --> C["errors.email<br/>ValidatorError<br/>message: 'Invalid email'"]
B --> D["errors.age<br/>ValidatorError<br/>message: 'Age must be >= 18'"]
B --> E["errors._id<br/>CastError<br/>message: 'invalid ObjectId'"]
F[MongoServerError] --> G["code: 11000<br/>Unique Index Conflict"]
style A fill:#f8d7da
style F fill:#fff3cd
| Error Type | Trigger Conditions | Detection Method |
|---|---|---|
ValidationError |
Validator failure | err.name === 'ValidationError' |
CastError |
Type conversion failed | err instanceof mongoose.Error.CastError |
E11000 |
Unique Index Conflict | err.code === 11000 |
Unified Strategy for Handling Validation Errors: Production applications should handle Mongoose validation errors consistently, rather than repeating try-catch blocks in every controller—1. Error middleware: In the Express error-handling middleware, uniformly convert ValidationError to a 400 response and extract error information for each field to generate user-friendly messages; 2. CastError Handling: CastErrors (such as an invalid ObjectId) should be mapped to a 400 status code rather than a 500—a user submitting an ID with an incorrect format is a client-side error; 3. E11000 Handling: Unique index conflicts should be mapped to a 409 Conflict status code plus a user-friendly message (“This email address is already registered”), rather than exposing the raw MongoDB error; 4. Unknown errors: All other errors return a 500 status code with a generic message (without exposing internal details). The benefit of this unified handling is that the front end only needs a single set of error-handling logic, and the backend controller does not need to concern itself with error formatting.
Front-End Display of Validation Errors: Validation errors should be specific to the field level—1. Field-level errors: Each field in err.errors has its own message; the front end can display a red error message below the corresponding input field; 2. Error priority: required errors > type errors > custom validation errors (display “Required” first, then “Invalid format”); 3. Real-time validation: The frontend uses Joi for pre-validation (providing instant feedback as the user types), while Mongoose validation on the backend serves as the final line of defense (since the frontend validation can potentially be bypassed); 4. Localization of error messages: err.errors[field].message uses Chinese/English templates and returns the corresponding language based on the Accept-Language header. Precise error display allows users to quickly identify and correct issues, rather than being faced with a vague “Invalid input” message.
// === Handling Validation Errors ===
try {
await User.create({ email: 'invalid', age: 200 });
} catch (err) {
if (err.name === 'ValidationError') {
// Handling Field Errors
for (const field in err.errors) {
console.error(`${field}: ${err.errors[field].message}`);
}
}
}
// === mongoose Error Type ===
const mongoose = require('mongoose');
if (err instanceof mongoose.Error.ValidationError) {
// Validation Error
}
if (err instanceof mongoose.Error.CastError) {
// Type Conversion Error (e.g. ObjectId Format error)
}
if (err.code === 11000) {
// Unique Index Conflict
}
❓ FAQ
required or default?required is evaluated first, then default is applied. If no value is provided and a default value exists, the default value is used; if required: true and no value is provided, an error is thrown.Error.message thrown becomes the error message for that field. To customize the format, use the message option.validateBeforeSave: false safe?📖 Summary
- Built-in validators: required/min/max/enum/match/minlength/maxlength
- Custom validator: synchronous or asynchronous function
- validateBeforeSave: Set to true by default; can be disabled (not recommended)
- pre middleware: save/find/validate/remove
- post middleware: save/findOneAndDelete
- Soft delete: isDeleted + pre-find filter
- Error Handling: ValidationError, CastError, E11000
📝 Exercises
- Basic Exercise (⭐): Define the User Schema and apply all built-in validators (required, email, age min-max, role enum).
- Basic Question (⭐): Add a custom validator: Usernames cannot start with a number.
- Advanced Exercise (⭐⭐): Implement password hashing (isModified check) using the
pre-savemiddleware. - Advanced Exercise (⭐⭐): Implement soft deletion (using the
preFindfilter and thesoftDeleteinstance method). - Challenge (⭐⭐⭐): Complete user registration model (5+ validators + password hashing + timestamp + soft delete + error handling).



