database-mongodb-document-design

Designing MongoDB schemas

MongoDB Document Design

Scope: Document modeling, embedding vs referencing, schema patterns Lines: ~280 Last Updated: 2025-10-18

When to Use This Skill

Activate this skill when:

Core Concepts

Document Model Fundamentals

MongoDB stores data as BSON documents (JSON-like):

{
  "_id": ObjectId("507f1f77bcf86cd799439011"),
  "name": "Alice",
  "email": "alice@example.com",
  "created_at": ISODate("2024-01-15T10:30:00Z")
}

Key differences from relational:

Design Philosophy

Relational: Normalize first, denormalize for performance MongoDB: Model for your access patterns, embed related data

Goal: Minimize queries by structuring data as it's retrieved.


Embedding vs Referencing

Embedding (Denormalization)

Pattern: Store related data inside the document.

// User document with embedded address
{
  "_id": ObjectId("..."),
  "name": "Alice",
  "email": "alice@example.com",
  "address": {
    "street": "123 Main St",
    "city": "San Francisco",
    "state": "CA",
    "zip": "94105"
  }
}

When to embed:

Pros:

Cons:

Referencing (Normalization)

Pattern: Store reference to another document (like foreign key).

// User document
{
  "_id": ObjectId("507f1f77bcf86cd799439011"),
  "name": "Alice",
  "email": "alice@example.com"
}

// Order document with reference
{
  "_id": ObjectId("..."),
  "user_id": ObjectId("507f1f77bcf86cd799439011"),  // Reference
  "total": 99.99,
  "items": [...]
}

When to reference:

Pros:

Cons:


Decision Tree: Embed or Reference?

Start: How is the data accessed?
│
├─ Always accessed together?
│  ├─ Yes → Embed
│  └─ No → Reference
│
├─ How often does related data change?
│  ├─ Rarely → Embed
│  └─ Frequently → Reference
│
├─ How many related items?
│  ├─ One or few (1:1, 1:few) → Embed
│  ├─ Bounded (1:many, predictable) → Embed or Reference
│  └─ Unbounded (1:many, grows indefinitely) → Reference
│
├─ Is related data shared across documents?
│  ├─ Yes → Reference
│  └─ No → Embed
│
└─ Document size concerns?
   ├─ Will exceed 16MB? → Reference
   └─ Stays small → Embed

Common Patterns

Pattern 1: One-to-One (Embed)

Use case: User profile

{
  "_id": ObjectId("..."),
  "email": "alice@example.com",
  "profile": {
    "first_name": "Alice",
    "last_name": "Smith",
    "bio": "Software engineer",
    "avatar_url": "https://..."
  },
  "created_at": ISODate("...")
}

Why embed: Profile is always accessed with user, doesn't change often.

Pattern 2: One-to-Few (Embed)

Use case: Blog post with comments (limited)

{
  "_id": ObjectId("..."),
  "title": "My Blog Post",
  "content": "Lorem ipsum...",
  "author": "Alice",
  "comments": [
    {
      "author": "Bob",
      "text": "Great post!",
      "created_at": ISODate("...")
    },
    {
      "author": "Charlie",
      "text": "Thanks for sharing",
      "created_at": ISODate("...")
    }
  ]
}

Why embed: Few comments (10-20), retrieved with post.

Constraint: Limit embedded array size (e.g., max 100 comments).

Pattern 3: One-to-Many (Bounded) - Hybrid

Use case: Product with reviews (potentially many)

Option 1: Embed first N reviews, reference rest

// Product document
{
  "_id": ObjectId("..."),
  "name": "Widget",
  "price": 29.99,
  "recent_reviews": [  // Embed first 10 for quick display
    {
      "user": "Alice",
      "rating": 5,
      "text": "Great product!",
      "created_at": ISODate("...")
    }
  ],
  "review_count": 150
}

// Separate reviews collection for full list
db.reviews.find({ product_id: ObjectId("...") })

Option 2: Reference all reviews

// Product document
{
  "_id": ObjectId("..."),
  "name": "Widget",
  "price": 29.99,
  "review_count": 150
}

