Skip to content
All projects

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.

Data Engineer — PySpark development, Delta optimisation, governance 4 months
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.

  1. L1

    Bronze

    Auto Loader ingests raw JSON with schema evolution; adds file path and ingestion timestamp. Append-only.

  2. L2

    Silver

    Typed, deduplicated, conformed events via foreachBatch MERGE; data expectations enforced.

  3. L3

    Gold

    Business aggregates — product performance, funnel, cohort — with liquid clustering.

  4. L4

    Governance

    Unity Catalog grants by group, column masks on PII, automatic lineage.

  5. L5

    Serving

    Databricks SQL Warehouse for Power BI and analysts.

04

Source systems

SystemTechnologyVolumeCadence
Web & app eventsJSON files in ADLS Gen2~25M events / dayContinuous landing
OrdersPostgreSQL CDC export~120K orders / dayEvery 15 min
Product catalogueREST API~80K SKUsHourly
Marketing spendCSV exports6 channelsDaily

05

Data flow

  1. 01Auto Loader

    New files discovered incrementally; schema inferred and evolved safely.

  2. 02Bronze append

    Raw payload stored with lineage columns for replay and audit.

  3. 03Silver MERGE

    foreachBatch deduplicates on event_id and upserts typed records.

  4. 04Gold refresh

    INSERT … REPLACE WHERE rebuilds only the affected recent dates.

  5. 05Maintain

    Predictive optimisation and OPTIMIZE keep file sizes healthy.

  6. 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

Incremental gold table with REPLACE WHERE (Databricks SQL)
sql
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

Auto Loader to bronze, idempotent MERGE to silver (PySpark)
python
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

MetricBeforeAfterChange
Daily compute costBaseline−38% −38%
Gold data freshness24 h1 h 24× fresher
Funnel query (P95)52 s6 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
Next case studyReal-Time Analytics Platform