Education › Data › Stage 4: Operate

Capstone: build a lakehouse pipeline

Ingest, model, test, orchestrate and monitor an end-to-end pipeline from raw events to a dashboard.

Advanced ~40 min read Module 16 of 16

Fifteen modules have given you the pieces: SQL and modelling, Python and storage, batch and orchestration, dbt and quality, warehouses, Spark, streaming and CDC, observability, governance and platform infrastructure. The capstone assembles them into one lakehouse pipeline you build from scratch and can put in front of an interviewer or a stakeholder: raw events landing in object storage, an idempotent batch pipeline, a table-format silver layer, dbt marts with tests and documentation, an orchestrated schedule with freshness SLAs, governance tags with masking, and a dashboard reading the result — all provisioned by Terraform and deployed by CI. Nothing new is introduced; what is built is the judgement to make the pieces fit, and the evidence that they do.

After this module you can
  • Design a lakehouse pipeline end to end from a written set of business questions
  • Implement ingestion, an idempotent batch layer, a table-format silver layer and dbt marts with tests
  • Orchestrate it with data-aware scheduling, freshness SLAs and alerting
  • Apply governance: ownership, PII tags, masking, retention, and a record of processing
  • Deliver a dashboard with a trust panel and a written design document with evidence

The brief and the deliverables

Pick a domain with events and entities: an online shop (orders, customers, products, page views), a ride service (trips, drivers, riders), a SaaS product (accounts, subscriptions, usage events). Write ten business questions first, in plain words, at least three of which need history ("revenue by customer country as it was at order time") and at least one of which needs recent data ("orders in the last hour"). The questions drive the model, the model drives the pipeline, and the pipeline drives the platform — never the other way around. Generate realistic synthetic data with a script you commit: a few million events over a year, late arrivals, corrections, deletes, and a slowly changing customer table.

DeliverableWhereModules it draws on
DESIGN.md: questions, grain decisions, star schema, layer diagram, SLAsrepo rootModelling, storage, observability
Terraform: bucket with lifecycle, catalog, warehouse or DuckDB setup, identities, budgetinfra/Platform infrastructure, cloud security
Ingestion + bronze: idempotent, watermarked, quarantining loaderpipelines/Python, batch design, quality
Silver: Iceberg (or Delta) tables with MERGE, deletes, compactionpipelines/Storage, CDC patterns
dbt project: staging, marts, SCD2 snapshot, tests, docs, exposuresdbt/dbt, modelling, quality
Orchestration: DAGs with datasets, retries, SLAs, backfill testeddags/Orchestration
Observability: run telemetry, freshness alerts, lineage, trust panelops/Observability
Governance: owners, PII tags, masking policies, retention job, erasure jobdbt/ + governance/Governance
CI/CD: lint, tests, slim build, DAG parse, deploy.github/workflows/Platform infrastructure
Dashboard answering the ten questions, with a trust panelBI tool or a notebookWarehouse
Note

Budget three to four focused days. Everything runs locally with DuckDB, MinIO or a local folder as object storage, a local Airflow and dbt-duckdb; a cloud account makes it more realistic but is optional. The design document is not optional.

Phase 1: model before you move anything

From the questions, declare the facts and their grains (one row per order line; one row per page view), the dimensions and which attributes are Type 2 (customer country, plan), the conformed date dimension, and the one definition of each headline metric. Draw the layer diagram: raw (immutable files by arrival date), bronze (typed Parquet by event date), silver (table-format entities and events, deduplicated, with deletes applied), gold (dbt marts), and where each of the ten questions is answered. Write all of it into DESIGN.md before writing a line of pipeline code; the document is what you will check the implementation against.

The core of a design document, one screen long. Every later decision refers back to it.
text
QUESTIONS (10)                       ANSWERED BY
  Q1 daily revenue by country        fct_orders x dim_customer (SCD2, country as of order)
  Q2 orders in the last hour         silver.orders (freshness SLA 15 min) via a direct query
  Q3 repeat-purchase rate by cohort  fct_orders + dim_customer.signup_month
  ...

FACTS                                GRAIN                      MEASURES
  fct_orders                         one row per order line     quantity, unit_price, line_amount
  fct_page_views                     one row per page view      duration_ms

DIMENSIONS                           SCD                        NOTES
  dim_customer                       type 2: country, segment   surrogate customer_key
  dim_product                        type 1                     category, brand
  dim_date                           n/a                        generated 2020-2030

METRICS (one definition each)
  revenue = SUM(line_amount) FROM fct_orders WHERE status IN ('paid','shipped')

LAYERS
  raw/     jsonl.gz by arrival date, immutable, 730-day retention
  bronze/  parquet by event_date, typed, quarantine table for rejects
  silver/  iceberg: orders, order_items, customers (deletes applied, last_lsn guard)
  gold/    dbt marts, tested, documented, tier-1 SLA 06:00 UTC

LATENESS: events up to 3 days late -> 3-day trailing reprocess; later -> manual backfill
PII: customer.email (direct, hash), customer.dob (indirect, year only), addresses (direct, restricted)

Phase 2: raw to silver, idempotently

