The Mongoose data models are the heart of the application — they define what data exists, how it is validated, how it relates to other collections, and which indexes make queries fast. Getting the models right at the start avoids painful schema migrations later. This lesson defines the complete Mongoose schemas for the Task Manager: User, Workspace, Task, Notification, and AuditLog — with full validation, virtuals, indexes, pre-save hooks, and the static/instance methods that move business logic into the model layer.
Schema Design Decisions
| Decision | Choice | Reason |
|---|---|---|
| Task–Workspace relationship | Reference (ObjectId) — not embedded | Tasks queried independently of workspace; workspaces have many tasks |
| Workspace members | Embedded array in Workspace | Members always accessed with their workspace; typically <100 members |
| Task assignees | Array of ObjectIds in Task | Tasks accessed independently; small array (typically 1–5 assignees) |
| Tags | String array in Task | No cross-task tag management needed; simple array is sufficient |
| Attachments | Embedded array in Task | Always accessed with task; no standalone attachment queries |
| Soft delete | deletedAt timestamp + partial index |
Audit trail, accidental deletion recovery, no TTL yet |
| Audit log | Separate collection | High write volume; separate from operational data |
save() and create() but does NOT run on updateOne(), findByIdAndUpdate(), or update() unless you pass { runValidators: true }. Always include this option on update operations, or your schema validation will be silently bypassed on updates — a task title could become an empty string or a status could be set to an invalid value. Make it a project convention to always pass { runValidators: true, new: true } on all Mongoose update calls.toJSON transform on all schemas to control what gets serialised when Express calls res.json(document). Common transforms: remove __v (version key), remove password (never send to client), rename _id to id (some APIs prefer this), and format dates. Set this once in the schema options rather than using select: false on individual queries or manually deleting fields in every controller method.schema.index() or { index: true } are only created in MongoDB when autoIndex is true (the default in development). In production, set mongoose.set('autoIndex', false) and create indexes manually or via a migration script during deployment. Auto-index creation in production can cause significant performance issues if a large collection needs a new index — the creation blocks writes until complete (in the foreground mode) or runs as a background job that consumes significant I/O.Complete Schema Definitions
// ── apps/api/src/modules/users/user.model.js ──────────────────────────────
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true, maxlength: 100 },
email: { type: String, required: true, lowercase: true, trim: true,
match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format'] },
password: { type: String, required: true, minlength: 8, select: false },
avatarUrl: { type: String },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
isVerified: { type: Boolean, default: false },
isActive: { type: Boolean, default: true },
// Email verification
verificationToken: { type: String, select: false },
verificationTokenExpiry: { type: Date, select: false },
// Password reset
resetToken: { type: String, select: false },
resetTokenExpiry: { type: Date, select: false },
// Stats (denormalised for performance)
taskCount: { type: Number, default: 0, min: 0 },
completedTaskCount: { type: Number, default: 0, min: 0 },
lastLoginAt: Date,
}, {
timestamps: true,
toJSON: {
virtuals: true,
transform: (doc, ret) => {
ret.id = ret._id; delete ret._id; delete ret.__v;
delete ret.password; delete ret.verificationToken;
delete ret.verificationTokenExpiry;
delete ret.resetToken; delete ret.resetTokenExpiry;
return ret;
},
},
});
// Indexes
userSchema.index({ email: 1 }, { unique: true });
userSchema.index({ verificationToken: 1 }, { sparse: true });
userSchema.index({ resetToken: 1 }, { sparse: true });
// Hash password before save
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Instance method — compare password
userSchema.methods.comparePassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};
// Virtual — full profile URL
userSchema.virtual('profileUrl').get(function () {
return `${process.env.APP_URL}/users/${this._id}`;
});
module.exports = mongoose.model('User', userSchema);
// ── apps/api/src/modules/tasks/task.model.js ─────────────────────────────
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, minlength: 1, maxlength: 500 },
description: { type: String, trim: true, maxlength: 10000 },
status: { type: String,
enum: ['todo', 'in-progress', 'in-review', 'done', 'cancelled'],
default: 'todo' },
priority: { type: String,
enum: ['none', 'low', 'medium', 'high', 'urgent'],
default: 'medium' },
dueDate: { type: Date },
startDate: { type: Date },
completedAt: { type: Date },
tags: [{ type: String, trim: true, maxlength: 50 }],
assignees: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
workspace: { type: mongoose.Schema.Types.ObjectId, ref: 'Workspace', required: true },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
// Soft delete
deletedAt: { type: Date },
deletedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
// Attachments (embedded)
attachments: [{
filename: { type: String, required: true },
url: { type: String, required: true },
size: { type: Number, required: true },
mimeType: { type: String, required: true },
uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
uploadedAt: { type: Date, default: Date.now },
}],
}, {
timestamps: true,
toJSON: {
virtuals: true,
transform: (doc, ret) => {
ret.id = ret._id; delete ret._id; delete ret.__v; return ret;
},
},
});
// Virtual — is the task overdue?
taskSchema.virtual('isOverdue').get(function () {
return this.dueDate && this.dueDate < new Date() && this.status !== 'done';
});
// Pre-save — set completedAt when status changes to 'done'
taskSchema.pre('save', function (next) {
if (this.isModified('status')) {
if (this.status === 'done' && !this.completedAt) {
this.completedAt = new Date();
} else if (this.status !== 'done') {
this.completedAt = undefined;
}
}
// Ensure max 10 tags
if (this.tags.length > 10) {
return next(new Error('A task can have at most 10 tags'));
}
next();
});
// Indexes — ESR rule
taskSchema.index({ workspace: 1, createdAt: -1 });
taskSchema.index({ workspace: 1, status: 1, createdAt: -1 });
taskSchema.index({ workspace: 1, assignees: 1, status: 1 });
taskSchema.index({ createdBy: 1, createdAt: -1 });
// Text search
taskSchema.index(
{ title: 'text', description: 'text', tags: 'text' },
{ weights: { title: 10, tags: 5, description: 1 }, name: 'task_text' }
);
// Partial index — only non-deleted tasks (most queries)
taskSchema.index(
{ workspace: 1, status: 1 },
{ partialFilterExpression: { deletedAt: { $exists: false } },
name: 'active_tasks_by_status' }
);
// TTL — auto-delete soft-deleted tasks after 90 days
taskSchema.index({ deletedAt: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60, sparse: true });
module.exports = mongoose.model('Task', taskSchema);
How It Works
Step 1 — Pre-Save Hooks Enforce Business Rules
The pre('save') hook on the Task schema automatically sets completedAt when status changes to 'done', and clears it if the status changes back. This business rule is enforced at the model level — no controller or service can forget to set completedAt because it happens automatically. The hook also validates the tag count limit, producing a descriptive error message.
Step 2 — select: false Prevents Sensitive Data from Leaking
Setting select: false on the password field means it is excluded from all query results by default. User.findOne({ email }) returns the user without the password hash. To include it (for login verification), explicitly request it: User.findOne({ email }).select('+password'). This makes password leakage through a missed select() very unlikely — you have to explicitly opt in to receiving the password.
Step 3 — toJSON Transform Standardises API Response Shape
The toJSON.transform function runs every time a Mongoose document is serialised to JSON — including when res.json(document) is called. Setting ret.id = ret._id; delete ret._id; delete ret.__v in one place applies to every response. This is more maintainable than using .lean() followed by manual field manipulation in every controller, and prevents __v and _id from appearing in responses.
Step 4 — Compound Indexes Follow the ESR Rule
Every compound index on the Task schema is designed for a specific query pattern. { workspace: 1, status: 1, createdAt: -1 } serves “get all pending tasks in a workspace, newest first” — the equality fields (workspace, status) come first, the sort field (createdAt) last. Adding a partial filter expression excludes deleted tasks from the index, reducing its size by ~30% (the soft-deleted portion) and making all active task queries faster.
Step 5 — TTL Index Automatically Cleans Up Deleted Tasks
{ deletedAt: 1 }` with `expireAfterSeconds: 7776000 (90 days) instructs MongoDB’s TTL monitor thread (which runs every 60 seconds) to delete documents where deletedAt is older than 90 days. Soft-deleted tasks are recoverable for 90 days, then automatically purged. The sparse: true option means tasks without a deletedAt field (non-deleted tasks) are not included in the index and are never accidentally expired.
Quick Reference
| Pattern | Code |
|---|---|
| Hide field by default | { type: String, select: false } |
| Include hidden field | User.find().select('+password') |
| toJSON transform | toJSON: { transform: (doc, ret) => { delete ret.__v; return ret; } } |
| Pre-save hook | schema.pre('save', function(next) { ... next(); }) |
| Virtual field | schema.virtual('name').get(function() { return ...; }) |
| Instance method | schema.methods.comparePassword = function(pw) { ... } |
| TTL index | schema.index({ deletedAt: 1 }, { expireAfterSeconds: 7776000, sparse: true }) |
| Partial index | schema.index({ field: 1 }, { partialFilterExpression: { ... } }) |
| Run validators on update | Model.findByIdAndUpdate(id, update, { runValidators: true, new: true }) |