Chapter 3.4☕ 18 min read

Database Relationships — ref aur populate()

In real apps, data is connected: users have orders, orders have items. Mongoose ref and populate() make relationships easy.

01Database Relationships — Users, Orders aur Biryani Ka Connection

In real applications, data doesn't exist in isolation. A Swiggy backend has multiple entities that relate to each other:

  • Users place Orders
  • Orders contain Biryani items
  • Restaurants serve Menu items
  • Orders have a Delivery address

MongoDB (being NoSQL) gives you two ways to handle relationships:

1. Embedding (Nested Documents)

Store related data directly inside the parent document. Like putting the biryani items INSIDE the order document.

// Embedded — order contains items directly
{
  _id: ObjectId('...'),
  user: { name: 'Raju', phone: '9876543210' },
  items: [
    { name: 'Chicken Biryani', qty: 2, price: 250 },
    { name: 'Gulab Jamun', qty: 1, price: 60 }
  ],
  total: 560,
  status: 'delivered'
}

2. Referencing (ObjectId References)

Store the ID of the related document. Like writing the order items as a reference to the biryani menu.

// Referenced — order stores ObjectIds, not the full data
{
  _id: ObjectId('...'),
  user: ObjectId('abc123'),
  items: [
    { biryani: ObjectId('xyz789'), qty: 2 },
    { biryani: ObjectId('xyz790'), qty: 1 }
  ],
  total: 560,
  status: 'delivered'
}

Which one to use?

  • Embed when data is always read together and doesn't change often (like order items)
  • Reference when data is shared across many documents and changes frequently (like user profiles, menu items)

Good rule of thumb: If the related data is small, read frequently with the parent, and doesn't change independently — embed it. Otherwise, reference it.

02Referencing vs Embedding — ObjectId vs Nested Documents

Let's see both approaches with a concrete Swiggy example.

Approach 1: Embedding (Nested Documents)

// models/Order.js — Embedded items
import mongoose from 'mongoose';

const orderSchema = new mongoose.Schema({
  user: {
    name: { type: String, required: true },
    phone: { type: String, required: true },
    address: { type: String, required: true }
  },
  items: [{
    name: { type: String, required: true },
    price: { type: Number, required: true },
    qty: { type: Number, required: true, min: 1 }
  }],
  restaurant: { type: String, required: true },
  total: { type: Number, required: true },
  status: {
    type: String,
    enum: ['pending', 'confirmed', 'cooking', 'outForDelivery', 'delivered'],
    default: 'pending'
  }
}, { timestamps: true });

const Order = mongoose.model('Order', orderSchema);
export default Order;

Pros: All data in one query. Fast reads. No JOINs needed. Cons: Duplicate data if same user orders again. Updating user info requires updating every order.

Approach 2: Referencing (ObjectId References)

// models/Order.js — Referenced user and menu items
import mongoose from 'mongoose';

const orderSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',    // Reference to User model
    required: true
  },
  items: [{
    biryani: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Biryani', // Reference to Biryani model
      required: true
    },
    qty: { type: Number, required: true, min: 1 }
  }],
  total: { type: Number, required: true },
  status: {
    type: String,
    enum: ['pending', 'confirmed', 'cooking', 'outForDelivery', 'delivered'],
    default: 'pending'
  }
}, { timestamps: true });

const Order = mongoose.model('Order', orderSchema);
export default Order;

Pros: Data stored once. Updating user info updates everywhere. No duplicates. Cons: Need populate() to get full data (extra query).

For our course, we'll use referencing because it's the standard approach for production apps with shared data.

03Mongoose ref — Models Ko Connect Karna

The ref property in a Mongoose schema tells Mongoose which model an ObjectId references. Think of it like a foreign key in SQL — but instead of a formal constraint, it's a hint for Mongoose to use when populating.

1. User Model

// models/User.js — User who places orders
import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  phone: { type: String, required: true },
  address: { type: String, required: true },
  favoriteBiryani: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Biryani'  // Reference to Biryani model
  }
}, { timestamps: true });

const User = mongoose.model('User', userSchema);
export default User;

2. Order Model (with refs)

// models/Order.js — Order references User and Biryani
import mongoose from 'mongoose';

const orderItemSchema = new mongoose.Schema({
  biryani: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Biryani',  // Reference to Biryani model
    required: true
  },
  qty: { type: Number, required: true, min: 1 }
});

const orderSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',  // Reference to User model
    required: true
  },
  items: [orderItemSchema],
  total: { type: Number, required: true },
  status: {
    type: String,
    enum: ['pending', 'confirmed', 'cooking', 'outForDelivery', 'delivered'],
    default: 'pending'
  },
  deliveryAddress: { type: String, required: true }
}, { timestamps: true });

const Order = mongoose.model('Order', orderSchema);
export default Order;

The ref syntax explained:

fieldName: {
  type: mongoose.Schema.Types.ObjectId,  // Stores an ObjectId
  ref: 'OtherModel'                       // References this model's collection
}

Important: ref does NOT create a foreign key constraint in MongoDB. It's purely a Mongoose feature — a hint for the populate() method. The database itself doesn't enforce the relationship.

04Mongoose populate() — References Ko Actual Data Mein Badalna

populate() is the magic method that replaces ObjectIds in your documents with the actual data from the referenced collection. It's like telling Mongoose: "I know you stored an ID here — now go fetch the actual object for me."

Without populate() — You get ObjectIds:

const order = await Order.findById(orderId);
// Result:
// {
//   user: ObjectId('abc123'),    // Just an ID 😕
//   items: [{ biryani: ObjectId('xyz789'), qty: 2 }],
//   total: 560,
//   status: 'pending'
// }

With populate() — You get full data:

const order = await Order.findById(orderId)
  .populate('user')     // Replace user ObjectId with full User document
  .populate('items.biryani');  // Replace biryani ObjectId with full Biryani doc

// Result:
// {
//   user: {
//     _id: ObjectId('abc123'),
//     name: 'Raju',
//     email: 'raju@email.com',
//     phone: '9876543210',
//     address: 'Banjara Hills, Hyderabad'
//   },
//   items: [{
//     biryani: {
//       _id: ObjectId('xyz789'),
//       name: 'Chicken Biryani',
//       price: 250,
//       category: 'Chicken'
//     },
//     qty: 2
//   }],
//   total: 560,
//   status: 'pending'
// }

Populate options:

// Select specific fields only
.populate('user', 'name email phone')

// Populate nested references
.populate({
  path: 'user',
  select: 'name email',
  populate: {
    path: 'favoriteBiryani',
    select: 'name price'
  }
})

// Populate with conditions
.populate({
  path: 'items.biryani',
  match: { isAvailable: true },
  select: 'name price'
})

Performance note: populate() works by making additional queries to the referenced collections. It's not a JOIN (MongoDB doesn't do JOINs). It's multiple queries stitched together. For simple cases this is fine, but for complex reporting, use the aggregation pipeline (next chapter).

05Populated Routes — Real API Endpoints with Relationships

Now let's see how ref and populate() work together in real API endpoints.

// routes/order.routes.js — Orders with populated relationships
import express from 'express';
import Order from '../models/Order.js';
import User from '../models/User.js';

const router = express.Router();

// POST /api/orders — Place an order (creates references)
router.post('/', async (req, res) => {
  try {
    const { userId, items, deliveryAddress } = req.body;

    // Verify user exists
    const user = await User.findById(userId);
    if (!user) {
      return res.status(404).json({
        success: false,
        message: 'User nahi mila! Pehle account banao.'
      });
    }

    // Calculate total from referenced biryani items
    // In real app, you'd query Biryani prices from DB
    const total = items.reduce((sum, item) => sum + (item.price * item.qty), 0);

    const order = await Order.create({
      user: userId,       // Store ObjectId reference
      items: items.map(i => ({
        biryani: i.biryaniId,  // Store ObjectId reference
        qty: i.qty
      })),
      total,
      deliveryAddress,
      status: 'confirmed'
    });

    // Populate the created order for the response
    const populatedOrder = await Order.findById(order._id)
      .populate('user', 'name phone')
      .populate('items.biryani', 'name price category');

    res.status(201).json({
      success: true,
      message: 'Order placed! 🎉 Delivery in 30-40 minutes.',
      data: populatedOrder
    });
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

// GET /api/orders/:id — Get order with populated data
router.get('/:id', async (req, res) => {
  try {
    const order = await Order.findById(req.params.id)
      .populate({
        path: 'user',
        select: 'name email phone address'
      })
      .populate({
        path: 'items.biryani',
        select: 'name price category spicyLevel',
        match: { isAvailable: true }  // Only populate available items
      });

    if (!order) {
      return res.status(404).json({
        success: false,
        message: 'Order nahi mila! ID check karo.'
      });
    }

    res.json({ success: true, data: order });
  } catch (error) {
    if (error.name === 'CastError') {
      return res.status(400).json({ success: false, message: 'Invalid ID format!' });
    }
    res.status(500).json({ success: false, message: error.message });
  }
});

// GET /api/users/:userId/orders — All orders for a user
router.get('/user/:userId', async (req, res) => {
  try {
    const orders = await Order.find({ user: req.params.userId })
      .populate('items.biryani', 'name price')
      .sort('-createdAt');

    res.json({
      success: true,
      count: orders.length,
      data: orders
    });
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

export default router;

Key patterns to remember:

  • Store the ObjectId when creating (just userId, not the whole user doc)
  • Populate when reading — replace IDs with actual data for the response
  • Always populate after creation — return meaningful data to the client
  • Use select to limit fields — don't send the entire user object if you only need name

This pattern gives you the best of both worlds: efficient writes (just store IDs) and rich reads (get full data when you need it).

Key Takeaways

  • ✅ Two relationship approaches: Embedding (nested docs) vs Referencing (ObjectIds)
  • ✅ Embed when data is read together and doesn't change independently
  • ✅ Reference when data is shared across documents and changes frequently
  • ✅ ref property in schema tells Mongoose which model an ObjectId references
  • ✅ populate() replaces ObjectId references with actual documents from the referenced collection
  • ✅ Without populate(): you get raw ObjectIds — not useful for API responses
  • ✅ With populate(): you get complete nested data in one query
  • ✅ Always populate after CREATE operations to return meaningful data to clients
  • ✅ Use select() with populate to limit which fields are returned for performance
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase