Plugins, Discriminators, and Mongoose Best Practices

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
Note: Mongoose plugins extend the 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.
Tip: Discriminators are ideal for notification systems, event logs, activity feeds, and content management systems where you have multiple related types that need to be queryable together. A notifications collection with 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.
Warning: Avoid the common anti-pattern of defining Mongoose models inside request handlers, middleware functions, or service methods that are called repeatedly. Calling 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 })

🧠 Test Yourself

You have Notification as the base discriminator model with TaskNotification and SystemNotification as discriminators. Which query returns ALL notification types for a user?