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.
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.
Transform a normalized entity model into a query inventory and Cassandra read-shape map.
Choose partition/clustering keys that satisfy known lookup values, order and bounds.
Calculate duplicated fields and write fan-out introduced by the redesign.
Define reconciliation, backfill, shadow-read and rollback controls for migration.
Defend where Cassandra fits—and where a relational system may remain the better source of truth.
-
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. 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 |
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
# 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.
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 |
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
- Freeze the query inventory and acceptance criteria.
- Create new Cassandra query tables additively.
- Backfill a bounded slice with source versions and checksums/counts.
- Run deterministic reconciliation against the authority.
- Shadow-read Cassandra and compare semantic results, not only row counts.
- Enable dual writes or event materialization with backlog/drift metrics.
- Shift a controlled traffic percentage; observe p50/p95/p99, errors, retries and partition hotspots.
- Retain the old read path and source data through the rollback window.
- Only after acceptance, retire old projections/contracts deliberately.
Check your understanding
- Why not migrate normalized tables one-for-one?
- Which business invariant may need an external authority?
- What does shadow reading validate?
- Why keep source_version in the migrated rows?
- 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.
# 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
- 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.