Analytical Data Modelling¶
Time: 60 minutes reading + 45 minutes exercise
Prerequisites: SQL joins, primary keys, batch vs stream
Outcomes: define a fact-table grain; prevent fan-out; choose an SCD strategy; model late-arriving changes.
Code review, 4:52 PM. A join between fct_order_item and payment_attempt just shipped, and this morning's GMV number is running 2.3x high. The SQL is clean — every join key exists, every column resolves, nothing errors.
Before you scroll to the diff, pick one: is the bug (A) a wrong join key, (B) a grain mismatch — payments join at attempt grain while orders are at item grain, so retries fan out the join — or (C) a missing filter on refunds?
It's (B), and no engine would have caught it: an engine cannot rescue an ambiguous grain. Before Spark, Iceberg, dbt, or ClickHouse, decide what one row means and which business changes must remain historically true.
Workload¶
E-commerce needs daily GMV by customer plan as the plan was when the order was placed. Orders mutate, refunds arrive later, and customers change plans.
Start with the consumer:
metric: net_gmv
grain: one order item
event time: order_created_at
corrections: refunds and cancellations restate the affected order
dimensions: customer, product, campaign
Grain before columns¶
Write this sentence before DDL:
One row in
fct_order_itemrepresents one sellable item on one accepted order.
Then enforce it with a key such as (order_id, order_item_id). If another table has one row per payment attempt, joining it directly to order items produces item × attempt fan-out. Aggregate payments to order grain first or model a bridge explicitly.
WITH payment_by_order AS (
SELECT order_id, sum(captured_amount) AS captured_amount
FROM payment_attempt
WHERE status = 'CAPTURED'
GROUP BY order_id
)
SELECT sum(i.net_amount), sum(p.captured_amount)
FROM fct_order_item i
JOIN payment_by_order p USING (order_id);
Always compare row counts and key uniqueness before and after a join.
Facts and dimensions¶
| Shape | Purpose | Examples |
|---|---|---|
| Transaction fact | One business event | order item, payment, shipment |
| Periodic snapshot | State at a regular interval | daily inventory balance |
| Accumulating snapshot | Milestones on one lifecycle | order placed→paid→shipped |
| Dimension | Descriptive context | customer, product, campaign |
| Bridge | Explicit many-to-many relation | order↔promotion |
Facts should contain additive measures where possible. Ratios and percentiles are not additive: retain numerator/denominator or mergeable sketches, not averages of averages.
Surrogate and natural keys¶
Natural keys identify the business object (customer_id). Surrogate keys identify a historical dimension version (customer_sk). Keep both. A warehouse-generated surrogate key must be stable across retries; use a deterministic lookup or merge, not an unguarded sequence in a replay.
Unknown and late dimensions need an explicit policy:
- Insert an “unknown” dimension row and restate later.
- Hold the fact until the dimension arrives, within a bounded SLA.
- Store the natural key on the fact so reconciliation remains possible.
Silent inner-join loss is never a policy.
Slowly changing dimensions¶
| Strategy | Meaning | Use |
|---|---|---|
| Type 1 | Overwrite current value | Corrections where history is irrelevant |
| Type 2 | New effective-dated row | “Plan at order time,” audit history |
| Type 3 | Retain limited previous value | Rare, fixed before/after comparison |
Type 2 invariant:
Intervals for one natural key must not overlap. Join facts using event time:
ON f.customer_id = d.customer_id
AND f.order_created_at >= d.valid_from
AND f.order_created_at < d.valid_to
This range join can be expensive. Resolve customer_sk during ingestion when correctness and latency allow, or maintain an engine-specific current dimension for serving.
Mutability and corrections¶
Do not confuse an immutable event log with a correct analytical fact. A cancellation is a new event, but the consumer may need one current order row. Pick one contract:
- Event fact: append every transition; derive current state.
- Current-state table: merge by key and ordering/version field.
- Ledger: append compensating entries; sum remains auditable.
Record event time, ingestion time, source transaction position, and stable event ID. “Latest Kafka arrival wins” is unsafe when replays interleave with live traffic.
How it fails¶
- A fact has no declared grain, so every consumer invents one.
- Many-to-many joins multiply money.
- Type 1 overwrite changes last quarter’s report.
- Overlapping Type 2 intervals match a fact twice.
- Late dimensions disappear through an inner join.
- Daily snapshots are summed across days as though they were transactions.
Practice the idea¶
Open the SCD Type-2 timeline explorer. Choose an event time first, predict which customer version should join, and only then move a validity boundary. This makes the half-open interval rule visible.
Check your understanding¶
Design orders, payments, refunds, and customer plan history. State the grain and key of every table. Then write how you calculate net GMV by the customer’s plan at purchase time.
Exit check
A defensible model has transaction facts at explicit grains, prevents payment×item fan-out, uses effective-dated customer history or a purchase-time plan key, and treats refunds as corrections or compensating facts. It states how late dimensions and replay are reconciled.
Next: CDC turns mutable source rows into ordered changes; Transformation Engineering builds this model repeatably.