DEV Community

Cover image for Best Practices for Designing Scalable MongoDB Models with Mongoose
Babar Bilal
Babar Bilal

Posted on

Best Practices for Designing Scalable MongoDB Models with Mongoose

Creating complex models in MongoDB using Mongoose requires careful planning to ensure scalability, maintainability, and efficiency. Here are the best practices for designing complex models in MongoDB with Mongoose.


1. Schema Design Best Practices

Use Embedded Documents for One-to-Few Relationships

If the related data is small and read together frequently, embed it inside the document.

Example: A User with multiple addresses

const mongoose = require("mongoose");

const AddressSchema = new mongoose.Schema({
  street: String,
  city: String,
  zip: String,
  country: String,
});

const UserSchema = new mongoose.Schema({
  name: String,
  email: { type: String, required: true, unique: true },
  addresses: [AddressSchema], // Embedded subdocument
});

const User = mongoose.model("User", UserSchema);
Enter fullscreen mode Exit fullscreen mode

Pros: Faster read operations, fewer queries

Cons: Updates require writing the entire document again

Use when:

  • Data is frequently read together
  • The number of embedded documents is small (<10)

Use References (Normalization) for One-to-Many Relationships

If the related data is large or frequently updated separately, store references (ObjectIds).

Example: A User with multiple Orders (large dataset)

const OrderSchema = new mongoose.Schema({
  user: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, // Reference to User
  totalPrice: Number,
  items: [{ product: String, quantity: Number }],
});

const Order = mongoose.model("Order", OrderSchema);
Enter fullscreen mode Exit fullscreen mode

Pros: Efficient updates, avoids document bloat

Cons: Requires populate() to fetch related data

Use when:

  • The sub-collection grows large (>10 items)
  • You need independent CRUD operations on the sub-collection

🔹 Fetching referenced data with populate:

Order.find().populate("user").exec((err, orders) => {
  console.log(orders);
});
Enter fullscreen mode Exit fullscreen mode

Hybrid Approach (Partial Embedding + References)

For medium-sized related data, embed only frequently used fields and reference the rest.

Example: Embed order summary but reference order details

const OrderSchema = new mongoose.Schema({
  user: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
  totalPrice: Number,
  items: [{ product: String, quantity: Number }],
  shipping: {
    address: String,
    status: { type: String, default: "Processing" }, // Frequently queried field
  },
});
Enter fullscreen mode Exit fullscreen mode

Best of both worlds – fast reads and efficient updates


2. Schema Design Optimizations

Indexing for Fast Queries

Indexes improve query speed. Always index fields that are frequently queried.

const UserSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true, index: true }, // Index for fast lookup
  createdAt: { type: Date, default: Date.now, index: -1 }, // Sort index for fast filtering
});
Enter fullscreen mode Exit fullscreen mode

Use indexes on:

  • Frequently queried fields (email, username)
  • Fields used in sorting (createdAt)
  • Fields used in filtering (status, category)

🔹 Check Index Usage

db.users.getIndexes();
db.orders.find({ userId: "123" }).explain("executionStats");
Enter fullscreen mode Exit fullscreen mode

Timestamps for Tracking

Use timestamps: true in your schema to automatically store createdAt and updatedAt.

const OrderSchema = new mongoose.Schema({
  totalPrice: Number,
}, { timestamps: true });
Enter fullscreen mode Exit fullscreen mode

Use lean() for Read-Only Queries

lean() improves performance by returning plain JavaScript objects instead of full Mongoose documents.

Order.find().lean().exec();
Enter fullscreen mode Exit fullscreen mode

30-50% faster than normal queries

Use when:

  • You don’t need to modify the retrieved data
  • You only need raw JSON output for API responses

3. Handling Large Data Efficiently

Pagination for Large Datasets

Use pagination to limit query results for better performance.

const page = 1;
const limit = 10;

Order.find()
  .skip((page - 1) * limit)
  .limit(limit)
  .exec();
Enter fullscreen mode Exit fullscreen mode

Avoid limit(1000), as it can cause performance issues


Aggregation Pipeline for Complex Queries

Use aggregation for reporting and complex queries.

Order.aggregate([
  { $match: { status: "Completed" } },
  { $group: { _id: "$user", totalSpent: { $sum: "$totalPrice" } } },
]);
Enter fullscreen mode Exit fullscreen mode

4. Soft Deletes Instead of Permanent Deletion

Instead of deleting a document, use a deletedAt field.

const UserSchema = new mongoose.Schema({
  name: String,
  email: String,
  deletedAt: { type: Date, default: null },
});
Enter fullscreen mode Exit fullscreen mode

Hides deleted items without losing data

🔹 Query only active users:

User.find({ deletedAt: null });
Enter fullscreen mode Exit fullscreen mode

5. Virtual Fields for Computed Values

Virtual fields do not get stored in the database but are calculated dynamically.

UserSchema.virtual("fullName").get(function () {
  return `${this.firstName} ${this.lastName}`;
});
Enter fullscreen mode Exit fullscreen mode

Use for derived data without increasing DB size


Conclusion

🚀 Best Practices Summary
✅ Embed small data, reference large data

✅ Use lean(), pagination, and caching

✅ Index frequently queried fields

✅ Use soft deletes instead of actual deletion

✅ Use environment variables for security

✅ Use middleware for automation

Following these practices will help you build efficient, scalable, and maintainable MongoDB applications with Mongoose! 🚀

Top comments (0)