The JSON Files That Became a Data Mart
A medium Data Modeling interview practice problem on DataDriven. Design the schema on the canvas: tables, keys and relationships, then get feedback on it.
- Domain
- Data Modeling
- Difficulty
- medium
- Seniority
- L5
- Asked in
- Amaris
Problem
A retail client delivers three daily JSON feeds from its e-commerce platform, every record keyed by a string source ID like 'ORD-20260401-0042', and wants them turned into a relational data mart whose joins run on the warehouse's own integer keys with the source IDs kept alongside. Orders arrive as {order_id, customer:{id, name, email}, line_items:[{product_id, qty, price}], status}; products as {product_id, name, categories:[{id, name, parent_id}]}, where parent_id chains categories up to three levels deep; and shipments as {shipment_id, order_id, address:{street, city, country}, status_history:[{status, timestamp}]}, where one order can span several shipments. Design the dimensional schema that captures every order, line item, product category, shipment, and shipment status transition.
Three semi-structured inputs. One queryable warehouse.
Worked solution and explanation
Why this problem exists in real interviews
This probes whether you can translate nested document structure into relational grain. The signal is whether you recognize that a JSON array inside a parent object is a child entity at a finer grain, and that naively mirroring the document produces repeating groups that destroy join performance.
Break down the requirements
Step 1: Unfold line_items into fact_order_lines
The line_items array is one row per item per order. Embedding line fields as columns on fact_orders would create a repeating group and break any per-product analysis.
Step 2: Unfold status_history into fact_shipment_events
Status history is a sequence of status transitions per shipment. Model it as fact_shipment_events at one-row-per-transition grain, each event carrying shipment_sk so transitions from different shipments of the same order never blur together. The shipment header itself, including its address object, lands in dim_shipment, which keeps order_sk so partial fulfillment stays first-class.
Step 3: Normalize customer and product as conformed dimensions
dim_customer and dim_product get surrogate keys; source IDs stay as natural keys. This lets the mart evolve without coupling to the source document shape.
Step 4: Model categories as a closure-style dimension
Nested category hierarchies flatten into dim_category with path and depth. A bridge table is a defensible alternative, but a denormalized path is cheaper for BI.
Step 5: Separate order header from order lines
fact_orders stores order-total and status, fact_order_lines stores per-product measures. The split prevents fan-out on queries that only need header counts.
The solution
Below is one conceptually sound approach. The grain of each child fact is derived directly from the corresponding JSON array.
- dim_customer: customer_sk BIGINT PK, customer_nk TEXT, email TEXT, country TEXT, created_at TIMESTAMP
- dim_product: product_sk BIGINT PK, product_nk TEXT, name TEXT, sku TEXT, list_price DECIMAL
- dim_category: category_sk BIGINT PK, product_sk BIGINT FK, path TEXT, leaf_name TEXT, depth INT
- fact_orders: order_sk BIGINT PK, order_nk TEXT, customer_sk BIGINT FK, order_ts TIMESTAMP, order_total DECIMAL, status TEXT
- fact_order_lines: order_line_sk BIGINT PK, order_sk BIGINT FK, product_sk BIGINT FK, quantity INT, unit_price DECIMAL, line_total DECIMAL
- dim_shipment: shipment_sk BIGINT PK, shipment_nk TEXT, order_sk BIGINT FK, street TEXT, city TEXT, country TEXT
- fact_shipment_events: shipment_event_sk BIGINT PK, shipment_sk BIGINT FK, event_ts TIMESTAMP, status TEXT, carrier TEXT
- dim_customer references fact_orders
- fact_orders references fact_order_lines
- dim_product references fact_order_lines
- dim_product references dim_category
- fact_orders references dim_shipment
- dim_shipment references fact_shipment_events
The analysis pattern
SELECT
p.name AS product,
c.path AS category_path,
SUM(ol.quantity) AS units_sold,
SUM(ol.line_total) AS gross_sales
FROM fact_order_lines ol
JOIN fact_orders o ON o.order_sk = ol.order_sk
JOIN dim_product p ON p.product_sk = ol.product_sk
JOIN dim_category c ON c.product_sk = p.product_sk
WHERE o.order_ts >= NOW() - INTERVAL '30 days'
AND o.status = 'fulfilled'
GROUP BY p.name, c.pathTrade-offs and alternatives
- Fully normalized star
- Child arrays unfold into their own facts. Writes are heavier (multiple inserts per document). Reads are cheap and each dimension evolves independently. Best when downstream BI drives the workload.
- Document-preserving OBT
- One wide fact stores JSON payloads as semi-structured columns. Ingestion is a single insert. Reads pay the cost of JSON parsing every query. Best when query patterns are exploratory and storage is cheap.
Common follow-up questions
- What if a product moves between categories over time? How does `dim_category` change? (Tests whether the candidate considers Type 2 SCD on category assignment.)
- How would you backfill `fact_shipment_events` from a source that now emits `status_history` in a different shape? (Tests schema evolution and ingestion re-processability.)
- What is the grain of `dim_category` and why is `depth` stored denormalized? (Tests understanding of closure tables vs materialized path trade-offs.)
- How does the mart handle a JSON payload missing `line_items`? (Tests late-arriving or malformed data handling.)
- If the source publishes an order update two days late, which tables are touched? (Tests idempotent upsert strategy on `fact_orders` and `fact_order_lines`.)