Make duplicated facts explicit: every fast read model has an owner, fan-out path, freshness contract, version evidence, and rebuild strategy.
Denormalization as Precomputed Work: Duplicated Data, Write Fan-Out, and Repair Strategies
Denormalization is not “NoSQL style.” It is a deliberate exchange: spend more work and storage when facts change so important reads can avoid joins, remote lookups, or broad scans. This lesson measures that exchange and demonstrates the partial-update failure that makes unmanaged duplication dangerous.
Learning outcomes
Denormalization is not “NoSQL style.” It is a deliberate exchange: spend more work and storage when facts change so important reads can avoid joins, remote lookups, or broad scans. This lesson measures that exchange and demonstrates the partial-update failure that makes unmanaged duplication dangerous.
Explain denormalization as precomputed work moved from reads to writes.
Quantify logical write fan-out across multiple projections and identify partial-update failure.
Use source versions to detect stale copies instead of assuming eventual convergence.
Design replay, reconciliation, and backfill paths before a projection becomes production-critical.
Mandatory work uses Python 3.13+ standard library only in one local process. No MongoDB, Cassandra, Redis, PostgreSQL server, cloud service, Docker image, paid feature, network manipulation, or destructive failure injection is required. Optional implementation references are PostgreSQL 18, MongoDB 8.3.8 (current released patch in the 8.3 stable series as of August 29, 2026), Apache Cassandra 5.0.9, and Redis Open Source 8.10. Product-specific transaction, indexing, partition, quota, security, and licensing behavior is illustrative rather than universal.
1. Duplication is a performance technique with a consistency bill
AtlasMart may copy a product’s title, category, price band, or customer display name into read-optimized projections. The benefit is locality: a browse request can return everything it needs without consulting multiple authoritative records. The cost is that one logical fact now has several physical representations. Write fan-out is the number of maintained destinations affected by one logical change. A projection is a derived representation shaped for a query. A version stamp records which authoritative version the projection reflects.
2. Decide which duplicated facts are snapshots and which must track the owner
| Duplicated fact | Why copy it? | Expected freshness | Repair rule |
|---|---|---|---|
| Order-line product name/price snapshot | Historical truth at purchase time | Immutable after checkout | Never “refresh” from catalog; snapshot belongs to the order aggregate |
| Catalog category browse card | Avoid product lookups during browse | Seconds acceptable | Replay catalog changes; reconcile by source version |
| Search document | Tokenized/ranked retrieval | Seconds to minutes per SLO | CDC/outbox + idempotent update + rebuild |
| Customer display name in support queue | Avoid remote lookup on every row | Minutes may be acceptable | Owner version + background reconciliation |
Duplication is safest when semantics are explicit. Historical snapshots intentionally diverge from the current source. Derived copies are expected to converge. Treating both categories the same causes either unwanted rewrites of history or unexplained stale data.
3. AtlasMart lab: expose partial fan-out failure
Run the deterministic simulation below. One product update should reach the authority plus three projections. The injected failure skips the search projection after the source commits.
from copy import deepcopy
source = {
"p1": {"id":"p1","name":"Trail Runner","category":"footwear","price":120,"version":1}
}
category_view = {"p1": {"category":"footwear","name":"Trail Runner","version":1}}
search_view = {"p1": {"text":"trail runner footwear","price":120,"version":1}}
recommend_view = {"p1": {"label":"Trail Runner","price_band":"mid","version":1}}
def band(price):
return "budget" if price < 50 else "mid" if price < 150 else "premium"
def fanout_write(new_doc, fail_target=None):
source[new_doc["id"]] = deepcopy(new_doc)
writes = 1
updates = {
"category": {"category":new_doc["category"],"name":new_doc["name"],"version":new_doc["version"]},
"search": {"text":f'{new_doc["name"].lower()} {new_doc["category"]}',"price":new_doc["price"],"version":new_doc["version"]},
"recommend": {"label":new_doc["name"],"price_band":band(new_doc["price"]),"version":new_doc["version"]},
}
for name, value in updates.items():
if name == fail_target:
continue
{"category":category_view,"search":search_view,"recommend":recommend_view}[name][new_doc["id"]] = value
writes += 1
return writes
new = {"id":"p1","name":"Trail Runner Pro","category":"footwear","price":175,"version":2}
writes = fanout_write(new, fail_target="search")
print("logical writes completed:", writes, "of 4")
print("source version:", source["p1"]["version"])
print("category version:", category_view["p1"]["version"])
print("search version (stale):", search_view["p1"]["version"])
print("recommend price band:", recommend_view["p1"]["price_band"])
# Version stamps expose divergence.
stale = [name for name,view in [("category",category_view),("search",search_view),("recommend",recommend_view)] if view["p1"]["version"] != source["p1"]["version"]]
print("stale projections detected:", stale)
# Reconciliation/backfill derives the failed projection from authority.
p = source["p1"]
search_view["p1"] = {"text":f'{p["name"].lower()} {p["category"]}',"price":p["price"],"version":p["version"]}
print("search repaired version:", search_view["p1"]["version"])
print("all projections current:", all(v["p1"]["version"] == p["version"] for v in [category_view,search_view,recommend_view]))
print("lesson: denormalization trades read work for write fan-out + repair obligations")
The update completes three of four logical writes. Source, category, and recommendation copies reach version 2 while search remains at version 1. Version comparison detects the stale projection and reconciliation rebuilds it from authoritative state. The number “4” is the model’s logical fan-out, not a claim about any specific product’s physical I/O.
4. Avoid blind dual writes
A naïve application writes the source and then directly updates several projections. If the process crashes between destinations, the business write may succeed while a read model remains stale. Retrying the whole workflow can duplicate non-idempotent side effects. Safer patterns include a transactional outbox when source state and an outbox record can share one transaction, change data capture from an authoritative log, idempotent consumers, source version checks, and periodic reconciliation. None of these magically creates “exactly once” end-to-end behavior; they make replay and convergence tractable.
5. Measure the right things
Track projection lag by source version or event offset, backlog depth, replay failures, oldest unprocessed change, reconciliation mismatches, backfill throughput, and write fan-out latency. A dashboard that only reports HTTP success can say “healthy” while customers read stale prices. Security also fans out: tenant filters, authorization labels, retention/deletion requirements, and sensitive-field minimization must apply to every copy.
6. Production judgment and bridge
Denormalize when the read-path gain is material, duplication is bounded, ownership is unambiguous, staleness is acceptable or detectable, and the team can operate replay/rebuild. Prefer an owner-local aggregate when duplicated mutable facts would otherwise require constant synchronous coordination. The next lesson formalizes that ownership through aggregate boundaries and bounded contexts.
Wrong approach: “eventual consistency will fix it”
Eventual convergence is not a repair mechanism by itself. If a change is permanently dropped, no amount of waiting repairs the copy. A production design needs durable change capture or an authoritative source that can be scanned, plus version evidence and reconciliation to prove convergence.
Verification, cleanup, and production checklist
Verification is the deterministic program output plus the
reasoning checks below. Cleanup is simply deleting the local
lesson2.py file because the lab creates no external
service or persistent database. In production, repeat the design
with representative cardinality/skew, tail-latency, failure
injection, tenant-isolation tests, restore/rebuild drills,
migration rollback, and cost/capacity evidence before committing
to a physical model.
Check your understanding
- What is write fan-out?
- Why are order-line snapshots different from stale catalog copies?
- What does a source version prove?
- Why is blind dual write unsafe?
- What makes denormalization operable?
Review the answers
1. The number of physical destinations or maintained representations affected by one logical change.
2. The snapshot is intentionally immutable historical truth; the stale catalog copy is a derived view that should converge to its owner.
3. Which authoritative version a copy reflects; it does not by itself prove the copy is semantically correct or complete.
4. The process can fail between destinations, leaving a committed source change and a missing projection update.
5. Explicit ownership, bounded fan-out, freshness SLOs, idempotent replay, reconciliation, observability, and rebuild/rollback paths.
References
Foundational statements are kept vendor-neutral. Version-sensitive examples use current official documentation and are labeled as examples rather than definitions.
- MongoDB — Embedded Data in Your Schema — Current example of denormalization for read locality and single-document atomic updates.
- MongoDB — Reference Data in Your Schema — Current guidance on preferring references when duplicated data changes frequently or relationships are complex.
- PostgreSQL 18 — Materialized Views — Current example of persisted derived query results that must be refreshed.
- Martin Fowler — CQRS — Architecture reference for separating write and read models when their shapes differ materially.