🏠 Home / Hub

🏠 MongoDB — Lesson 3: Schema Design

MongoDB's flexible schema is a feature, not just a convenience. Good schema design is the single most important factor in MongoDB performance. This lesson covers the fundamental decision: embed or reference, and how to model real-world relationships.

1. Embedding vs Referencing

Every relationship in MongoDB can be modeled in two ways: embed the related data inside the document, or reference it by storing an ObjectId pointing to another collection.

Factor Embedding (Denormalized) Referencing (Normalized)
Read performanceFast — single document fetchSlower — requires $lookup or 2 queries
Write performanceUpdate embedded = update parentUpdate referenced = update 1 document
Data duplicationYes — same data repeatedNo — single source of truth
Data consistencyManual update if data changesAutomatic — one place to update
Document sizeCan grow large (16MB limit)Small, predictable
AtomicityAtomic (single doc operation)Multi-doc (needs transactions)
Query simplicitySimple — just access the fieldComplex — need $lookup or populate
Best whenData accessed together, 1:1 or 1:FewData accessed independently, 1:Many or M:N
The golden rule: If you always read the related data together, embed it. If you read it independently, reference it. The read pattern drives the schema design.

2. One-to-One Relationships — Embed

A user has exactly one profile. The profile is never fetched without the user. This is the ideal case for embedding.

SQL Approach (2 tables)

-- users table
users: id, email, password

-- profiles table
profiles: id, user_id,
          first_name, last_name,
          avatar, bio

MongoDB Approach (embedded)

{
  _id: ObjectId(...),
  email: "alice@example.com",
  password: "$2b$12$...",
  profile: {
    firstName: "Alice",
    lastName: "Johnson",
    avatar: "https://...",
    bio: "Developer..."
  }
}
// Access embedded profile — no join needed
db.users.findOne({ email: "alice@example.com" })
// Returns the whole doc including profile

// Query by nested field
db.users.find({ "profile.firstName": "Alice" })

// Update a nested field
db.users.updateOne(
  { email: "alice@example.com" },
  { $set: { "profile.bio": "Senior Developer & MongoDB enthusiast" } }
)

3. One-to-Many Relationships

Pattern A: Embed Array (1:Few)

Use when the "many" side is small (fewer than 100 items), grows slowly, and is always accessed with the parent. Example: a post's comments.

// Post document with embedded comments
{
  _id: ObjectId("post1"),
  title: "MongoDB Schema Design",
  author: "alice",
  body: "Today we learn about schema design...",
  comments: [
    {
      _id: ObjectId(),
      author: "bob",
      text: "Great post!",
      createdAt: ISODate("2024-01-15")
    },
    {
      _id: ObjectId(),
      author: "carol",
      text: "Very helpful, thanks!",
      createdAt: ISODate("2024-01-16")
    }
  ]
}

// Add a new comment
db.posts.updateOne(
  { _id: ObjectId("post1") },
  {
    $push: {
      comments: {
        _id: new ObjectId(),
        author: "dave",
        text: "Bookmarking this!",
        createdAt: new Date()
      }
    }
  }
)

// Find posts with a comment from "bob"
db.posts.find({ "comments.author": "bob" })

Pattern B: Reference by _id (1:Many)

Use when the "many" side can be large, is frequently accessed independently, or needs its own indexes. Example: orders for a customer.

// Customer document (the "one" side)
{
  _id: ObjectId("customer1"),
  name: "Alice Johnson",
  email: "alice@example.com"
}

// Order documents (the "many" side — each references the customer)
{
  _id: ObjectId("order1"),
  customerId: ObjectId("customer1"),  // ← the reference
  items: [{ productId: ObjectId("p1"), qty: 2, price: 19.99 }],
  total: 39.98,
  status: "shipped",
  createdAt: ISODate("2024-02-01")
}

// Find all orders for a customer
db.orders.find({ customerId: ObjectId("customer1") })

// Lookup orders when fetching customer (aggregation)
db.customers.aggregate([
  { $match: { _id: ObjectId("customer1") } },
  {
    $lookup: {
      from: "orders",
      localField: "_id",
      foreignField: "customerId",
      as: "orders"
    }
  }
])

Pattern C: Store Array of IDs on Parent (1:Many, bounded)

// Author stores an array of post IDs
{
  _id: ObjectId("author1"),
  name: "Alice Johnson",
  postIds: [
    ObjectId("post1"),
    ObjectId("post2"),
    ObjectId("post3")
  ]
}

// Fetch all posts by IDs
db.posts.find({ _id: { $in: authorDoc.postIds } })

4. Many-to-Many Relationships

Students can enroll in many courses; courses have many students. There are two common approaches:

Approach A: Array of References on Both Sides

// Student document
{
  _id: ObjectId("student1"),
  name: "Bob Smith",
  enrolledCourseIds: [
    ObjectId("course1"),
    ObjectId("course2")
  ]
}

// Course document
{
  _id: ObjectId("course1"),
  title: "MongoDB Fundamentals",
  enrolledStudentIds: [
    ObjectId("student1"),
    ObjectId("student3")
  ]
}

// Find all courses a student is in
db.courses.find({ _id: { $in: student.enrolledCourseIds } })

// Find all students in a course
db.students.find({ _id: { $in: course.enrolledStudentIds } })

Approach B: Junction Collection (for extra metadata)

Use when the relationship itself has data (e.g., enrollment date, grade).

// Enrollment junction document
{
  _id: ObjectId(),
  studentId: ObjectId("student1"),
  courseId: ObjectId("course1"),
  enrolledAt: ISODate("2024-01-10"),
  grade: null,
  completed: false
}

// Find all courses with student info (aggregation)
db.enrollments.aggregate([
  { $match: { studentId: ObjectId("student1") } },
  {
    $lookup: {
      from: "courses",
      localField: "courseId",
      foreignField: "_id",
      as: "course"
    }
  },
  { $unwind: "$course" }
])

5. Real-World Examples

Blog Platform Schema

// USERS collection — referenced from posts
{
  _id: ObjectId("u1"),
  username: "alice_dev",
  email: "alice@example.com",
  password: "$2b$12$hashed...",
  role: "author",
  bio: "Software developer...",
  avatar: "https://...",
  createdAt: ISODate("2023-06-01")
}

// POSTS collection — author referenced, comments embedded
{
  _id: ObjectId("p1"),
  title: "MongoDB Schema Design",
  slug: "mongodb-schema-design",
  body: "Full article text...",
  authorId: ObjectId("u1"),     // ← reference (user changes name? Update one place)
  tags: ["mongodb", "design"],
  status: "published",
  views: 1240,
  likes: 84,
  comments: [                     // ← embedded (always read with post)
    {
      _id: ObjectId(),
      authorId: ObjectId("u2"),
      authorName: "Bob",          // ← denormalized for display performance
      text: "Great article!",
      createdAt: ISODate("2024-01-15")
    }
  ],
  publishedAt: ISODate("2024-01-10"),
  updatedAt: ISODate("2024-01-12")
}

// TAGS collection — referenced by posts
{
  _id: ObjectId(),
  name: "mongodb",
  slug: "mongodb",
  postCount: 42
}

E-Commerce Schema

// PRODUCTS collection
{
  _id: ObjectId("prod1"),
  name: "Mechanical Keyboard",
  sku: "KB-MX-001",
  price: 149.99,
  category: "electronics",
  stock: 23,
  attributes: {
    brand: "Keychron",
    switchType: "Brown",
    layout: "TKL"
  },
  images: ["img1.jpg", "img2.jpg"]
}

// ORDERS collection — snapshot pattern (embed product details)
{
  _id: ObjectId("ord1"),
  userId: ObjectId("u1"),
  status: "processing",
  items: [
    {
      productId: ObjectId("prod1"),
      // Snapshot fields — price at time of purchase
      name: "Mechanical Keyboard",
      sku: "KB-MX-001",
      price: 149.99,           // ← embedded price (won't change if product price changes)
      quantity: 1
    }
  ],
  shipping: {
    address: "123 Main St, New York, NY 10001",
    method: "standard",
    estimatedDelivery: ISODate("2024-02-15")
  },
  subtotal: 149.99,
  tax: 13.50,
  total: 163.49,
  createdAt: ISODate("2024-02-10")
}

6. Schema Anti-Patterns to Avoid

Anti-Pattern 1: Unbounded Arrays
Never embed an array that can grow without limit. A post with millions of comments will hit the 16MB document limit and cause performance issues.
// BAD: comments can grow to millions
{ _id: postId, title: "...", comments: [ /* 1,000,000 comments */ ] }

// GOOD: separate comments collection
{ _id: commentId, postId: postId, text: "...", author: "..." }
Anti-Pattern 2: Deeply Nested Documents
MongoDB supports dot notation for querying nested fields, but deeply nested structures become hard to query, index, and update.
// BAD: 5 levels of nesting
{ a: { b: { c: { d: { e: "value" } } } } }
// Query: db.col.find({ "a.b.c.d.e": "value" }) — messy!

// GOOD: flatten where possible
{ aValue: "value", category: "...", type: "..." }
Anti-Pattern 3: Treating MongoDB Like a SQL Database
Normalizing everything into separate collections and doing constant $lookup is slower than SQL joins. Embrace denormalization where it makes sense.
Anti-Pattern 4: Massive Number of Collections
Don't create a collection per user, per date, or per category. Use a discriminator field instead.
// BAD: one collection per user
db.user_alice_posts, db.user_bob_posts ...

// GOOD: one collection with a userId field
db.posts.find({ userId: "alice" })

7. Schema Validation with $jsonSchema

MongoDB supports optional schema validation using JSON Schema. This enforces rules at the database level.

// Create a collection with validation rules
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "password", "role"],
      properties: {
        email: {
          bsonType: "string",
          pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
          description: "must be a valid email address"
        },
        password: {
          bsonType: "string",
          minLength: 8,
          description: "must be at least 8 characters"
        },
        role: {
          bsonType: "string",
          enum: ["admin", "author", "reader"],
          description: "must be one of: admin, author, reader"
        },
        age: {
          bsonType: "int",
          minimum: 0,
          maximum: 150,
          description: "must be an integer between 0 and 150"
        }
      }
    }
  },
  validationLevel: "strict",    // "strict" | "moderate"
  validationAction: "error"    // "error" (reject) | "warn" (log only)
})

// This insert will FAIL validation (missing role, bad email)
db.users.insertOne({
  email: "not-an-email",
  password: "secret"
})

// Add validation to an existing collection
db.runCommand({
  collMod: "posts",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["title", "authorId"],
      properties: {
        title: { bsonType: "string", maxLength: 200 },
        status: { enum: ["draft", "published", "archived"] }
      }
    }
  }
})

8. Document Size Limit & Best Practices

Limit / RuleValueNotes
Max document size16 MBBSON encoded. Use GridFS for larger files.
Max nesting depth100 levelsKeep it under 5 for sanity
Embedded array sizeNo hard limitPractical limit ~100 items for performance
Field name lengthNo hard limitKeep short — stored with every document
Collections per databaseNo hard limitPractical: keep collections meaningful
Indexes per collection64 maxEach index has write overhead
GridFS: For files larger than 16MB, use GridFS — MongoDB's spec for storing large files by splitting them into 255KB chunks stored in a fs.chunks collection with metadata in fs.files.

📌 Study Checklist