Chapter 06 · Query-First Data Modeling and Denormalization

Redesign a Normalized Relational Model into Cassandra Query Tables and Explain the Tradeoffs

Translate a normalized relational domain into workload-specific Cassandra query tables without losing business invariants or rollback options.

Intermediate90–120 minutesNormalized-to-Cassandra redesign + migration labApache Cassandra 5.0.9 · cqlsh/nodetool · UCSLast reviewed: September 2026

Learning outcomes

AtlasMart’s starting relational model has customers, products, orders, order_items and inventory with foreign keys and joins. The goal is not to translate those tables one-for-one. The goal is to preserve business invariants while deriving Cassandra tables from the application’s bounded reads, then document every duplication and operational consequence.

01

Transform a normalized entity model into a query inventory and Cassandra read-shape map.

02

Choose partition/clustering keys that satisfy known lookup values, order and bounds.

03

Calculate duplicated fields and write fan-out introduced by the redesign.

04

Define reconciliation, backfill, shadow-read and rollback controls for migration.

05

Defend where Cassandra fits—and where a relational system may remain the better source of truth.

Version and lab contract
  • 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, cluster atlasmart-course, one DC dc1, racks rack1..rack3, 16 vnodes per node, Docker network atlasmart-cassandra.
  • Replication / consistency: chapter keyspace atlasmart_model, NetworkTopologyStrategy, RF=3 in dc1; examples use QUORUM unless a different level is stated next to the operation.
  • Storage: chapter tables explicitly use UnifiedCompactionStrategy (UCS); no chapter-specific gc_grace_seconds or 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.
Execution and safety note

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. Start with the relational model, then refuse to copy it literally

Normalized table Typical relational role Why a one-for-one Cassandra copy is insufficient
customers customer entity does not answer order-history access by customer/time
orders order header + customer FK customer timeline would need index/scan/join choices
order_items line items + order/product FKs detail page would need join to products for display names/prices
products product entity category browsing needs different partition/order
inventory product/store relation availability view needs product-local rows

Foreign-key relationships express integrity in the relational system. Cassandra has no foreign-key enforcement or cascading operations. If that integrity is business-critical, decide whether the relational system remains authoritative, whether the application enforces it, or whether the workflow can tolerate asynchronous projections. Do not silently discard the invariant during migration.

2. Derive query tables from the user journeys

Journey/query Cassandra table Primary key Duplicated display fields
product detail by ID products_by_id ((product_id)) name/category/price/active
category browse by price products_by_category ((category),price_cents,product_id) name/active
customer monthly order history orders_by_customer_bucket ((customer_id,order_month),ordered_at,order_id) status/total/item_count
order detail order_details_by_order ((order_id),line_no) product_name/unit_price/status
inventory for one product inventory_by_product ((product_id),store_id) available/updated_at
sql · implement the redesign core
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.orders_by_customer_bucket (customer_id text,order_month date,ordered_at timestamp,order_id text,status text,total_cents bigint,item_count int,source_version bigint,PRIMARY KEY ((customer_id,order_month),ordered_at,order_id)) WITH CLUSTERING ORDER BY (ordered_at DESC,order_id ASC) AND compaction={'class':'UnifiedCompactionStrategy'};CREATE TABLE atlasmart_model.order_details_by_order (order_id text,line_no int,product_id text,product_name text,quantity int,unit_price_cents int,status text,source_version bigint,PRIMARY KEY ((order_id),line_no)) WITH 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'};

3. Reproducible migration slice

bash · standalone disposable Chapter 06 baseline
# 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.

The lab migrates one order and one product—not because that volume is representative, but because the transformation is deterministic and inspectable. In a real migration, backfill throughput, partition distribution, retry behavior, source change capture and cutover windows must be measured separately.