// Reviews collection
{
  "_id": ObjectId("..."),
  "product_id": ObjectId("..."),  // Reference
  "user": "Alice",
  "rating": 5,
  "text": "Great product!"
}

Query with $lookup (join):

db.products.aggregate([
  { $match: { _id: ObjectId("...") } },
  { $lookup: {
      from: "reviews",
      localField: "_id",
      foreignField: "product_id",
      as: "reviews"
  }}
])

Pattern 4: One-to-Many (Unbounded) - Reference

Use case: User with orders (grows indefinitely)

// User document
{
  "_id": ObjectId("507f1f77bcf86cd799439011"),
  "name": "Alice",
  "email": "alice@example.com"
}

// Orders collection
{
  "_id": ObjectId("..."),
  "user_id": ObjectId("507f1f77bcf86cd799439011"),  // Reference
  "items": [...],
  "total": 99.99,
  "created_at": ISODate("...")
}

// Query user's orders
db.orders.find({ user_id: ObjectId("507f1f77bcf86cd799439011") })

Why reference: Orders grow indefinitely, not always accessed with user.

Pattern 5: Many-to-Many (Reference with Junction)

Use case: Users and roles

Option 1: Embed role IDs in user

// User document
{
  "_id": ObjectId("..."),
  "name": "Alice",
  "role_ids": [
    ObjectId("role1"),
    ObjectId("role2")
  ]
}

// Roles collection
{
  "_id": ObjectId("role1"),
  "name": "admin",
  "permissions": ["read", "write", "delete"]
}

// Query with $lookup
db.users.aggregate([
  { $match: { _id: ObjectId("...") } },
  { $lookup: {
      from: "roles",
      localField: "role_ids",
      foreignField: "_id",
      as: "roles"
  }}
])

Option 2: Separate junction collection

// Users
{ "_id": ObjectId("user1"), "name": "Alice" }

// Roles
{ "_id": ObjectId("role1"), "name": "admin" }

// User-Roles junction
{
  "user_id": ObjectId("user1"),
  "role_id": ObjectId("role1"),
  "granted_at": ISODate("...")
}

Avoiding Unbounded Arrays

Problem: Embedding unbounded arrays leads to document growth and performance issues.

// ❌ BAD: Unbounded array
{
  "_id": ObjectId("..."),
  "user": "Alice",
  "posts": [  // Could grow to thousands
    { "title": "Post 1", "content": "..." },
    { "title": "Post 2", "content": "..." },
    // ... thousands more
  ]
}

Solutions:

Solution 1: Reference Instead

// User
{ "_id": ObjectId("user1"), "name": "Alice" }

// Posts (separate collection)
{ "_id": ObjectId("..."), "user_id": ObjectId("user1"), "title": "Post 1" }
{ "_id": ObjectId("..."), "user_id": ObjectId("user1"), "title": "Post 2" }

Solution 2: Bucketing Pattern

Use case: Time-series data (sensor readings, logs)

// Instead of one document per reading:
// { sensor_id: 1, reading: 23.5, timestamp: ... }

// Bucket readings by hour:
{
  "_id": ObjectId("..."),
  "sensor_id": 1,
  "bucket_hour": ISODate("2024-01-15T10:00:00Z"),
  "readings": [
    { "value": 23.5, "minute": 0 },
    { "value": 23.7, "minute": 1 },
    // ... up to 60 readings per hour
  ],
  "reading_count": 60
}

Benefits:

Solution 3: Outlier Pattern

Use case: Products with varying review counts (most have few, some have thousands)

// For products with < 100 reviews: embed
{
  "_id": ObjectId("product1"),
  "name": "Widget",
  "reviews": [
    { "user": "Alice", "rating": 5, "text": "..." }
  ],
  "review_count": 10
}

// For products with > 100 reviews: reference
{
  "_id": ObjectId("product2"),
  "name": "Popular Widget",
  "review_count": 5000,
  "has_outlier_reviews": true  // Flag
}

// Separate reviews collection for outlier products
db.reviews.find({ product_id: ObjectId("product2") })

Schema Validation

