Design Patterns
3 copies of the same data
| Layer | One row means | Promise | Who reads it |
|---|---|---|---|
| Bronze | one record exactly as the source delivered it | append only, never edited, never deleted | pipeline engineers replaying history |
| Silver | one cleaned record per real-world event or entity | typed columns, duplicates removed, bad rows quarantined | analysts and downstream builds |
| Gold | one answer per business question | shaped for a named consumer, arithmetic done once | dashboards and executives |
Each arrow is a scheduled job. Bronze stores the API responses as text, silver parses them, gold sums them by country and day.
What a bronze row holds
A bronze table adds columns about the delivery rather than the business. _ingested_at records when the row landed, _source_file records which file or API page it came from, and payload is the record as a single string. Nothing is parsed yet, because parsing is where mistakes happen, and bronze exists so mistakes can be undone.
| load_date | _ingested_at | _source_file | payload |
|---|---|---|---|
| 2026-10-20 | 2026-10-20 01:31:04 | page_0017.json | {"id":"ev_91","customer":"c_204","amount_cents":"4999","country":"fr","paid_at":"2026-10-20T00:58:11Z"} |
| 2026-10-20 | 2026-10-20 01:31:04 | page_0017.json | {"id":"ev_92","customer":"c_777","amount_cents":"1299","country":"DE","paid_at":"2026-10-20T01:02:40Z"} |
| 2026-10-20 | 2026-10-20 01:33:52 | page_0017.json | {"id":"ev_91","customer":"c_204","amount_cents":"4999","country":"fr","paid_at":"2026-10-20T00:58:11Z"} |
Row 1 and row 3 are the same event, delivered twice because the API retried a page. The country arrives as fr on one row and DE on another, and the amount is a string. All 3 problems are left in place. The silver build is where they get fixed, and it reads bronze every time it runs:
The inner query parses each field out of the JSON string, casts the amount to a whole number of cents, upper-cases the country, and numbers each event's copies from newest to oldest. The outer query keeps copy number 1, so ev_91 lands in silver once. If a later audit finds that countries should have been mapped through a lookup table, the fix is a change to this query and a rerun over bronze. The API is never called again.
Gold answers one question
- ▸Malformed or duplicated source records: the silver build, rerun over bronze.
- ▸A metric computed with the wrong rule: the gold build, rerun over silver.
- ▸Rows that should never have existed: bronze keeps them. Silver moves them to a rejects table with a reason column.
A finance analyst finds that gold_daily_revenue has counted refunds, which arrive as events with a negative amount_cents, as revenue for the last 90 days. Which change fixes both the past and the future?
Hubs, links and satellites
- Hubone row per business keyA business key is the identifier the business already uses: an order number, an email, a product code. The hub stores only that key, the first time it was seen, and the source that supplied it.
- Linkone row per relationship between hubsCustomer c_204 placed order 51833: that fact is a link row. A link carries the hash keys of the hubs it joins plus load time and source, and no descriptive attributes.
- Satelliteone row per change to a hub or linkNames, cities, plans, prices: every descriptive attribute lives in a satellite, stamped with when it loaded and which system said so. Rows are added, never updated.
customer_hk is a hash of the email. Link rows and satellite rows use that fixed-length value to refer to a customer, so the email is never repeated. Each satellite belongs to one source: sat_customer_crm holds what the CRM says about a customer and sat_customer_billing holds what the billing system says, and the 2 are never merged in storage. Here is the CRM satellite for 1 customer after 3 loads:
| customer_hk | load_ts | record_source | full_name | city | plan |
|---|---|---|---|---|---|
| 8f3a... | 2023-11-02 03:00 | crm | Ana Costa | Lisbon | basic |
| 8f3a... | 2024-02-14 03:00 | crm | Ana Costa | Porto | basic |
| 8f3a... | 2024-09-30 03:00 | crm | Ana Costa | Porto | pro |
Add WHERE load_ts <= TIMESTAMP '2024-03-01 00:00:00' to the inner query and the Porto, basic row becomes the newest. That is what the warehouse knew on 2024-03-01, because load_ts records when a change loaded. When Ana actually moved is the source's effective date, which belongs in the satellite as its own column. Every row also names the system that asserted it, so an auditor gets a source and a timestamp.
- New source: add a satellite, touch nothing else
- History: every load kept, as-of queries by load_ts
- Audit: record_source on every row
- Queries: 3 to 6 joins to assemble one customer
- New source: alter the table and edit everything that loads or reads it
- History: last value wins
- Audit: who said the city is Porto is unknown
- Queries: 1 table, written in a minute
Your company acquired a competitor. Their CRM holds 400,000 customers, and 60,000 of them already exist in your CRM under the same email. Their records carry attributes yours lack: industry and account manager. Finance also wants any customer's city as of a past date.
| customer_hk | customer_email | load_ts | record_source |
|---|
| customer_hk | load_ts | record_source | full_name | city | plan |
|---|
The acquired CRM's customer feed starts tomorrow. How does it enter the vault?
Every attribute beside the number it describes
| order_line_id | order_id | product_id | qty | amount_cents |
|---|---|---|---|---|
| L1 | O100 | P7 | 2 | 3998 |
| L2 | O100 | P9 | 1 | 1299 |
| L3 | O101 | P7 | 1 | 1999 |
| order_line_id | order_id | customer_region | category | product_name | amount_cents |
|---|---|---|---|---|---|
| L1 | O100 | EMEA | Audio | Headphones | 3998 |
| L2 | O100 | EMEA | Cables | USB-C cable | 1299 |
| L3 | O101 | APAC | Audio | Headphones | 1999 |
The row meaning is unchanged: one row per order line. What changed is that each line now carries customer_region and category alongside product_name, copied from their source tables at build time. Both rows for order O100 say EMEA, and that repetition is the point: the analyst reads 1 table.
An order-level column on an order-line table
| order_line_id | order_id | shipping_fee_cents | amount_cents |
|---|---|---|---|
| L1 | O100 | 500 | 3998 |
| L2 | O100 | 500 | 1299 |
| L3 | O101 | 700 | 1999 |
SUM(shipping_fee_cents) over these 3 rows gives 1700, and the true total is 1200. SUM(DISTINCT shipping_fee_cents) happens to return 1200 here, which makes it worse: if order O102 also charges 500, the true total becomes 1700 while DISTINCT still returns 1200 with no sign of error. The fix is orders_wide, one row per order with the fee on it. A column whose row meaning differs from its table's belongs in a different table.
Which moment is the region from?
Say a customer moved from Lisbon, in EMEA, to Singapore, in APAC, in March 2026. Their January orders in order_lines_wide show EMEA if the table stores the region at order time, and APAC if it's rebuilt nightly from the current customer record. Both are valid, and they answer different questions: where the customer lived when they ordered, versus where the customer lives now. Pick one per column and write it in the column description. If both are needed, store them as 2 columns named region_at_order and region_current.
- Name the table after its row meaning: order_lines_wide, orders_wide, sessions_wide.
- Build it from silver so parsing and deduplication happen once, upstream.
- Document for each copied column which moment in time it reflects.
- Put an order-level amount on an order-line table and hope nobody sums it.
- Hand-maintain a wide table; rebuild it from upstream on every run.
- Build one wide table per dashboard; build one per row meaning.
Analysts run this on order_lines_wide, where one row is one order line. Which statement is true?
The metric written once
| Dashboard | Rule used | Count |
|---|---|---|
| Marketing | any order in the trailing 30 days | 41,200 |
| Finance | paid order in the trailing 30 days, refunds excluded | 38,950 |
| Product | any session in the trailing 28 days | 97,300 |
A definition names the metric, the table it reads, the expression that computes it, the filter that scopes it, and the breakdowns a user may group by. When the finance tool asks for active_customers by customer_region, the layer writes the SQL:
The marketing tool asking the same question gets the same SQL and the same 38,950. Changing the rule to 28 days is an edit to 1 line of the definition, and every tool follows on its next query. Nobody searches 40 dashboards for the string 30.
Ratios are summed before they are divided
| Day | Sessions | Orders | Daily rate |
|---|---|---|---|
| Monday | 50 | 5 | 10.0% |
| Tuesday | 100 | 30 | 30.0% |
| Average of the 2 rates | 20.0% | ||
| Combined, 35 / 150 | 150 | 35 | 23.3% |
- ▸Gold and wide tables own the rows: cleaned, joined, at a stated row meaning.
- ▸The semantic layer owns the arithmetic: which rows a named number filters to, and how its columns are summed and divided.
- ▸Tools own presentation: charts, colours, drill paths. A tool never owns a definition.
A new charting tool is adopted and its users want active_customers by category. What happens?
Tasks, edges and no cycles
The graph has 8 tasks: 1 per table, plus a gate after each silver build. A failing gate halts every task downstream of it, which keeps bad rows out of gold and wide tables.
Depend on completion, never on the clock
| Time | What happens |
|---|---|
| 01:30 | load_bronze_payments starts, 40 pages to fetch |
| 01:41 | API returns rate-limit errors, the loader backs off and retries |
| 02:00 | build_silver_payments starts on schedule and reads 23 of 40 pages |
| 02:20 | load_bronze_payments finishes; silver already wrote the day |
| 06:00 | Finance dashboard shows 58% of the day's revenue, no alert fired |
load_bronze_payments starts at 01:30 and build_silver_payments at 02:00. On a night when bronze runs until 02:20, which design guarantees silver never reads a partial day?
Rerunning a task must give the same table
A gate between layers
If silver_rows differs from distinct_events, deduplication failed. If non_positive is above 0, a refund was classified as a payment. Either way the gate fails, the gold build never starts, and the dashboard shows yesterday's number with a staleness flag instead of today's wrong one.
After a bug fix, the whole graph is rerun for 2026-10-20. silver_payments is correct and passes its gate, but gold_daily_revenue shows every country at exactly double its true revenue. Which task is at fault?
> A music streaming company launches a paid tier in Brazil. On day 1 the CFO wants weekly Brazilian revenue, paying listener counts and free-to-paid conversion, and the numbers must survive a restatement when the payment processor corrects a batch of currency conversions 2 weeks later.
_ingested_at per event, so the corrections replace the originals and gold inherits the fix with no call back to the processor.Five shapes a warehouse can take, what each one makes cheap, and the requirement that breaks it.
- Category
- Data Modeling
- Difficulty
- intermediate
- Duration
- 21 minutes
- Challenges
- 12 hands-on challenges
Topics covered: Medallion Architecture, Data Vault, One Big Table (OBT), Semantic Layers, Pipeline DAG Design
Lesson Sections
- Medallion Architecture
3 copies of the same data A pipeline that reads a source system and writes a report in a single step has nowhere to stand when a rule changes. Suppose the report counted refunds as revenue for 6 months. Fixing it means pulling the source again, and a payments API may keep only 30 days of events, so the older months are gone for good. A layered design keeps 3 copies of the data, each with a stated promise about what it holds and who may change it. The names describe how far the data has travelled
- Data Vault
Hubs, links and satellites A warehouse with one wide customer table works until a second source arrives. Your company acquires a competitor whose CRM describes customers with 14 attributes, 9 of which yours has never heard of. Widening the table means editing and retesting each job that loads it and each view or report that reads it. Data Vault splits a model into 3 kinds of table so that a new source adds tables and edits none. No row was overwritten when Ana moved or upgraded. Reading her curr
- One Big Table (OBT)
Every attribute beside the number it describes An analyst wants October revenue by customer region and product category. In a model that keeps orders and their lines apart from customers and products, that query joins 4 tables, and each of 30 dashboard authors writes those joins again. Some join orders to lines and sum the order total, counting a 3-line order 3 times. One Big Table runs those joins once in the pipeline and stores the result, so the analyst's query has none. The second query read
- Semantic Layers
The metric written once At one company, 3 teams each publish active customers for the trailing month: All 3 numbers are correct for their rule, and the business has no way to say which one is the company's. The rules live inside 3 tools, written as SQL by 3 people, and a fourth tool will add a fourth rule. A semantic layer moves the metric definitions out of the tools and into one place that every tool queries. Ratios are summed before they are divided Conversion rate is orders divided by sessio
- Pipeline DAG Design
Tasks, edges and no cycles Every table in the previous sections is written by a task, and every task reads tables written by other tasks. A pipeline DAG is that set of tasks with an arrow from each writer to each reader. Directed means the arrows have a direction. Acyclic means following them never returns to the start, so some task can always run first. An orchestrator walks the graph and starts a task when every task pointing into it has finished. Depend on completion, never on the clock A com