sql · materialize one product and one order into query tables
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,100);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',129900,'p-501','AtlasBook 14',true,100);INSERT INTO atlasmart_model.orders_by_customer_bucket (customer_id,order_month,ordered_at,order_id,status,total_cents,item_count,source_version) VALUES ('c-17','2026-09-01','2026-09-07T10:00:00Z','o-9001','PAID',129900,1,55);INSERT INTO atlasmart_model.order_details_by_order (order_id,line_no,product_id,product_name,quantity,unit_price_cents,status,source_version) VALUES ('o-9001',1,'p-501','AtlasBook 14',1,129900,'PAID',55);SELECT * FROM atlasmart_model.products_by_id WHERE product_id='p-501';SELECT product_id,name,price_cents FROM atlasmart_model.products_by_category WHERE category='laptops' LIMIT 20;SELECT ordered_at,order_id,status,total_cents FROM atlasmart_model.orders_by_customer_bucket WHERE customer_id='c-17' AND order_month='2026-09-01' LIMIT 20;SELECT line_no,product_id,product_name,quantity,unit_price_cents FROM atlasmart_model.order_details_by_order WHERE order_id='o-9001';

4. Document the costs that moved

Concern Relational design Cassandra query-table design Owner/control
joins read-time join engine fields prejoined/duplicated projection writer
referential integrity FK constraints/cascades not enforced across tables application/domain workflow
write amplification normalized row updates multiple query projections + RF fan-out metrics/capacity
read latency join/index plan dependent targeted bounded partitions partition metrics/tracing/SLOs
schema evolution migration + app compatibility new read shape/backfill/dual-read-write migration runbook
consistency drift transaction constraints possible projection divergence versioning/reconciliation
Wrong approach

Do not declare the redesign successful because five CQL tables were created. Acceptance requires representative partitions, correct query results, bounded growth, projection consistency, measured latency under realistic load, repair/backup implications, failure tests and an application cutover/rollback plan.

5. Migration and rollback sequence

  1. Freeze the query inventory and acceptance criteria.
  2. Create new Cassandra query tables additively.
  3. Backfill a bounded slice with source versions and checksums/counts.
  4. Run deterministic reconciliation against the authority.
  5. Shadow-read Cassandra and compare semantic results, not only row counts.
  6. Enable dual writes or event materialization with backlog/drift metrics.
  7. Shift a controlled traffic percentage; observe p50/p95/p99, errors, retries and partition hotspots.
  8. Retain the old read path and source data through the rollback window.
  9. Only after acceptance, retire old projections/contracts deliberately.

Check your understanding

  1. Why not migrate normalized tables one-for-one?
  2. Which business invariant may need an external authority?
  3. What does shadow reading validate?
  4. Why keep source_version in the migrated rows?
  5. When should AtlasMart keep a relational database?
Review the answers

1. Because Cassandra table design is driven by application queries; normalized entity tables usually do not provide the required partition address/order without joins or scans.

2. Foreign-key/referential integrity across entities, because Cassandra does not enforce it across tables.

3. That the new read shape returns semantically equivalent application results under real query inputs before it becomes authoritative.

4. It supports deterministic reconciliation, out-of-order protection and proof that projections reflect the intended source state.

5. When transactional relational invariants, ad-hoc joins/queries or operational economics are a better fit than Cassandra’s query-table/denormalization model for that workload.

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.

Chapter 07 now zooms in on the primary-key machinery used throughout these designs: composite partition keys, clustering columns, ordering and the boundary between a well-bounded partition and a hotspot.

Summary and next bridge

A successful Cassandra redesign does not preserve normalized tables; it preserves business behavior through workload-specific query tables. The gains are bounded, direct reads and predictable distribution. The costs are duplication, write fan-out, projection reconciliation, migration complexity and new operational capacity concerns. Chapter 07 makes the primary-key choices behind those tradeoffs explicit.

bash · chapter-only cleanup
# Remove only Chapter 06 data; preserve the reusable course cluster.docker exec atlasmart-cass-1 cqlsh -e "DROP KEYSPACE IF EXISTS atlasmart_model;"# Optional full course-lab reset (destructive only to the dedicated disposable lab):# docker rm -f atlasmart-cass-1 atlasmart-cass-2 atlasmart-cass-3# docker volume rm atlasmart-cass-1-data atlasmart-cass-2-data atlasmart-cass-3-data# docker network rm atlasmart-cassandra

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.