MongoDB supports JSON Schema validation to enforce structure.

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "name", "created_at"],
      properties: {
        email: {
          bsonType: "string",
          pattern: "^.+@.+$",
          description: "must be a valid email"
        },
        name: {
          bsonType: "string",
          minLength: 1,
          maxLength: 100
        },
        age: {
          bsonType: "int",
          minimum: 0,
          maximum: 120
        },
        created_at: {
          bsonType: "date"
        }
      }
    }
  }
})

Validation modes:


Indexing Strategies

Single Field Index

db.users.createIndex({ email: 1 })  // Ascending
db.users.createIndex({ created_at: -1 })  // Descending

Compound Index

db.orders.createIndex({ user_id: 1, created_at: -1 })

Order matters: Can use for user_id alone, but not created_at alone.

Unique Index

db.users.createIndex({ email: 1 }, { unique: true })

Sparse Index

Use case: Index field that doesn't exist in all documents.

db.users.createIndex({ phone: 1 }, { sparse: true })

Only indexes documents with phone field.

TTL Index (Auto-Delete)

Use case: Session data, temporary records.

db.sessions.createIndex(
  { created_at: 1 },
  { expireAfterSeconds: 3600 }  // Delete after 1 hour
)

Text Index (Full-Text Search)

db.posts.createIndex({ title: "text", content: "text" })

// Search
db.posts.find({ $text: { $search: "mongodb tutorial" } })

Common Pitfalls

Embedding unbounded arrays - Document grows indefinitely ✅ Use referencing or bucketing pattern

Normalizing like relational DB - Loses MongoDB benefits ✅ Embed related data when accessed together

No indexing on reference fields - Slow lookups ✅ Index foreign key fields (user_id, etc.)

Exceeding 16MB document limit - Insert/update fails ✅ Reference large data, use GridFS for files

Using $lookup for every query - Slow (like SQL joins) ✅ Embed data when possible, minimize lookups

No schema validation - Data inconsistency ✅ Use JSON Schema validation for structure


Migration from Relational

Relational → MongoDB Mapping

| Relational | MongoDB | |------------|---------| | Table | Collection | | Row | Document | | Column | Field | | Primary Key | _id field | | Foreign Key | Reference (ObjectId) or embedded | | JOIN | $lookup or embedded | | Index | Index |

Example: Blog Schema

Relational:

CREATE TABLE users (id, name, email);
CREATE TABLE posts (id, user_id, title, content);
CREATE TABLE comments (id, post_id, user_id, text);

MongoDB (Embedded):

// posts collection
{
  "_id": ObjectId("..."),
  "title": "My Post",
  "content": "...",
  "author": {  // Embedded user (denormalized)
    "name": "Alice",
    "email": "alice@example.com"
  },
  "comments": [  // Embedded comments
    {
      "author": "Bob",
      "text": "Great post!",
      "created_at": ISODate("...")
    }
  ]
}

MongoDB (Referenced):

// users collection
{ "_id": ObjectId("user1"), "name": "Alice", "email": "..." }

// posts collection
{
  "_id": ObjectId("post1"),
  "user_id": ObjectId("user1"),  // Reference
  "title": "My Post",
  "content": "..."
}

// comments collection
{
  "_id": ObjectId("..."),
  "post_id": ObjectId("post1"),  // Reference
  "user_id": ObjectId("user1"),  // Reference
  "text": "Great post!"
}

Decision: Embed if data accessed together, reference if queried independently.


Quick Reference

Embed When:

Reference When:

Design Checklist

Schema Design:
[ ] Modeled for access patterns (not normalization first)
[ ] Embedded data that's always accessed together
[ ] Referenced unbounded or frequently changing data
[ ] Avoided unbounded arrays
[ ] Considered bucketing for time-series data
[ ] Added schema validation for critical fields

Indexes:
[ ] _id indexed automatically
[ ] Foreign key fields (user_id, etc.) indexed
[ ] Frequently queried fields indexed
[ ] Compound indexes for multi-field queries
[ ] Unique indexes where appropriate

Performance:
[ ] Documents stay under 16MB
[ ] Minimize use of $lookup (prefer embedding)
[ ] Use projections to return only needed fields
[ ] Consider capping collections for logs/events

Related Skills


Last Updated: 2025-10-18 Format Version: 1.0 (Atomic)