Introduction:
In MongoDB, especially when working with Mongoose in Node.js, there are two main ways to fetch related data:
- Using MongoDB's native
$lookup
in aggregation pipelines - Using Mongoose’s built-in
ref
andpopulate()
functionality
While $lookup
gives fine-grained control and works directly at the database level, Mongoose's ref-based population simplifies your code, makes your data models more maintainable, and leads to better developer experience for most use cases.
Let’s explore this using a real-world project management example involving Sprints and Tasks.
Schema Design: Tasks and Sprints
Here's how we model the relationship between Tasks and Sprints in Mongoose using ref
and populate
.
const taskSchema = new mongoose.Schema({ sprintId: { type: mongoose.Types.ObjectId, ref: 'sprints' }, projectId: { type: mongoose.Types.ObjectId, ref: 'projects', required: true }, title: { type: String, required: true, minlength: 3 }, description: { type: String, default: '' }, type: { type: String, enum: ['Story', 'Bug', 'Task', 'Epic', 'Sub-task'], default: 'Task' }, status: { type: String, enum: ['To Do', 'In Progress', 'In Review', 'Done', 'Blocked'], default: 'To Do' }, priority: { type: String, enum: ['Low', 'Medium', 'High', 'Critical'], default: 'Medium' }, assignee: { _id: mongoose.Types.ObjectId, email: String, displayName: String }, reporter: { _id: mongoose.Types.ObjectId, email: String, displayName: String }, estimate: { type: Number, default: 0 }, createdBy: { _id: mongoose.Types.ObjectId, email: String, displayName: String }, createdAt: { type: Date, default: Date.now }, dueDate: Date, lastModified: Date });
Task Creation Logic
When creating a task, we also push its ID into the corresponding Sprint:
const taskModel = { createTask: async (sprintId, projectId, title, description, type, status, priority, assignee, reporter, estimate, createdBy, createdAt, dueDate, lastModified) => { const task = new Task({ sprintId: sprintId ? new mongoose.Types.ObjectId(sprintId) : undefined, projectId: new mongoose.Types.ObjectId(projectId), title, description, type, status, priority, assignee, reporter, estimate, createdBy, createdAt, dueDate, lastModified }); const savedTask = await task.save(); if (sprintId) { const Sprint = mongoose.model('sprints'); await Sprint.findByIdAndUpdate( sprintId, { $push: { tasks: task._id } }, { new: true } ); } return savedTask; } };
Sprint Schema with tasks
as Array of ref
const sprintSchema = new mongoose.Schema({ projectId: { type: mongoose.Types.ObjectId, ref: 'projects', required: true }, sprintNum: { type: String, required: true }, name: { type: String, required: true }, description: { type: String, default: '' }, duration: { type: String, enum: ['1 Week', '2 Weeks', '3 Weeks'] }, startDate: { type: Date, required: true }, endDate: { type: Date, required: true }, tasks: [{ type: mongoose.Types.ObjectId, ref: 'tasks' }], createdBy: { _id: mongoose.Types.ObjectId, email: String, displayName: String } }, { timestamps: true });
Populating Tasks from Sprints
Instead of writing a complex $lookup
query, we simply do:
const getAllSprints = async () => { return await Sprint.find({}).populate('tasks').exec(); };
This automatically pulls in all related task documents for each sprint!
Some plus factors to use ref over lookup
- The code is very readable compared to the lookup.
- The integration of mongoose is seamless. Only thing you just have too push the ids into the array and ref and populate will do the job automatically.
- Note: This will be easier for small one-level joins. If you have multi-level joins or complex joins then aggregate is the best to do so.
Conclusion
If you're using Mongoose, stop reaching for $lookup unless you have a really complex use case. For most apps, especially CRUD-based systems like task managers, ref and populate() are more than enough.
Not only is the syntax cleaner, but it also keeps your code more expressive and schema-driven. Let MongoDB handle relationships behind the scenes—just like a good ORM should.
Top comments (1)
Nice, stuff like this makes me wanna clean up half my own code tbh