DEV Community

Aniruddha Bhattacharya
Aniruddha Bhattacharya

Posted on

1 1 2 1

Optimize Your Node.js MongoDB Code: Replace $lookup with Mongoose ref - [Anni]

Introduction:

In MongoDB, especially when working with Mongoose in Node.js, there are two main ways to fetch related data:

  1. Using MongoDB's native $lookup in aggregation pipelines
  2. Using Mongoose’s built-in ref and populate() 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
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

Populating Tasks from Sprints

Instead of writing a complex $lookup query, we simply do:

const getAllSprints = async () => {
  return await Sprint.find({}).populate('tasks').exec();
};
Enter fullscreen mode Exit fullscreen mode

This automatically pulls in all related task documents for each sprint!

Some plus factors to use ref over lookup

  1. The code is very readable compared to the lookup.
  2. 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.
  3. 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)

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

Nice, stuff like this makes me wanna clean up half my own code tbh