NoSQL Data Modeling — Patterns
Contents
- Access-pattern table template
- Embed vs reference — worked examples
- Bucket pattern for unbounded arrays
- Cursor pagination
- Redis key catalog
- Schema versioning / lazy migration
- Gotchas
Access-pattern table template
| # | Operation | Fields needed | Freq | Consistency |
|---|---|---|---|---|
| 1 | Load order page | order, items, shipping status | 500 r/s | read-your-writes |
| 2 | Add item to cart | cart items | 50 w/s | strong (same doc) |
| 3 | Seller sales list | order ids, totals by seller | 5 r/s | eventual ok |
Every pattern must map to one indexed query or key lookup in the final model.
Embed vs reference — worked examples
js
// EMBED: order + line items (1:few, read & updated together, bounded)
{
_id: "ord_81",
user_id: "u_42", // reference — user updates independently
user_name: "Alice", // denormalized copy; truth: users collection,
// refreshed: never (historical snapshot is correct here)
status: "shipped",
items: [ { sku: "A-1", qty: 2, unit_price_cents: 1250 } ],
schema_version: 2
}
// REFERENCE: user ← posts (unbounded growth)
{ _id: "u_42", name: "Alice", schema_version: 1 }
{ _id: "p_777", author_id: "u_42", author_name: "Alice", // copy, refreshed on user rename via async job
title: "...", created_at: ISODate("2026-08-01") }
// Index to serve "posts by user, newest first":
db.posts.createIndex({ author_id: 1, created_at: -1 })Bucket pattern for unbounded arrays
js
// Instead of one sensor doc with an ever-growing readings[] array:
{
_id: "sensor_9:2026-08-05", // one bucket per sensor per day
sensor_id: "sensor_9",
day: "2026-08-05",
count: 1440,
readings: [ { t: "00:00", v: 20.1 }, /* ≤ 1440 */ ]
}
// Bounded documents, efficient range reads, no 16MB ceiling risk.Cursor pagination
js
// Page 1:
db.posts.find({ author_id: uid })
.sort({ created_at: -1, _id: -1 }) // _id breaks timestamp ties
.limit(20)
// Next page — cursor = (created_at, _id) of last item:
db.posts.find({
author_id: uid,
$or: [
{ created_at: { $lt: cursor.created_at } },
{ created_at: cursor.created_at, _id: { $lt: cursor._id } }
]
}).sort({ created_at: -1, _id: -1 }).limit(20)
// Offsets (skip) degrade linearly and break when rows shift — never for feeds.Redis key catalog
Document every key family in one table; every cache key has a TTL.
| Key pattern | Type | TTL | Notes |
|---|---|---|---|
user:{id}:session |
hash | 30d sliding | auth token data |
cache:product:{id} |
string(json) | 300s | invalidate on product write |
rate:{ip}:{minute} |
int (INCR) | 120s | rate limiting window |
queue:emails |
list | none | worker queue (durable store elsewhere) |
counter:orders:{0..15} |
int | none | sharded hot counter; SUM on read |
text
SET cache:product:9f3e '{"name":...}' EX 300
INCR rate:203.0.113.7:202608051211
EXPIRE rate:203.0.113.7:202608051211 120 NXSchema versioning / lazy migration
js
// Reader handles current and previous version:
function readUser(doc) {
if (doc.schema_version === 1) {
doc.full_name = doc.name; // v1 → v2 shape
doc.schema_version = 2;
}
return doc;
}
// Writer persists upgraded shape on next write ("lazy migration").
// Backfill job optional once v1 read-rate ≈ 0.Gotchas
- MongoDB's 16MB limit includes field names — long repeated keys in big arrays waste real space; short names matter at scale.
- An index on
{a: 1, b: 1}serves filters onaanda+b, NOTbalone (prefix rule) — order compound indexes by equality → sort → range. $lookup(joins) run on unsharded/local data paths and get slow fast — a frequent$lookupis a modeling smell: embed or copy instead.- DynamoDB: model EVERYTHING around partition key + sort key up front; there is no ad-hoc query escape hatch, only GSIs (each with its own cost).
- Redis
KEYS patternblocks the server — alwaysSCANin production. - Firestore charges and limits per document read — bucket smallness matters differently: many tiny docs can cost more than fewer medium ones.
- Eventual consistency of denormalized copies must be a product decision ("name may be stale ≤5 min"), never an accident.