name: modeling-nosql-data description: Models data for document and key-value stores — access-pattern-first design, embed vs reference decisions, controlled denormalization, Redis key design, cursor pagination, and document schema versioning. Use when the user asks to design a MongoDB or DynamoDB schema, model documents or collections, structure Redis keys, decide between embedding and referencing, or migrate a relational model to NoSQL. Do not use for relational schema design or for SQL databases with JSON columns, which follow relational rules first.
Modeling NoSQL Data
When to use / when NOT to use
- Use for: designing document models (MongoDB, Firestore, DynamoDB) and key-value layouts (Redis, DynamoDB keys).
- Do NOT use for: relational schemas (
designing-database-schemas) — and a Postgres table with a JSONB column is still relational: normalize first, JSONB for the truly schemaless remainder.
Core rules
- Queries first, schema second. List every access pattern (screen/endpoint → fields → frequency) BEFORE shaping documents — the inverse of relational design. A model that can't be listed against its queries isn't done.
- Embed vs reference decision rule:
- Embed when data is 1:few, read together, and updated together (order + its line items).
- Reference when 1:many grows unbounded, is shared by many parents, or updates independently (user ← posts; product ← reviews).
- ✅
{order_id, items: [{sku, qty}]}— ❌ embedding a user's entire post history in the user document.
- Denormalize on purpose, with ONE source of truth per fact. Copying
author_nameinto posts is fine — but document where the canonical value lives and how copies get refreshed (on write, or accept staleness with a TTL). - Design far below size limits. MongoDB caps documents at 16MB; treat ~1MB as the design ceiling. Unbounded arrays are the failure smell — bucket them (one document per day/100 items) or reference.
- Redis/KV keys: colon-namespaced, predictable, with a TTL policy.
- ✅
user:42:session,cache:product:9f3e+EXPIRE - ❌
data_42_final, keys without owner/type, cache keys with no TTL.
- ✅
- Paginate with cursors, never offsets. Sort by an indexed, unique (or tie-broken) field and continue from the last seen value:
find({created_at: {$lt: cursor}}).limit(20). - Version documents. Every document carries
schema_version: 3; readers handle N and N-1, writers upgrade on write (lazy migration). - Model transactions around aggregates. Put data that must change atomically in ONE document; cross-document transactions exist but are the escape hatch, not the design.
Workflow
- Write the access-pattern table: operation, fields needed, reads/sec vs writes/sec, consistency need.
- Group into aggregates (what changes together) → those become documents; everything else becomes references or copies.
- For each copy created by denormalization, record the source of truth and the refresh rule.
- Define keys/indexes per access pattern (compound indexes matching sort + filter; Redis key format + TTL per type).
- Validate: walk EVERY access pattern from step 1 against the model — each must resolve to one indexed query or one key lookup. Any pattern needing a scan or N+1 fetches → reshape and re-check.
Edge cases & failure modes
- Many-to-many → reference both ways only if both directions are queried; otherwise store the relation on the side that's queried.
- Hot documents (one doc absorbing all writes, e.g. a global counter) → shard the key (
counter:{0..15}) and sum on read. - Search across fields → document stores are poor at ad-hoc search; pair with a search index rather than contorting the model.
- Strong consistency needed across entities → that's the signal you may be in relational territory; say so instead of forcing it.
- Relational→NoSQL migration → do NOT port tables 1:1 into collections; restart from access patterns (rule 1).
References
Worked examples (blog, orders, sessions), bucket pattern, cursor pagination code, Redis key catalog: see references/patterns.md.