Automating Validation and Auditing with Mongoose Pre and Post Hooks
Centralize data normalization, validation, and audit logging inside your Mongoose schemas using pre and post middleware instead of duplicating logic across application code.
22 Jul 2025, 20:14 UTC

The Problem: Scattered Validation and Audit Logic
When every route handler repeats the same email normalization, password hashing, or audit-logging steps, those rules drift apart. One endpoint forgets to trim whitespace; another skips the audit entry. Mongoose middleware lets you attach this logic directly to schema operations, so the database layer enforces the rules regardless of which application code triggers the write.
The useful takeaway: move cross-cutting validation and auditing into pre and post hooks, and use query middleware to apply global read filters such as soft-delete exclusion.
Desired Outcome
You want a schema that automatically normalizes and validates data before it reaches MongoDB, records an audit trail after successful writes, and hides soft-deleted documents from ordinary queries—without requiring every service or script to remember those steps.
Prerequisites
- A Node.js project with Mongoose installed and a working MongoDB connection.
- An existing schema or the ability to create one.
- Access to a MongoDB shell or Compass to inspect stored documents.
- Basic familiarity with Mongoose models and the
.save()method.
Procedure: Document Middleware for Writes
Document middleware runs around instance methods such as save and validate. Inside a document hook, this refers to the document being processed. Use a regular function expression, not an arrow function, so that this binding remains intact.
Pre-Save Normalization and Validation
A pre('save') hook executes before the document is written. This is the right place to normalize fields or perform synchronous validation that Mongoose's built-in validators cannot express.
userSchema.pre('save', function(next) {
if (this.isModified('email')) {
this.email = this.email.toLowerCase().trim();
}
if (this.isModified('password')) {
// Replace with a real hash call in production
// this.password = bcrypt.hashSync(this.password, 10);
}
next();
});
Call next() to continue, or pass an error to abort the save. If you hash passwords here, every code path that calls .save() gets the same protection.
Post-Save Audit Logging
A post('save') hook runs after the database acknowledges the write. Use it for side effects that should happen only on success: writing to an audit collection, emitting an event, or calling an external service.
userSchema.post('save', function(doc) {
// Example: write to a separate audit log collection
auditLogModel.create({
action: 'user_saved',
documentId: doc._id,
timestamp: new Date()
}).catch(err => console.error('Audit write failed:', err));
});
Because the hook receives the saved document as doc, you can capture identifiers without re-querying. Keep the work asynchronous and handle errors locally so a failed audit write does not crash the original save.
Procedure: Query Middleware for Global Read Filters
Query middleware attaches to model queries such as find, findOne, and findOneAndUpdate. It is the supported way to inject filters into every matching query. A common pattern is soft deletion: mark records as deleted instead of removing them, then exclude them from reads.
userSchema.pre('find', function() {
this.where({ deleted: false });
});
Here this is the query object, not a document. The filter applies automatically whenever Model.find() runs. If you need to include deleted records in a specific query, you must explicitly override the condition or use a different method.
Expected Checks
Verify each layer independently before relying on it in production.
| Layer | Check | Expected result |
|---|---|---|
| Pre-save normalization | Save a document with mixed-case email, then inspect it in MongoDB shell: db.users.find({ _id: ObjectId("your-doc-id") }) | The stored email is lowercase and trimmed. |
| Post-save audit | Save a document and watch application logs or query the audit collection. | The audit entry appears only after the user document exists. |
| Query middleware | Set deleted: true on one document, then run Model.find(). | The soft-deleted document is absent from the result set. |
Limitations and Recovery Options
Middleware is powerful but has sharp edges. The following issues are common sources of bugs:
- Arrow functions break document hooks. If you write
schema.pre('save', (next) => { ... }),thisis undefined or lexically bound, so you cannot read or modify the document. Recovery: rewrite the hook asfunction(next) { ... }. - Infinite save loops. Calling
this.save()inside apre('save')hook without a guard triggers the hook again. Recovery: use a boolean flag on the document, or move the logic to a separate method. - Bulk updates bypass document middleware. Methods like
updateMany()do not triggerpre('save')hooks. If you need normalization or auditing on bulk operations, define query middleware for those methods or handle the logic in application code. - Global filters surprise callers. A
pre('find')filter changes behavior for every consumer of the model. Recovery: document the behavior clearly, or provide an alternative static method that bypasses the filter when necessary.
Practical Verification
Run a short Node.js script that creates a user with an uppercase email and deleted: false, saves it, and then queries with User.find(). Confirm three things: the stored email is normalized, the audit collection contains one entry, and the query returns the user. Next, update that user to deleted: true using updateOne (not save), and run User.find() again. If the user disappears, query middleware is active. If not, verify that your Mongoose version supports the hook and that no other middleware is overriding the filter.
Because middleware APIs and default behaviors can vary between Mongoose major versions, treat the exact hook names and this semantics as version-dependent. Consult the middleware documentation for your installed release before deploying complex chains.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.