Chapter 06 · Query-First Data Modeling and Denormalization
Start from Application Queries, Ordering, Cardinality, Retention, and Consistency
Turn AtlasMart request contracts into partition-addressable Cassandra read shapes before schema decisions become expensive.
Learning outcomes
AtlasMart’s team has an entity diagram—products, customers, orders and events—but Cassandra cannot infer efficient access paths from that diagram. The modeling input is the actual request contract: which values are known at query time, which ordering is required, how many rows can accumulate together, how long data lives, what consistency is needed and what latency envelope the application is trying to protect.
Turn exact application queries into a query inventory before writing CQL.
Choose partition and clustering keys from known lookup values, required order and bounded growth.
Separate declared latency/consistency/retention targets from measurements actually captured in the lab.
Explain why a convenient entity table can be wrong when the application needs a different access path.
Map each important AtlasMart read to an explicit materialized read shape and identify its write fan-out consequence.
-
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. Begin with query contracts, not nouns
Relational modeling commonly begins with normalized entities and relationships. Cassandra modeling reverses the direction: the table shape is chosen to make a known request local and ordered. A query inventory records the values supplied by the caller, the predicates, ordering, result bound, cardinality growth, retention, consistency level and service-level objective (SLO) that matter to that request. An SLO is a target; it is not proof that the local lab meets production latency.
| ID | Application query | Ordering / bound | Consistency / retention input | Candidate read shape |
|---|---|---|---|---|
| Q1 | Fetch product p-501 by ID |
one row | QUORUM; product lifecycle | products_by_id |
| Q2 | List active laptops in a category by price | price ascending; category must remain bounded | QUORUM; catalog lifecycle | products_by_category |
| Q3 | Show a customer’s newest orders for one month | ordered_at descending; month bucket | QUORUM; order retention policy | orders_by_customer_bucket |
| Q4 | Show one order’s event timeline | event time ascending; one order | QUORUM; audit/event retention | order_events_by_order |
Notice that “orders” is not yet a table design. “Newest orders for customer C in month M, newest first, bounded to one month” is a designable request. The month bucket is not decorative: it constrains partition growth and gives the caller a deterministic partition key.
2. Primary-key decisions encode distribution and order
In
PRIMARY KEY ((customer_id, order_month), ordered_at,
order_id), the composite partition key
(customer_id, order_month) determines which
partition is addressed and therefore which token/replicas own
the data. The clustering columns
ordered_at, order_id organize rows within that
partition. CLUSTERING ORDER BY defines on-disk/read
order; it is not a request to globally sort arbitrary
partitions.
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, 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_events_by_order ( order_id text, event_at timestamp, event_id timeuuid, event_type text, detail text, PRIMARY KEY ((order_id), event_at, event_id)) WITH CLUSTERING ORDER BY (event_at ASC, event_id ASC) AND compaction = {'class':'UnifiedCompactionStrategy'};
The schema deliberately duplicates product attributes between
products_by_id and
products_by_category. That is not an accidental
violation of normalization—it is the cost of serving Q1 and Q2
as direct read shapes. Lesson 2 turns that cost into an explicit
write-fan-out budget.
3. Reproducible AtlasMart 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,1);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('laptops',129900,'p-501','AtlasBook 14',true,1);INSERT INTO atlasmart_model.orders_by_customer_bucket (customer_id,order_month,ordered_at,order_id,status,total_cents) VALUES ('c-17','2026-09-01','2026-09-07T10:00:00Z','o-9001','PAID',129900);INSERT INTO atlasmart_model.orders_by_customer_bucket (customer_id,order_month,ordered_at,order_id,status,total_cents) VALUES ('c-17','2026-09-01','2026-09-07T11:00:00Z','o-9002','PACKING',4599);SELECT product_id,name,price_cents FROM atlasmart_model.products_by_id WHERE product_id='p-501';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;
Record actual output. Then enable TRACING ON for
one partition-key read and save the trace. The trace proves
which coordinator/replicas participated for that request; it
does not establish production capacity or a general latency
guarantee.
TRACING ON;CONSISTENCY QUORUM;SELECT ordered_at,order_id,status,total_centsFROM atlasmart_model.orders_by_customer_bucketWHERE customer_id='c-17' AND order_month='2026-09-01'LIMIT 20;TRACING OFF;
4. Boundary case: cardinality and retention can invalidate an otherwise correct key
Suppose Q3 changes from “one month” to “all orders ever,” and
one marketplace customer can generate millions of orders.
Removing order_month would make the query
syntactically convenient but turns partition growth into an
unbounded operational risk. The correction is not a magic
cluster setting; it is a query/API decision about buckets,
pagination and retention. The caller may need to walk month
buckets intentionally.
Do not create one generic orders table keyed only
by order_id and assume Cassandra will later
scan/join/sort it efficiently for every customer timeline.
That starts from storage nouns and pushes the application’s
access problem into expensive cross-partition work.
5. Observable design review
| Decision | Evidence to collect | What would force a redesign? |
|---|---|---|
| customer-month bucket | rows/bytes per partition; months touched per request | bucket still grows too large or most reads span many buckets |
| category partition | category cardinality/skew; rows/bytes; hot-key request rate | a few categories dominate volume/traffic |
| clustering order | requested order and pagination behavior | application needs incompatible orderings |
| QUORUM | availability/latency under replica loss | business consistency/failure requirement differs |
Check your understanding
- Why does a query inventory include retention?
- Why is a month bucket part of the partition key?
- Does CLUSTERING ORDER BY globally sort all customer orders?
- What does a local trace prove?
- Why can Q1 and Q2 justify duplicate product fields?
Review the answers
1. Retention helps bound how much data can accumulate in a partition and determines whether time bucketing or TTL policy is part of the access design.
2. It keeps each customer-order partition bounded and gives the caller an explicit partition address for one month.
3. No. Clustering order applies within a partition; global ordering across partitions would require application orchestration or a different read shape.
4. It shows the observed coordinator/replica activity for that request in that environment, not production throughput or an SLO guarantee.
5. They are materially different read paths; duplicating the needed fields lets each query target a direct table instead of performing a join or scan at read time.
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 2 makes the duplication visible as a write-time fan-out budget and asks who owns keeping those read shapes consistent.
Summary and next bridge
Query-first modeling starts with exact reads, order, bounds, retention, consistency and latency objectives, then encodes those requirements into partition and clustering keys. The result is intentionally workload-specific. Next, the same product mutation will be written into multiple query tables so the cost of denormalization becomes measurable rather than rhetorical.
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.