Mongoose plugins and discriminators are two advanced features that are underused in most applications but provide enormous value at scale. Plugins package reusable schema functionality โ soft delete, audit timestamps, slug generation, pagination โ into self-contained modules that can be applied to any schema with a single line. Discriminators let multiple document types with different shapes share a single MongoDB collection while using separate Mongoose models โ eliminating collection proliferation and enabling queries that span all types simultaneously. Combined with the best practices covered in this lesson, they form the toolkit for production-grade Mongoose architecture.
Mongoose Plugins
| Category | Popular Plugin / Pattern | Provides |
|---|---|---|
| Soft Delete | mongoose-delete |
softDelete(), restore(), deleted() query method |
| Pagination | mongoose-paginate-v2 |
Model.paginate() with page, limit, meta |
| Audit Fields | Custom plugin | createdBy, updatedBy from request context |
| Slug | mongoose-slug-updater |
Auto-generate URL-safe slug from title field |
| Search | mongoose-fuzzy-searching |
Add fuzzy text search to any model |
| Timestamps | Built-in { timestamps: true } |
createdAt, updatedAt managed automatically |
Discriminator Key Options
| Option | Description |
|---|---|
| Default key | __t โ Mongoose uses this field to store the discriminator type name |
| Custom key | Set in base schema options: discriminatorKey: 'type' |
| Query scoping | Queries on a discriminator model automatically filter by the discriminator key |
| Base model queries | Queries on the base model return ALL types โ no discriminator filter |
Mongoose Best Practices Summary
| Practice | Do | Avoid |
|---|---|---|
| Query performance | Always use .lean() for read-only queries |
Returning full Mongoose documents from list endpoints |
| Validation | Always pass { runValidators: true } to update methods |
Calling update methods without validation |
| Sensitive fields | Use select: false on password, tokens |
Relying on per-query exclusion of sensitive fields |
| Connections | Open one connection pool per application | Creating new connections per request |
| Error handling | Detect err.code === 11000 for duplicate key |
Generic 500 errors for MongoDB duplicate key errors |
| Parallel queries | Use Promise.all() for independent queries |
Sequential awaits for unrelated queries |
| Large results | Always paginate โ never return unlimited results | Returning all documents from a collection |
schema.plugin(pluginFn, options) API. A plugin function receives the schema and options, then calls schema.add(), schema.methods, schema.statics, schema.pre/post(), and schema.index() just like you would when defining the schema directly. The key insight is that everything you can do when defining a schema, a plugin can also do โ it is just a reusable function applied to schemas. Global plugins applied with mongoose.plugin(fn) run on every schema automatically.TaskCreated, TaskAssigned, UserMentioned, and CommentAdded discriminators lets you query “all unread notifications for this user” with a single base model query, while individual discriminator models give you type-specific fields and methods.mongoose.model('User', userSchema) more than once throws OverwriteModelError. Define all models at the module level (top of the file), export the Model, and require() it where needed. Node.js module caching ensures the model is created exactly once regardless of how many files import it.Building Reusable Plugins
// โโ Plugin 1: Soft Delete โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// plugins/softDelete.plugin.js
function softDeletePlugin(schema, options = {}) {
const { field = 'deletedAt', indexField = true } = options;
// Add the deletedAt field
schema.add({
[field]: { type: Date, default: null, select: false },
});
// Add index if requested
if (indexField) {
schema.index({ [field]: 1 });
}
// Instance method: soft delete this document
schema.methods.softDelete = async function() {
this[field] = new Date();
return this.save();
};
// Instance method: restore this document
schema.methods.restore = async function() {
this[field] = null;
return this.save();
};
// Static method: get all including deleted
schema.statics.findWithDeleted = function(filter = {}) {
return this.find(filter).setOptions({ bypassSoftDelete: true });
};
// Auto-filter soft-deleted documents from all queries
schema.pre(/^find/, function() {
if (this.getOptions().bypassSoftDelete) return;
if (!this.getFilter()[field]) {
this.find({ [field]: null });
}
});
// Also filter from aggregations
schema.pre('aggregate', function() {
if (this.options.bypassSoftDelete) return;
this.pipeline().unshift({ $match: { [field]: null } });
});
}
module.exports = softDeletePlugin;
// โโ Plugin 2: Pagination โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// plugins/paginate.plugin.js
function paginatePlugin(schema) {
schema.statics.paginate = async function(filter = {}, options = {}) {
const {
page = 1,
limit = 10,
sort = '-createdAt',
select = '',
populate = null,
lean = true,
} = options;
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, parseInt(limit));
const skip = (pageNum - 1) * limitNum;
let query = this.find(filter)
.sort(sort)
.skip(skip)
.limit(limitNum);
if (select) query = query.select(select);
if (populate) query = query.populate(populate);
if (lean) query = query.lean();
const [docs, total] = await Promise.all([query, this.countDocuments(filter)]);
return {
docs,
meta: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
hasNext: pageNum < Math.ceil(total / limitNum),
hasPrev: pageNum > 1,
},
};
};
}
module.exports = paginatePlugin;
// โโ Plugin 3: Audit Trail โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// plugins/audit.plugin.js
function auditPlugin(schema) {
schema.add({
createdBy: { type: mongoose.Types.ObjectId, ref: 'User', select: false },
updatedBy: { type: mongoose.Types.ObjectId, ref: 'User', select: false },
});
// Use AsyncLocalStorage to pass current user context
const { AsyncLocalStorage } = require('async_hooks');
const requestContext = new AsyncLocalStorage();
schema.statics.setRequestContext = function(store) {
return requestContext.run(store, async () => {});
};
schema.pre('save', function() {
const ctx = requestContext.getStore();
if (!ctx?.userId) return;
if (this.isNew) this.createdBy = ctx.userId;
this.updatedBy = ctx.userId;
});
}
// โโ Applying plugins โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const softDeletePlugin = require('./plugins/softDelete.plugin');
const paginatePlugin = require('./plugins/paginate.plugin');
taskSchema.plugin(softDeletePlugin, { field: 'deletedAt' });
taskSchema.plugin(paginatePlugin);
// Usage:
const result = await Task.paginate({ user: userId, status: 'pending' }, {
page: 2, limit: 10, sort: '-priority', select: 'title status priority',
});
Discriminators โ Multiple Types in One Collection
// โโ Discriminators for a notification system โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Base notification schema
const notificationSchema = new mongoose.Schema({
userId: { type: mongoose.Types.ObjectId, ref: 'User', required: true, index: true },
isRead: { type: Boolean, default: false, index: true },
createdAt: { type: Date, default: Date.now, index: true },
}, {
discriminatorKey: 'type', // stored as 'type' field in MongoDB
timestamps: false,
});
notificationSchema.index({ userId: 1, isRead: 1, createdAt: -1 });
// Base model
const Notification = mongoose.model('Notification', notificationSchema);
// Discriminator 1: Task assigned notification
const TaskAssignedNotification = Notification.discriminator(
'TaskAssigned',
new mongoose.Schema({
taskId: { type: mongoose.Types.ObjectId, ref: 'Task', required: true },
taskTitle: String,
assignedBy:{ type: mongoose.Types.ObjectId, ref: 'User' },
})
);
// Discriminator 2: Comment mention notification
const MentionNotification = Notification.discriminator(
'Mention',
new mongoose.Schema({
commentId: { type: mongoose.Types.ObjectId, required: true },
taskId: { type: mongoose.Types.ObjectId, ref: 'Task' },
mentionedBy:{ type: mongoose.Types.ObjectId, ref: 'User' },
excerpt: String,
})
);
// Discriminator 3: System notification
const SystemNotification = Notification.discriminator(
'System',
new mongoose.Schema({
message: { type: String, required: true },
severity: { type: String, enum: ['info', 'warning', 'error'], default: 'info' },
link: String,
})
);
// โโ Creating discriminated documents โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Each automatically sets the type field
await TaskAssignedNotification.create({
userId: assigneeId,
taskId: task._id,
taskTitle: task.title,
assignedBy: currentUserId,
});
// Stored in MongoDB: { type: 'TaskAssigned', userId, taskId, taskTitle, assignedBy, isRead: false }
await SystemNotification.create({
userId: adminId,
message: 'Server maintenance scheduled for tonight at 11 PM UTC',
severity: 'warning',
});
// โโ Querying โ base model returns ALL types โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Get all unread notifications for a user (all types mixed)
const allUnread = await Notification.find({ userId, isRead: false })
.sort('-createdAt')
.limit(20)
.lean();
// Returns mix of TaskAssigned, Mention, System notifications
// Get only task assignment notifications
const assignments = await TaskAssignedNotification.find({ userId }).lean();
// Automatically filters: { type: 'TaskAssigned', userId }
// Mark all as read (all types at once using base model)
await Notification.updateMany({ userId, isRead: false }, { $set: { isRead: true } });
How It Works
Step 1 โ Plugins Are Functions Applied to Schemas
A plugin is simply a function that accepts a schema and options. When applied with schema.plugin(pluginFn, opts), it can add fields, methods, statics, pre/post hooks, indexes, and query helpers โ exactly as you would when defining the schema yourself. Plugins promote reusability: instead of copying the same deletedAt field and soft-delete query filter to 10 different schemas, define it once as a plugin and apply it everywhere with one line.
Step 2 โ mongoose.plugin() Applies Globally to All Schemas
Calling mongoose.plugin(fn) before any model is defined applies the plugin to every schema created after that point. Global plugins are ideal for cross-cutting concerns that every model needs: audit timestamps, soft delete, change tracking. Register global plugins in your application’s entry point or a dedicated setup module before importing any model files.
Step 3 โ Discriminators Share a Collection with a Type Key
All discriminator models store their documents in the same MongoDB collection as the base model. Mongoose adds a discriminator key field (default: __t, or whatever you configure with discriminatorKey) to every document, storing the discriminator model name. When you query a specific discriminator model, Mongoose automatically adds a filter for that type. When you query the base model, all types are returned regardless of their discriminator key.
Step 4 โ Each Discriminator Adds Its Own Fields
The discriminator schema only defines the fields unique to that type. The base schema’s fields are shared by all types. MongoDB stores all fields โ base and type-specific โ in a flat document. When Mongoose retrieves a document, it uses the discriminator key to determine which model class to instantiate, giving the result access to the type-specific schema’s validators, virtuals, and methods.
Step 5 โ Best Practices Compound Over Time
The best practices in this lesson โ .lean() for reads, runValidators: true for updates, select: false for sensitive fields, Promise.all() for parallel queries, pagination everywhere โ each have modest individual impact. Applied consistently across every model, every service, and every endpoint in a codebase, they compound into a measurably faster, safer, and more maintainable application. Establish them as team conventions from day one rather than retrofitting them later.
Real-World Example: Task Model with Plugins
// models/task.model.js โ complete production model with plugins
const mongoose = require('mongoose');
const softDeletePlugin = require('../plugins/softDelete.plugin');
const paginatePlugin = require('../plugins/paginate.plugin');
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
description: { type: String, trim: true, maxlength: 2000 },
status: { type: String, enum: ['pending', 'in-progress', 'completed'], default: 'pending' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: Date,
completedAt: { type: Date, select: false },
tags: [{ type: String, trim: true }],
user: { type: mongoose.Types.ObjectId, ref: 'User', required: true, immutable: true },
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
});
// Apply plugins
taskSchema.plugin(softDeletePlugin); // adds deletedAt, softDelete(), restore(), findWithDeleted()
taskSchema.plugin(paginatePlugin); // adds Task.paginate()
// Indexes
taskSchema.index({ user: 1, status: 1, createdAt: -1 });
taskSchema.index({ user: 1, dueDate: 1 });
taskSchema.index({ title: 'text', description: 'text' });
// Virtuals
taskSchema.virtual('isOverdue').get(function() {
return !!(this.dueDate && this.dueDate < new Date() && this.status !== 'completed');
});
// Statics
taskSchema.statics.findForUser = function(userId, options) {
return this.paginate({ user: userId }, options);
};
// Instance methods
taskSchema.methods.complete = async function() {
this.status = 'completed'; this.completedAt = new Date(); return this.save();
};
const Task = mongoose.model('Task', taskSchema);
module.exports = Task;
// โโ Usage in service โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Paginated list
const result = await Task.paginate({ user: userId, status: 'pending' }, {
page: 1, limit: 10, sort: '-priority,dueDate',
});
// Soft delete (plugin method)
const task = await Task.findById(id);
await task.softDelete(); // sets deletedAt โ filtered from all subsequent queries
// Restore
await task.restore(); // clears deletedAt โ visible again in queries
// Query including deleted
const allTasks = await Task.findWithDeleted({ user: userId });
Common Mistakes
Mistake 1 โ Defining models inside functions (repeated model creation)
โ Wrong โ OverwriteModelError on second call:
function getTaskModel() {
return mongoose.model('Task', taskSchema); // throws OverwriteModelError on 2nd call
}
app.get('/tasks', async (req, res) => {
const Task = getTaskModel(); // breaks on every request after the first!
});
✅ Correct โ define model at module level, cache via require():
// task.model.js โ defined once, exported
const Task = mongoose.model('Task', taskSchema);
module.exports = Task;
// controller โ imported, not redefined
const Task = require('../models/task.model');
Mistake 2 โ Not using Promise.all() for independent queries
โ Wrong โ sequential queries when parallel would be faster:
const tasks = await Task.find({ user: userId }).lean(); // 30ms
const user = await User.findById(userId).lean(); // 20ms
// Total: 50ms
✅ Correct โ run independent queries in parallel:
const [tasks, user] = await Promise.all([
Task.find({ user: userId }).lean(), // both start simultaneously
User.findById(userId).lean(),
]);
// Total: max(30ms, 20ms) = 30ms
Mistake 3 โ Returning unlimited results from a list endpoint
โ Wrong โ client receives all 50,000 tasks at once:
const tasks = await Task.find({ user: userId }).lean();
res.json({ data: tasks }); // 50,000 documents, possible OOM or timeout
✅ Correct โ always paginate list endpoints:
const result = await Task.paginate({ user: userId }, { page: 1, limit: 10 });
res.json({ data: result.docs, meta: result.meta });
Quick Reference
| Task | Code |
|---|---|
| Apply plugin to schema | schema.plugin(pluginFn, options) |
| Apply plugin globally | mongoose.plugin(pluginFn) |
| Create base model | const Base = mongoose.model('Base', baseSchema) |
| Create discriminator | const Sub = Base.discriminator('Sub', new Schema({...})) |
| Query all types | Base.find(filter) |
| Query specific type | Sub.find(filter) โ auto-filters by type |
| Parallel queries | const [a, b] = await Promise.all([queryA, queryB]) |
| Read-only query | Model.find().lean() |
| Update with validation | findByIdAndUpdate(id, data, { runValidators: true, new: true }) |