Case study 04 · E-commerce
Databricks Medallion Architecture
Bronze, silver and gold Delta Lake layers on Databricks with Auto Loader, Unity Catalog and liquid clustering.
- Events / day
- ~25M
- Compute cost
- −38%
- Gold freshness
- 24h → 1h
01
Executive summary
Delivered a medallion lakehouse on Azure Databricks for clickstream and order data. Auto Loader ingests JSON events incrementally into bronze, PySpark streams cleanse and deduplicate into silver, and gold tables power product, funnel and marketing analytics — all governed in Unity Catalog.
02
Business problem
Clickstream data was processed by a nightly batch job that re-read the entire raw folder every run. Costs grew with data volume, analytics were a day behind, and nobody could say which tables were safe to use.
- Full reprocessing of raw files every night
- Duplicate events inflating conversion metrics
- Small-file problem slowing every downstream query
- No access control or lineage across 200+ tables
03
Architecture design
A Unity Catalog metastore with bronze, silver and gold schemas per domain; Databricks Workflows orchestrate incremental streaming jobs with availableNow triggers.
- L1
Bronze
Auto Loader ingests raw JSON with schema evolution; adds file path and ingestion timestamp. Append-only.
- L2
Silver
Typed, deduplicated, conformed events via foreachBatch MERGE; data expectations enforced.
- L3
Gold
Business aggregates — product performance, funnel, cohort — with liquid clustering.
- L4
Governance
Unity Catalog grants by group, column masks on PII, automatic lineage.
- L5
Serving
Databricks SQL Warehouse for Power BI and analysts.
04
Source systems
| System | Technology | Volume | Cadence |
|---|---|---|---|
| Web & app events | JSON files in ADLS Gen2 | ~25M events / day | Continuous landing |
| Orders | PostgreSQL CDC export | ~120K orders / day | Every 15 min |
| Product catalogue | REST API | ~80K SKUs | Hourly |
| Marketing spend | CSV exports | 6 channels | Daily |
05
Data flow
01Auto Loader
New files discovered incrementally; schema inferred and evolved safely.
02Bronze append
Raw payload stored with lineage columns for replay and audit.
03Silver MERGE
foreachBatch deduplicates on event_id and upserts typed records.
04Gold refresh
INSERT … REPLACE WHERE rebuilds only the affected recent dates.
05Maintain
Predictive optimisation and OPTIMIZE keep file sizes healthy.
06Serve
SQL Warehouse endpoints back Power BI and ad-hoc analysis.
06
Data model
Silver is modelled as clean, conformed entities; gold is modelled for consumption as aggregates and star schemas.
Facts
gold.daily_product_performance
Grain: One row per product per day
gold.funnel_sessions
Grain: One row per session
silver.events
Grain: One row per unique event
Dimensions
gold.dim_product
From catalogue API, SCD Type 2
gold.dim_channel
Marketing channel and campaign
gold.dim_date
Shared calendar
07
ETL process
Incremental by default
Checkpointed streams with availableNow triggers process only new data, then shut down to save cost.
Idempotent silver
MERGE on event_id makes reprocessing safe after a failure or a replay.
Targeted gold rebuilds
REPLACE WHERE overwrites only the last three days, absorbing late events cheaply.
Job clusters & policies
Workflows run on job clusters with cluster policies and spot instances for workers.
08
Tech stack
- Platform
- Azure DatabricksUnity CatalogWorkflows
- Processing
- PySparkAuto LoaderStructured StreamingSpark SQL
- Storage
- Delta LakeADLS Gen2Liquid clustering
- Serving
- Databricks SQL WarehousePower BI
- DevOps
- Databricks Asset BundlesGit foldersAzure DevOps
09
SQL example
CREATE TABLE IF NOT EXISTS lakehouse.gold.daily_product_performance (
event_date DATE,
product_id STRING,
sessions BIGINT,
add_to_cart BIGINT,
purchases BIGINT,
revenue DECIMAL(18, 2),
conversion_rate DOUBLE
)
CLUSTER BY (event_date, product_id);
-- Rebuild only the last 3 days so late-arriving events are absorbed
INSERT INTO lakehouse.gold.daily_product_performance
REPLACE WHERE event_date >= date_sub(current_date(), 3)
SELECT event_date,
product_id,
COUNT(DISTINCT session_id) AS sessions,
COUNT_IF(event_type = 'add_to_cart') AS add_to_cart,
COUNT_IF(event_type = 'purchase') AS purchases,
CAST(SUM(IF(event_type = 'purchase', amount, 0)) AS DECIMAL(18, 2)) AS revenue,
COUNT_IF(event_type = 'purchase')
/ NULLIF(COUNT(DISTINCT session_id), 0) AS conversion_rate
FROM lakehouse.silver.events
WHERE event_date >= date_sub(current_date(), 3)
AND product_id IS NOT NULL
GROUP BY event_date, product_id;
OPTIMIZE lakehouse.gold.daily_product_performance;10
Python example
from delta.tables import DeltaTable
from pyspark.sql import functions as F
CATALOG = "lakehouse"
RAW = "abfss://raw@stlakehouseprod.dfs.core.windows.net/app_events/"
CHK = "abfss://checkpoints@stlakehouseprod.dfs.core.windows.net/app_events"
# ── Bronze: incremental file ingestion with schema evolution
(spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation", f"{CHK}/schema")
.option("cloudFiles.schemaEvolutionMode", "addNewColumns")
.load(RAW)
.withColumn("_source_file", F.col("_metadata.file_path"))
.withColumn("_ingested_at", F.current_timestamp())
.writeStream
.option("checkpointLocation", f"{CHK}/bronze")
.trigger(availableNow=True)
.toTable(f"{CATALOG}.bronze.app_events")
.awaitTermination())
# ── Silver: typed, deduplicated, idempotent upsert
def upsert_events(batch_df, batch_id: int) -> None:
events = (batch_df
.where(F.col("event_id").isNotNull())
.select(
"event_id", "session_id", "event_type", "product_id",
F.to_timestamp("event_time").alias("event_ts"),
F.to_date(F.to_timestamp("event_time")).alias("event_date"),
F.col("amount").cast("decimal(18,2)").alias("amount"),
)
.dropDuplicates(["event_id"]))
(DeltaTable.forName(spark, f"{CATALOG}.silver.events").alias("t")
.merge(events.alias("s"), "t.event_id = s.event_id")
.whenNotMatchedInsertAll()
.execute())
(spark.readStream.table(f"{CATALOG}.bronze.app_events")
.writeStream
.foreachBatch(upsert_events)
.option("checkpointLocation", f"{CHK}/silver")
.trigger(availableNow=True)
.start()
.awaitTermination())11
Challenges
Challenge
Mobile clients retried requests, producing duplicate events and inflated conversion.
Solution
Deduplicated on event_id within each micro-batch and with a MERGE against silver, making the pipeline idempotent.
Challenge
Millions of small JSON files slowed both ingestion and queries.
Solution
Auto Loader with file notification mode for discovery, plus OPTIMIZE and liquid clustering on silver and gold.
Challenge
Analysts had broad access to tables containing email addresses.
Solution
Unity Catalog column masks on PII and group-based grants per schema, with lineage used to find every downstream copy.
12
Performance improvements
| Metric | Before | After | Change |
|---|---|---|---|
| Daily compute cost | Baseline | −38% | −38% |
| Gold data freshness | 24 h | 1 h | 24× fresher |
| Funnel query (P95) | 52 s | 6 s | −88% |
| Average file size (silver) | 1.8 MB | ~250 MB | Healthy |
13
Business impact
- Conversion metrics corrected by removing duplicate events (previously overstated by ~3%)
- Merchandising sees product performance hourly instead of the next day
- PII exposure removed for analyst groups without blocking their work
- Lower cost per processed event even as volume doubled
14
Lessons learned
- Incremental processing is a design decision, not an optimisation to add later.
- Idempotency is what makes streaming operable — make every write safe to replay.
- Unity Catalog lineage pays for itself the first time you need to answer 'where is this column used?'.
15
Success metrics
- Events processed / day
- ~25MBronze to gold
- Duplicate rate in silver
- 0%Enforced by MERGE on event_id
- Tables governed
- 200+Under Unity Catalog
- Job success rate
- 99.6%Workflows, trailing 90 days