Chapter 06 · Query-First Data Modeling and Denormalization
One Table per Query Pattern: Denormalization as an Explicit Write-Time Cost
Treat denormalization as an intentional read-performance choice whose write fan-out and failure modes must be owned.
Learning outcomes
AtlasMart can now state its read patterns, but a product update affects more than one table: product details, category browsing and inventory-facing projections may each need a materialized row. Cassandra’s write path makes repeated writes cheap relative to distributed joins, but denormalization transfers complexity from read time to write/reconciliation time. This lesson makes that transfer explicit.
Explain “one table per query pattern” as a design heuristic, not a literal rule that every syntactic query needs a new table.
Calculate write fan-out for a mutation and distinguish logical application writes from RF-expanded replica writes.
Use a source version to detect projection drift across duplicated rows.
Identify when multiple compatible queries can share one table and when incompatible ordering/partitioning requires a new shape.
Design idempotent, observable projection updates and a reconciliation path.
-
Server: Apache Cassandra 5.0.9, pinned
container image
cassandra:5.0.9; Java 17 inside the image. -
Topology: three disposable nodes
atlasmart-cass-1..3, clusteratlasmart-course, one DCdc1, racksrack1..rack3, 16 vnodes per node, Docker networkatlasmart-cassandra. -
Replication / consistency: chapter keyspace
atlasmart_model,NetworkTopologyStrategy, RF=3 indc1; examples useQUORUMunless a different level is stated next to the operation. -
Storage: chapter tables explicitly use
UnifiedCompactionStrategy(UCS); no chapter-specificgc_grace_secondsor default TTL override. TTL is introduced only when a query has explicit retention semantics. - Security: the disposable Docker network is isolated for learning; authentication, client TLS, internode TLS and hardened JMX are not enabled. Do not expose CQL/JMX ports broadly or reuse this posture for production.
- Resources: the full three-node lab is intended for a development machine with enough headroom for three Cassandra JVMs (plan roughly 6 GiB+ RAM for the containers plus host/Docker overhead and several GiB of disposable disk). If that is impractical, use one pinned node and RF=1 only for schema/query-shape exercises and label the topology difference.
-
Client: mandatory work uses the bundled
cqlsh/nodetool. No application driver is required in Chapter 06, so driver retry/idempotency behavior is discussed as a design obligation but not fabricated as lab evidence. - Evidence: capture your own row counts, trace events, timings and node state. The generated lesson never claims unexecuted throughput, p95/p99 latency, fan-out cost or convergence measurements.
Run commands only against the disposable Apache Cassandra course lab or another explicitly approved non-production environment. Confirm node, keyspace, table, container, volume, path, and datacenter targets before destructive, failure-injection, cleanup, repair, restore, security, or topology operations. Capture current state and expected rollback/recovery evidence first; output and timings can differ by host, operating system, Java runtime, Docker/runtime, driver, and Cassandra configuration.
1. A query table is a materialized read contract
The phrase “one table per query” is shorthand for a deeper rule: a Cassandra table should serve a coherent family of requests that share the same partition address and clustering order. Two filters that require incompatible partition keys or orderings often need different tables. Conversely, a single table can serve several bounded variants if they use the same access path.
For AtlasMart, products_by_id answers direct
lookup, products_by_category answers category/price
traversal, and inventory_by_product answers store
availability for one product. Reusing one generic table would
force scans or client joins; creating these projections creates
write fan-out instead.
CREATE TABLE atlasmart_model.products_by_id ( product_id text PRIMARY KEY, name text, category text, price_cents int, active boolean, source_version bigint) WITH compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.products_by_category ( category text, price_cents int, product_id text, name text, active boolean, source_version bigint, PRIMARY KEY ((category), price_cents, product_id)) WITH CLUSTERING ORDER BY (price_cents ASC, product_id ASC) AND compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.inventory_by_product ( product_id text, store_id text, available int, updated_at timestamp, source_version bigint, PRIMARY KEY ((product_id), store_id)) WITH compaction = {'class':'UnifiedCompactionStrategy'};
2. Count fan-out at two different layers
A single business mutation—“product p-501 price changed to 119900, source version 8”—may require two or three logical projection writes. With RF=3, each logical write is then replicated to natural replicas according to the keyspace and consistency protocol. Do not multiply those numbers together and call the result “database transactions”; they are distinct costs and failure surfaces.
| Layer | Example | What to measure |
|---|---|---|
| Business event | 1 product change | event rate, payload, idempotency key/version |
| Projection fan-out | 2 product read-shape updates (+ inventory only if inventory changed) | logical writes/event, partial-failure rate |
| Replication | RF=3 per affected partition | replica/network/storage cost, CL latency |
| Reconciliation | compare source_version/content | drift count, age, repair throughput |
3. Reproducible fan-out and drift lab
# Reuse the course cluster if it exists; otherwise create only these disposable resources.docker network inspect atlasmart-cassandra >/dev/null 2>&1 || docker network create atlasmart-cassandradocker volume create atlasmart-cass-1-datadocker volume create atlasmart-cass-2-datadocker volume create atlasmart-cass-3-data# Start node 1 only if it does not already exist.docker inspect atlasmart-cass-1 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-1 --hostname atlasmart-cass-1 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack1 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -v atlasmart-cass-1-data:/var/lib/cassandra cassandra:5.0.9# Wait until node 1 answers before starting peers:docker exec atlasmart-cass-1 nodetool statusdocker inspect atlasmart-cass-2 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-2 --hostname atlasmart-cass-2 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack2 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-2-data:/var/lib/cassandra cassandra:5.0.9docker inspect atlasmart-cass-3 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-3 --hostname atlasmart-cass-3 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack3 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-3-data:/var/lib/cassandra cassandra:5.0.9# Wait until all three nodes report UN before creating RF=3 data.docker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 cqlsh -e "SELECT release_version,cluster_name,data_center,rack FROM system.local;"docker exec atlasmart-cass-1 cqlsh -e "CREATE KEYSPACE IF NOT EXISTS atlasmart_model WITH replication = {'class':'NetworkTopologyStrategy','dc1':3};"docker exec atlasmart-cass-1 cqlsh -e "DESCRIBE KEYSPACE atlasmart_model"
On Windows, run these Docker commands from WSL/Git Bash or adapt
the existence checks to PowerShell. If three Cassandra JVMs are
too heavy for the machine, a single
cassandra:5.0.9 node with RF=1 is sufficient for
the chapter's query-shape exercises, but it is
not equivalent evidence for RF=3/QUORUM
behavior.
CONSISTENCY QUORUM;INSERT INTO atlasmart_model.products_by_id (product_id,name,category,price_cents,active,source_version) VALUES ('p-501','AtlasBook 14','laptops',129900,true,7);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',129900,'p-501','AtlasBook 14',true,7);SELECT product_id,name,price_cents,source_version FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT product_id,name,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';
Now inject a realistic partial dual-write failure: update only the ID projection to source version 8. This is safe because the fixture is disposable and the reset is deterministic.
UPDATE atlasmart_model.products_by_id SET price_cents=119900,source_version=8 WHERE product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';
The category table’s primary key includes
price_cents. A price change is a key move, not an
in-place clustering-key update. The projection workflow must
write the new row and delete the old row using the old key.
That design consequence comes directly from query-first
clustering.
INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',119900,'p-501','AtlasBook 14',true,8);DELETE FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=129900 AND product_id='p-501';SELECT product_id,price_cents,source_version FROM atlasmart_model.products_by_category WHERE category='laptops' AND price_cents=119900 AND product_id='p-501';
4. Wrong approach: uncontrolled dual writes
Writing table A and then table B from an HTTP handler without an idempotency key, source version, retry classification or reconciler makes “denormalization” synonymous with silent inconsistency. Cassandra does not provide cross-table foreign-key integrity that repairs this for you. Logged batches are not a generic replacement for an application consistency design; batch semantics are taught later and have partition/topology costs.
A safer pattern identifies one authoritative business change, assigns a stable event/version, makes each projection update idempotent, records success/failure, retries only when semantics allow it and periodically reconciles projections against the authority. The exact implementation may be synchronous application code, an outbox/event pipeline or a streaming processor; ownership and observability are mandatory either way.
5. When a new table is justified
| Question | Same table may work | New read shape likely needed |
|---|---|---|
| Partition address | same partition-key values known | different lookup key |
| Order | compatible clustering order/range | different primary sort requirement |
| Bound | same bounded partition | query would touch many partitions |
| Payload | same fields are acceptable | large payload/retention shape differs materially |
| Update cost | fan-out remains owned/observable | projection count makes consistency/ops unacceptable |
Check your understanding
- What is write fan-out?
- Why did the price change require insert plus delete in products_by_category?
- Does RF=3 mean the application should issue three INSERTs?
- What makes projection retries safer?
- When can multiple queries share one table?
Review the answers
1. The number of materialized query projections an application/business mutation must update; it is separate from Cassandra replication factor.
2. price_cents is part of that table’s primary key, so changing it moves the row to a different clustering position/key.
3. No. The application issues the logical CQL write; Cassandra replicates it to natural replicas according to RF and CL.
4. A stable event/idempotency identity, explicit source version, classified errors and writes that can be replayed without corrupting state.
5. When they share a compatible partition address, clustering order, result bound and payload/retention model.
Production judgment
A Cassandra table is an operational commitment, not just a schema object. Before approving the read shape, record expected partition cardinality and byte growth, retention/TTL behavior, read and write rates, tail-latency objectives, RF/CL, failure domains, write fan-out, retry/idempotency rules, reconciliation ownership, compaction and tombstone consequences, repair/backup requirements, observability signals, and the migration/rollback path. The same denormalization that removes a read-time join can multiply writes, storage, repair traffic and opportunities for projection drift.
Do not derive universal size or latency thresholds from this local lab. Production decisions require measurements with the real key distribution, payloads, concurrency, disk/network/JVM behavior and failure modes. SAI/vector features are intentionally not used to rescue a poor primary access model here; those mechanisms have their own later chapters and costs.
Lesson 3 uses the same idea to eliminate read-time joins and broad cross-partition scans by precomputing the shape the application actually renders.
Summary and next bridge
Denormalization is an exchange: predictable single-table reads in return for write fan-out, storage duplication and projection-consistency work. A source-version field and deterministic reconciliation make that exchange observable. Next, AtlasMart redesigns a join-heavy order view so the user-facing read is precomputed rather than reconstructed across arbitrary partitions.
Authoritative references
- Apache Cassandra downloads — release baseline; 5.0.9 is the current GA 5.0 patch at chapter generation time.
- Data modeling introduction — official query-driven modeling, partition-minimization and denormalization guidance.
- RDBMS design versus Cassandra design — official join, referential-integrity and denormalization contrasts.
- CQL data definition — primary-key, partition-key and clustering-order syntax used by the chapter tables.
- CQL data manipulation — SELECT restrictions and single-table query semantics.
- Unified Compaction Strategy — current Cassandra guidance for the explicit lab compaction baseline.