Skip to main content
Database Design·Lesson 4 of 5

NoSQL with MongoDB

MongoDB is the most popular NoSQL database. Instead of tables and rows, it stores data as flexible JSON-like documents in collections. This lesson covers MongoDB's document model, CRUD operations, and schema design patterns.

Documents and Collections

In MongoDB, data is organized into databases, collections, and documents:

Database:   myapp
  Collection: users
    Document: { _id: "...", name: "Alice", email: "a@mail.co" }
    Document: { _id: "...", name: "Bob", email: "b@mail.co" }
  Collection: posts
    Document: { _id: "...", title: "Hello World", author: "Alice" }

A document is a JSON object (technically BSON — Binary JSON). Unlike SQL rows, documents in the same collection can have different fields.

Setting Up

Install the MongoDB Node.js driver or Mongoose (an ODM — Object Document Mapper):

npm install mongoose

Connect to MongoDB:

const mongoose = require("mongoose");

async function connect() {
  await mongoose.connect("mongodb://localhost:27017/myapp");
  console.log("Connected to MongoDB");
}

connect();

Defining Schemas with Mongoose

Although MongoDB is schemaless, Mongoose lets you define schemas for validation and structure:

const mongoose = require("mongoose");

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, "Name is required"],
      trim: true,
      minlength: 2,
      maxlength: 100,
    },
    email: {
      type: String,
      required: [true, "Email is required"],
      unique: true,
      lowercase: true,
    },
    role: {
      type: String,
      enum: ["user", "admin", "moderator"],
      default: "user",
    },
    age: {
      type: Number,
      min: 0,
      max: 150,
    },
    tags: [String],
    address: {
      street: String,
      city: String,
      country: String,
      zip: String,
    },
    isActive: {
      type: Boolean,
      default: true,
    },
  },
  {
    timestamps: true, // adds createdAt and updatedAt
  }
);

const User = mongoose.model("User", userSchema);
module.exports = User;

CRUD Operations

Create

// Create one
const user = await User.create({
  name: "Alice",
  email: "alice@example.com",
  tags: ["developer", "designer"],
  address: { city: "New York", country: "US" },
});

// Create many
await User.insertMany([
  { name: "Bob", email: "bob@example.com" },
  { name: "Charlie", email: "charlie@example.com" },
]);

Read

// Find all
const users = await User.find();

// Find with conditions
const activeUsers = await User.find({ isActive: true });

// Find one
const alice = await User.findOne({ email: "alice@example.com" });

// Find by ID
const user = await User.findById("65a1b2c3d4e5f6a7b8c9d0e1");

// Select specific fields
const names = await User.find().select("name email -_id");

// Sort, limit, skip
const recent = await User.find()
  .sort({ createdAt: -1 })
  .limit(10)
  .skip(0);

Query Operators

// Comparison
const expensive = await Product.find({ price: { $gt: 100 } });
const affordable = await Product.find({ price: { $lte: 50 } });
const range = await Product.find({ price: { $gte: 20, $lte: 80 } });

// Logical
const results = await Product.find({
  $or: [{ category: "electronics" }, { price: { $lt: 10 } }],
});

// Array operations
const devs = await User.find({ tags: "developer" }); // contains "developer"
const multi = await User.find({ tags: { $all: ["developer", "designer"] } });

// Text search
const matches = await User.find({ name: { $regex: /ali/i } });

Operator Reference

OperatorMeaningExample
$eqEqual{ age: { $eq: 25 } }
$gtGreater than{ price: { $gt: 100 } }
$gteGreater than or equal{ score: { $gte: 70 } }
$ltLess than{ stock: { $lt: 5 } }
$inIn array{ role: { $in: ["admin"] } }
$orLogical OR{ $or: [cond1, cond2] }
$andLogical AND{ $and: [cond1, cond2] }
$regexPattern match{ name: { $regex: /^A/i } }

Update

// Update one
await User.findByIdAndUpdate(
  userId,
  { name: "Alice Updated" },
  { new: true, runValidators: true }
);

// Update with operators
await User.updateOne(
  { email: "alice@example.com" },
  {
    $set: { role: "admin" },
    $push: { tags: "lead" },
    $inc: { loginCount: 1 },
  }
);

// Update many
await User.updateMany(
  { isActive: false },
  { $set: { isActive: true } }
);

Delete

await User.findByIdAndDelete(userId);
await User.deleteOne({ email: "old@example.com" });
await User.deleteMany({ isActive: false });

Embedding vs Referencing

The biggest design decision in MongoDB is whether to embed related data or reference it.

Embedding (Denormalized)

Store related data inside the parent document:

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  comments: [
    {
      author: String,
      body: String,
      createdAt: { type: Date, default: Date.now },
    },
  ],
});

Use embedding when:

  • The related data is always accessed together
  • The embedded array will not grow unboundedly
  • The data does not need to be queried independently

Referencing (Normalized)

Store a reference (ID) to a document in another collection:

const commentSchema = new mongoose.Schema({
  post: { type: mongoose.Schema.Types.ObjectId, ref: "Post" },
  author: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
  body: String,
});

// Populate references when querying
const comments = await Comment.find({ post: postId })
  .populate("author", "name email")
  .sort({ createdAt: -1 });

Use referencing when:

  • The related data is large or accessed independently
  • The related data changes frequently
  • You need to query the related data on its own

Aggregation Pipeline

MongoDB's aggregation pipeline lets you process data in stages:

const stats = await Order.aggregate([
  // Stage 1: Filter
  { $match: { status: "completed" } },

  // Stage 2: Group by customer
  {
    $group: {
      _id: "$customerId",
      totalSpent: { $sum: "$total" },
      orderCount: { $count: {} },
      avgOrderValue: { $avg: "$total" },
    },
  },

  // Stage 3: Sort by total spent
  { $sort: { totalSpent: -1 } },

  // Stage 4: Limit to top 10
  { $limit: 10 },
]);

Practical Exercise

Build a blog schema with MongoDB:

const blogPostSchema = new mongoose.Schema(
  {
    title: { type: String, required: true },
    slug: { type: String, unique: true, required: true },
    content: { type: String, required: true },
    author: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
    tags: [{ type: String, lowercase: true }],
    status: {
      type: String,
      enum: ["draft", "published", "archived"],
      default: "draft",
    },
    views: { type: Number, default: 0 },
    comments: [
      {
        user: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
        body: { type: String, required: true },
        createdAt: { type: Date, default: Date.now },
      },
    ],
  },
  { timestamps: true }
);

// Indexes for common queries
blogPostSchema.index({ slug: 1 });
blogPostSchema.index({ tags: 1 });
blogPostSchema.index({ status: 1, createdAt: -1 });

Key Takeaways

  • MongoDB stores data as flexible JSON-like documents grouped into collections.
  • Mongoose adds schema validation and convenience methods on top of the native driver.
  • Choose embedding for tightly coupled data and referencing for independent, large, or frequently changing data.
  • Query operators like $gt, $in, $or, and $regex give you powerful filtering capabilities.
  • The aggregation pipeline processes data in stages for complex analytics queries.