Data Modeling Patterns for Document Databases
Practical data modeling for MongoDB and document databases: embedding versus referencing, denormalization tradeoffs and schema evolution patterns.

Document databases store data differently than relational databases. There are no JOINs, no foreign keys, and no schema enforcement by default. This is not a limitation — it is a different set of tradeoffs. The document model is optimized for reading complete objects in a single query, at the cost of more complex write patterns.
Most document database problems come from modeling data the relational way. Normalizing everything into separate collections and then "joining" in application code defeats the purpose. The right approach is modeling for your access patterns.
Embedding vs. Referencing
The fundamental decision in document modeling: should related data live inside the parent document (embedded) or in a separate collection (referenced)?
// ❌ Over-normalized — relational thinking in a document database
// Three collections, three queries to build a page view
const user = await db.users.findOne({ _id: userId });
const address = await db.addresses.findOne({ userId });
const preferences = await db.preferences.findOne({ userId });
// Assembled in application code:
const profile = { ...user, address, preferences };// ✅ Embedded — single read returns the complete object
// One collection, one query
const profile = await db.users.findOne({ _id: userId });
// Document structure:
{
_id: "user123",
name: "Alice Chen",
email: "alice@example.com",
address: {
street: "123 Main St",
city: "Portland",
state: "OR",
zip: "97201"
},
preferences: {
theme: "dark",
language: "en",
notifications: {
email: true,
push: false
}
}
}Embed when:
- The related data always appears with the parent (address belongs to user)
- The embedded data does not grow unboundedly
- You rarely need the embedded data independently
When to Reference
Referencing is correct when embedded data would grow without limit, when the same data appears in multiple contexts, or when the embedded document would exceed MongoDB's 16MB document size limit.
// ❌ Embedding unbounded arrays — document grows forever
{
_id: "user123",
name: "Alice",
orders: [
{ orderId: "ord1", total: 59.99, items: [...] },
{ orderId: "ord2", total: 129.50, items: [...] },
// ... 10,000 more orders over 5 years
// Document exceeds 16MB, queries slow down
]
}// ✅ Reference — orders in separate collection
// Users collection:
{
_id: "user123",
name: "Alice",
email: "alice@example.com"
}
// Orders collection — indexed by userId for fast lookups:
{
_id: "ord456",
userId: "user123",
total: 59.99,
status: "delivered",
createdAt: ISODate("2021-06-01"),
items: [
{ productId: "prod1", name: "Widget", quantity: 2, price: 29.99 }
]
}
// Query: recent orders for a user
const orders = await db.orders
.find({ userId: "user123" })
.sort({ createdAt: -1 })
.limit(10);Reference when:
- The related data grows unboundedly (orders, logs, comments)
- The related data has its own lifecycle (products exist independently of orders)
- You need to query the related data independently (all orders over $100)
The Subset Pattern
When you need some embedded data for common reads but the full set is too large, embed a subset and reference the full collection.
// Product document with the 10 most recent reviews embedded
{
_id: "prod789",
name: "Wireless Headphones",
price: 79.99,
rating: 4.3,
reviewCount: 2847,
// Embed recent reviews for the product page
recentReviews: [
{
userId: "user1",
userName: "Bob",
rating: 5,
text: "Great sound quality",
createdAt: ISODate("2021-06-07")
},
{
userId: "user2",
userName: "Carol",
rating: 4,
text: "Good but pricey",
createdAt: ISODate("2021-06-05")
}
// ... up to 10 recent reviews
]
}
// Full reviews collection — for pagination, search, and analytics
{
_id: "rev123",
productId: "prod789",
userId: "user1",
userName: "Bob",
rating: 5,
text: "Great sound quality",
createdAt: ISODate("2021-06-07"),
helpful: 23,
verified: true
}// Product page: single query returns product + recent reviews
const product = await db.products.findOne({ _id: productId });
// product.recentReviews is already there — no JOIN needed
// "See all reviews" page: paginated query on reviews collection
const allReviews = await db.reviews
.find({ productId })
.sort({ createdAt: -1 })
.skip(page * pageSize)
.limit(pageSize);The tradeoff is write complexity. When a new review is added, you update both the reviews collection and the product's recentReviews array. This is acceptable because reviews are read far more often than written.
The Extended Reference Pattern
Store a copy of frequently accessed fields from referenced documents to avoid lookups for common queries.
// ❌ Order references userId — needs a second query for user name
{
_id: "ord456",
userId: "user123", // Need to look up user to display their name
total: 59.99
}
// Display "Order by Alice Chen" requires:
const order = await db.orders.findOne({ _id: orderId });
const user = await db.users.findOne({ _id: order.userId });
const display = `Order by ${user.name}`;// ✅ Extended reference — copy the fields you need for display
{
_id: "ord456",
userId: "user123",
userName: "Alice Chen", // Copied from user document
userEmail: "alice@example.com", // Copied for email receipts
total: 59.99,
createdAt: ISODate("2021-06-08")
}
// Single query returns everything needed for display
const order = await db.orders.findOne({ _id: orderId });
const display = `Order by ${order.userName}`; // No second queryThe copied fields are denormalized — they can become stale if the user changes their name. This is acceptable for orders (the name at time of purchase is what matters) but would be a problem for a real-time chat display.
Schema Validation
Document databases do not enforce schemas by default, but MongoDB supports JSON Schema validation to prevent bad data from entering collections.
// Create collection with schema validation
await db.createCollection('users', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['name', 'email', 'createdAt'],
properties: {
name: {
bsonType: 'string',
minLength: 1,
maxLength: 200,
},
email: {
bsonType: 'string',
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
},
role: {
enum: ['admin', 'user', 'moderator'],
},
address: {
bsonType: 'object',
properties: {
street: { bsonType: 'string' },
city: { bsonType: 'string' },
zip: { bsonType: 'string', pattern: '^[0-9]{5}$' },
},
},
createdAt: {
bsonType: 'date',
},
},
},
},
validationAction: 'error', // Reject invalid documents
validationLevel: 'strict', // Validate all inserts and updates
});Schema validation catches data quality issues at the database level. Even if application code has a bug that sends malformed data, the database rejects it.
Schema Evolution
Documents evolve as features change. Unlike relational migrations that alter tables, document schemas evolve through application-level migration patterns.
// Schema version pattern — handle multiple versions in code
interface UserV1 {
_id: string;
name: string; // single name field
email: string;
}
interface UserV2 {
_id: string;
firstName: string; // name split into two fields
lastName: string;
email: string;
schemaVersion: 2;
}
type User = UserV1 | UserV2;
// Read function handles both versions
function normalizeUser(doc: User): NormalizedUser {
if ('schemaVersion' in doc && doc.schemaVersion === 2) {
return {
firstName: doc.firstName,
lastName: doc.lastName,
email: doc.email,
};
}
// V1: split the single name field
const [firstName, ...rest] = doc.name.split(' ');
return {
firstName,
lastName: rest.join(' ') || '',
email: doc.email,
};
}
// Lazy migration: update documents as they're read
async function getUser(id: string): Promise<NormalizedUser> {
const doc = await db.users.findOne({ _id: id });
const normalized = normalizeUser(doc);
// If document is old format, update it opportunistically
if (!('schemaVersion' in doc)) {
await db.users.updateOne(
{ _id: id },
{
$set: {
firstName: normalized.firstName,
lastName: normalized.lastName,
schemaVersion: 2,
},
$unset: { name: '' },
}
);
}
return normalized;
}Lazy migration updates documents as they are accessed. Over time, most documents migrate to the new schema. For rarely-accessed documents, a background job can sweep through remaining old-format documents.
Key Takeaways
- Embed data that belongs together — if you always need address with user, put it in the same document
- Reference unbounded one-to-many relationships — orders, logs, and comments belong in separate collections
- Use the subset pattern for hot data — embed recent reviews on the product, paginate the full set from a separate collection
- Denormalize for read performance — copy frequently-displayed fields to avoid lookups, accept write complexity
- Add schema validation — document databases should not mean schema-free; validate at the database level
- Evolve schemas lazily — handle multiple versions in code, migrate documents as they are read