Build the loader as the Python and batch modules described: a --day parameter, an incremental extract with a persisted watermark and overlap, pydantic validation with a quarantine table and a reject-rate threshold, Parquet output to bronze partitioned by event date, and a MERGE into silver Iceberg tables keyed on the natural key with a version guard, including deletes. Run it twice for the same day and prove the row counts do not change. Then simulate three kinds of trouble — a crash mid-load, a late batch for a day already processed, and a corrected record — and prove each is handled by the trailing-window reprocess and the merge.

The test that proves idempotency, which the rest of the capstone depends on. It runs in CI against a small fixture.
python
from datetime import date

import duckdb

from pipelines.orders import run


def test_rerun_is_idempotent(tmp_path, fixture_events):
    lake = tmp_path / "lake"
    run(day=date(2026, 3, 1), lake=lake, source=fixture_events)
    first = duckdb.sql(f"SELECT COUNT(*), SUM(amount) FROM read_parquet('{lake}/bronze/orders/**/*.parquet')").fetchone()

    run(day=date(2026, 3, 1), lake=lake, source=fixture_events)   # same day again
    second = duckdb.sql(f"SELECT COUNT(*), SUM(amount) FROM read_parquet('{lake}/bronze/orders/**/*.parquet')").fetchone()

    assert first == second, f"rerun changed the data: {first} -> {second}"


def test_late_event_lands_in_its_own_partition(tmp_path, fixture_events_with_late):
    lake = tmp_path / "lake"
    run(day=date(2026, 3, 4), lake=lake, source=fixture_events_with_late, lookback_days=3)
    rows = duckdb.sql(
        f"SELECT event_date, COUNT(*) FROM read_parquet('{lake}/bronze/orders/**/*.parquet') GROUP BY 1 ORDER BY 1"
    ).fetchall()
    assert (date(2026, 3, 2), 1) in rows          # the late March 2nd event is in the March 2nd partition

Add compaction and snapshot expiry as a weekly maintenance task, and a reconciliation query that compares silver row counts and a checksum against the synthetic source for a window. Record every run in the telemetry table with rows in, out and rejected.

Phase 3: marts, tests and schedule

Build the dbt project over silver: staging models per silver table, an SCD2 snapshot of customers (or use the CDC-driven history), the dimensions and facts from the design, and the metric models. Add the standard column tests, the SCD2 invariants (one current row per key, no overlapping validity), a seasonal volume test and a relationships test between facts and dimensions. Document grain and metric definitions on the marts and declare the dashboard as an exposure. Then orchestrate: the loader DAG produces a dataset; the dbt DAG is scheduled on it; both have retries, timeouts, an on-failure callback and a freshness SLA. Backfill one week through the orchestrator and verify the marts.

The dbt YAML that carries tests, SLA and governance for the main fact in one place, as the earlier modules designed it.
yaml
models:
  - name: fct_orders
    description: One row per order line, non-cancelled. revenue = SUM(line_amount) WHERE status IN ('paid','shipped').
    meta:
      owner: data-platform@acme.example
      product_tier: 1
      classification: internal
      sla: {freshness: "by 06:00 UTC daily", completeness: "±30% same-weekday baseline"}
    columns:
      - name: order_id
        tests: [not_null]
      - name: line_no
        tests: [not_null]
      - name: customer_key
        tests:
          - not_null
          - relationships: {to: ref('dim_customer'), field: customer_key}
      - name: line_amount
        tests:
          - dbt_utils.accepted_range: {min_value: 0, inclusive: true}
    tests:
      - dbt_utils.unique_combination_of_columns: {combination_of_columns: [order_id, line_no]}
      - dbt_utils.recency: {datepart: hour, field: loaded_at, interval: 8}

exposures:
  - name: revenue_dashboard
    type: dashboard
    owner: {name: Finance analytics, email: finance-analytics@acme.example}
    depends_on: [ref('fct_orders'), ref('dim_customer'), ref('dim_date')]

Phase 4: governance and the recent-data question

Tag every PII column, generate the masking policies (or masked views in DuckDB), implement row-level access for one attribute, and write the record of processing. Implement the retention job for raw files and the erasure job for one synthetic customer across silver, gold and snapshots, and prove with a query that the person is gone. Add the governance CI check: no model without an owner, no PII without a mask.

Then answer the question that needs recent data. Choose deliberately between a fifteen-minute micro-batch of the loader (simplest, usually enough) and a small streaming path — events to a topic, a windowed aggregation, a freshness SLA in minutes. Justify the choice in the design document in terms of the consumer's need and the operational cost. If you build the stream, make its output idempotent and its watermark explicit, and show a comparison against the batch result for the same window.

Watch out

The most common capstone failure is over-building: a Kafka cluster, a Spark job and a streaming aggregation for a question a 15-minute batch answers. Choosing the simpler tool, and writing down why, is a stronger demonstration of engineering than the more complex one.

Phase 5: platform, evidence and the write-up

