Start by defining a canonical schema and then produce two read/write/transform pipelines: one that normalizes (flatten) items into a fact table for analytics, and a compact nested store for full event rehydration.Canonical incoming JSON schema (logical):- order_id, order_ts, customer: {customer_id, signup_ts, cohort}, items: [{item_id, qty, price, promotion:{promo_id, type, discount_pct}}], order_total, channelRecommended table schemas:1) orders_fact (one row per order)- order_id STRING (PK)- order_ts TIMESTAMP- customer_id STRING- order_total DECIMAL- channel STRING- item_count INT2) order_items_fact (denormalized: one row per item in order) — main analytics table- order_id STRING- order_ts TIMESTAMP- customer_id STRING- item_id STRING- qty INT- price DECIMAL- line_total DECIMAL (qty * price * (1 - discount))- promo_id STRING- promo_type STRING- discount_pct DOUBLE3) customers_dim- customer_id STRING (PK)- signup_ts TIMESTAMP- cohort STRING- last_order_ts TIMESTAMP- lifetime_value DECIMALWhy flatten items: queries like total revenue per item and promotion effectiveness require efficient filtering/aggregation by item_id and promo_id. A flattened parquet table with one row per item avoids expensive explode+aggregation at query time and leverages column pruning.Example Spark transformation (PySpark):python
from pyspark.sql import functions as F
from pyspark.sql.types import *
raw = spark.read.json("/raw/orders/*.json")
orders = raw.select(
F.col("order_id"),
F.to_timestamp("order_ts").alias("order_ts"),
F.col("customer.customer_id").alias("customer_id"),
F.col("order_total"),
F.size("items").alias("item_count"),
F.col("channel")
)
items = raw.select(
F.col("order_id"),
F.to_timestamp("order_ts").alias("order_ts"),
F.col("customer.customer_id").alias("customer_id"),
F.explode("items").alias("item")
).select(
"order_id","order_ts","customer_id",
F.col("item.item_id").alias("item_id"),
F.coalesce(F.col("item.qty"), F.lit(1)).alias("qty"),
F.col("item.price").cast("decimal(10,2)").alias("price"),
F.col("item.promotion.promo_id").alias("promo_id"),
F.col("item.promotion.type").alias("promo_type"),
F.col("item.promotion.discount_pct").cast("double").alias("discount_pct")
).withColumn("line_total", F.expr("qty * price * (1 - coalesce(discount_pct,0))"))
Partitioning & File format:- Store as Parquet (columnar, predicate pushdown, efficient compression).- Partition orders and items by event date (order_date = date(order_ts)) and optionally by env (region/channel) if queries often filter by it.- Use bucketing on item_id (or Z-Ordering in Databricks) for heavy item-level joins/aggregations to reduce shuffle.- Compact small files with periodic compaction jobs (Delta Lake or Hudi recommended for upserts/time travel).Indexes & optimizations:- Use an MV (materialized view) or pre-aggregated table for daily item revenue: item_revenue_by_day(item_id, order_date, revenue, units, promo_id).- Maintain customers_dim by incremental jobs: update last_order_ts and lifetime_value (sum of line_total) using aggregations on order_items_fact.Trade-offs:- Flattening increases storage slightly but dramatically speeds analytics and simplifies joins/filters.- Keeping nested JSON in a raw zone is useful for auditing; keep both raw (as JSON/Parquet nested) and curated flattened tables.- Use CDC/upserts (Delta/Hudi/Iceberg) if you need corrections.This design supports fast revenue-per-item, promo effectiveness (group by promo_id, compare lift), and customer LTV (aggregate line_total by customer), while preserving raw nested events for reprocessing.