Database Relationships — ref aur populate()
In real apps, data is connected: users have orders, orders have items. Mongoose ref and populate() make relationships easy.
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.
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.
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.
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).
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
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login