Put the infrastructure in Terraform with a policy check in the plan, the repository under the CI workflow from the platform module, and production behind an approval. Build the dashboard that answers the ten questions from the marts, with a trust panel showing last update, SLA status and test results. Finally write the evidence section of DESIGN.md: for each claim (idempotent, handles late data, tested, governed, observable, deployed by CI) point at the test, the run record, the policy or the workflow that proves it, and include the reconciliation results and the backfill log. Add a residual-risks section: what is not handled, what you would do next with more time.

  • A reader with no context can run make demo (or the documented commands) and reproduce the pipeline on the synthetic data in under thirty minutes.
  • Every business question is answered by a named mart with a documented grain and metric definition.
  • Rerun, crash, late data, correction and delete scenarios each have a test or a recorded drill.
  • Freshness alerts fire when the schedule is skipped; the dashboard trust panel reflects it.
  • The erasure drill removes a customer from every layer including snapshots, with a log entry.
  • CI blocks: lint failures, failing tests, DAG parse errors, a public bucket, an untagged PII column.

When it is done you will have built and documented the full loop that the track kept returning to: model from questions, land raw immutably, transform idempotently, test what must be true, orchestrate on data readiness, watch freshness and lineage, govern the personal data, and keep all of it as reviewed, deployable code. That loop is the job, and the repository is the proof.

Hands-on practice

Complete the capstone

  1. Phase 1 (half a day): write the ten questions and DESIGN.md with grains, SCD choices, metric definitions, layers, lateness and PII decisions. Commit a data generator that produces a year of events with late, corrected and deleted records.
  2. Phase 2 (one day): implement the watermarked, quarantining loader to bronze and the MERGE to silver Iceberg tables with deletes; write the idempotency and late-event tests; add compaction, snapshot expiry and reconciliation.
  3. Phase 3 (one day): build the dbt project with staging, SCD2, dimensions, facts, tests, docs and an exposure; orchestrate loader and dbt DAGs with datasets, retries, SLAs and alerts; backfill one week and verify.
  4. Phase 4 (half a day): tag PII, generate masking and row policies, write the record of processing, implement retention and erasure jobs and prove erasure; decide and build the recent-data path with a written justification.
  5. Phase 5 (half a day): Terraform with policy checks, the CI/CD workflow with a production approval, the dashboard with a trust panel, and the evidence and residual-risk sections of DESIGN.md.
  6. Have someone else run the demo from the README and try to break one guarantee (rerun, late data, PII exposure). Fix what they find and record it.
  7. Present it in fifteen minutes: the questions, the model, the guarantees and their evidence, and what you would do next.
Cheat sheet

Capstone: build a lakehouse pipeline — at a glance

Main things to focus on

  • Questions drive the model, the model drives the pipeline, the pipeline drives the platform
  • Raw immutable; bronze typed by event date with quarantine; silver table-format with merges and deletes; gold as tested dbt marts
  • Every guarantee (idempotent, late data, tested, governed, observable, deployed) points at a test, a run record or a policy
  • Data-aware scheduling, retries, SLAs and alerts; backfills rehearsed
  • Governance from tags: masking, row policies, retention, erasure including snapshots
  • Choose the simpler tool for recent data and write down why; over-building is the common failure

Repository layout

DESIGN.md: questions, grains, metrics, layers, lateness, PII, evidence, residual risksThe document everything checks against
generate/: synthetic data with late, corrected, deleted recordsReproducible input
pipelines/: loader (--day, watermark, quarantine), merge to silver, maintenanceRaw to silver
dbt/: staging, snapshots, marts, tests, docs, exposuresSilver to gold
dags/: loader DAG -> dataset -> dbt DAGOrchestration
infra/, .github/workflows/, governance/, ops/Platform, CI/CD, policies, telemetry

Guarantees and their evidence

test_rerun_is_idempotentSame counts and sums after a second run
test_late_event_lands_in_its_own_partitionTrailing-window reprocessing works
dbt build in CI + prod; SCD2 invariant testsWhat must be true is checked
ops.pipeline_runs + freshness alert fired in a drillObservable
erasure_log + time-travel query returning nothingGoverned
CI blocks on lint/tests/DAG parse/public bucket/untagged PIIDeployed safely

Decisions to justify

batch every 15 min vs streamConsumer need and operational cost
warehouse vs lakehouse engine for goldTeam, tools, scale
SCD2 attributesWhat the business analyses over time
lookback window lengthObserved lateness distribution
product tiers and SLAsWhere guarantees are worth the cost

Demo checklist

make demo reproduces the pipeline in < 30 minReproducibility
10 questions -> named marts with grain and definitionsModel completeness
rerun / crash / late / correction / delete drillsPipeline robustness
trust panel on the dashboardConsumer-visible health
residual risks statedJudgement

Common pitfalls

  • Starting with tools instead of questions, then modelling around what the tools produced.
  • A loader that works once and duplicates on the second run; everything downstream inherits it.
  • Marts without tests, so the demo works until the data generator is changed.
  • Streaming, Kafka and Spark for a question a small batch answers.
  • Governance skipped 'because it is synthetic data'; the point is to prove the mechanism.
  • A design document written after the fact that describes what was built instead of what was decided.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →