Lab 07 — The Semantic Layer & Metrics
Your gold marts hold clean facts and dimensions — but a fact is not a metric. "Revenue" isn't a column; it's a definition. In this lab you'll define mini-GridDP's business metrics exactly once, in one place, so that every consumer — the dashboard, an analyst's ad-hoc query, an AI assistant — gets the identical number. This is the semantic layer, and it's where a warehouse becomes a source of truth.
You need the gold marts from Lab 03 built and queryable in DuckDB: dim_customer, dim_gpu, dim_date, fct_rentals, and fct_telemetry_hourly. Confirm with cd mini-griddp/dbt && dbt run --select marts and a quick SELECT count(*) FROM fct_rentals; in duckdb ../warehouse.duckdb. If those tables are missing, go back and finish Lab 03 — this lab builds directly on them.
The goal
So far the platform can store facts. Now it needs to mean something. A "metric" is a number with a fixed business definition — Gross Merchandise Value, take-rate revenue, utilization. The job of the semantic layer is to capture each definition once and hand the same answer to everyone who asks.
This realizes chapter 08 — Serving & the Semantic Layer of the Systems Design reference, at laptop scale. The principle is identical whether your warehouse serves five people or five thousand.
The five-definitions problem
Here's the failure mode the semantic layer exists to prevent. Imagine mini-GridDP has no shared metric definitions. The CEO asks five people for "monthly revenue," and each one writes their own SQL against fct_rentals:
| Who | Their "revenue" SQL | Quietly assumes… |
|---|---|---|
| Analyst A | sum(rental_amount) | gross GMV = revenue (wrong — that's the customer's spend) |
| Analyst B | sum(rental_amount * 0.20) | 20% take rate, hard-coded |
| Finance | sum(rental_amount * take_rate) | per-row take rate, only completed rentals |
| Dashboard | sum(rental_amount * take_rate) incl. cancelled | forgot the status filter |
| AI assistant | whatever the prompt guessed | nobody knows |
Five numbers. They disagree, sometimes by 5×. Now every meeting starts with "whose number is right?" instead of "what should we do?" — and once people stop trusting the dashboard, they go back to spreadsheets and the platform's whole value evaporates.
A metric is a contract, not a query. The contract — "take-rate revenue = sum of rental_amount × take_rate over rentals where status = 'completed'" — must live in one place that everyone reads from. Write it twice and it will drift. The semantic layer is just the place where that contract lives, version-controlled, beside the rest of your dbt project.
This is the same lesson as systems-design chapter 08: serving isn't about making data available, it's about making one agreed definition available. Trust is the product.
The metric catalog
Before writing a line of code, write the catalog. For each metric: a precise English definition and the gold fact it's computed from. This table is the spec — the SQL that follows just encodes it.
| Metric | Precise definition | Source |
|---|---|---|
| GMV | Gross Merchandise Value — total customer spend. sum(rental_amount) over completed rentals. | fct_rentals |
| Take-rate revenue | What the marketplace keeps. sum(rental_amount × take_rate) over completed rentals. | fct_rentals |
| Utilization % | Share of available GPU-hours that were actually rented. rented_hours / available_hours × 100. | fct_rentals, fct_telemetry_hourly |
| Fill rate | Of all rental requests, the fraction that became a completed rental. completed / requested. | fct_rentals |
| ARPU | Average Revenue Per User. take-rate revenue / count(distinct customer) over the period. | fct_rentals, dim_customer |
| Revenue per gpu_model | Take-rate revenue sliced by dim_gpu.gpu_model (A100, H100, …). | fct_rentals, dim_gpu |
Notice every definition names a filter (completed rentals) and a grain (per period, per model). Most "the number is wrong" bugs are really a forgotten filter or a wrong grain. Writing them down here, in plain English, is half the battle.
Implementing one source of truth
There are two ways to build a semantic layer in dbt. The fancy way is dbt's MetricFlow / semantic models — you declare metrics in YAML and dbt generates the SQL on demand. It's powerful, but it needs dbt Cloud or the MetricFlow CLI and adds moving parts. We'll use the pragmatic way that's fully local and just as principled: metric models. Each metric is a small, heavily documented dbt model in models/metrics/ that every consumer queries. The definition lives in the SQL; nobody re-derives it.
Create the folder and tell dbt to materialize these as views (cheap, always fresh):
cd mini-griddp/dbt
mkdir -p models/metricsFirst, the headline metrics — GMV and take-rate revenue — at a daily grain, so any consumer can roll them up to month, quarter, or year:
-- METRIC: GMV and take-rate revenue, per day.
-- Definition (the contract — do NOT redefine elsewhere):
-- gmv = sum(rental_amount) over completed rentals
-- revenue = sum(rental_amount * take_rate) over completed rentals
-- Grain: one row per rental_date. Roll up by summing.
{{ config(materialized='view') }}
select
d.date_day as rental_date,
d.year_month,
sum(r.rental_amount) as gmv,
sum(r.rental_amount * r.take_rate) as revenue
from {{ ref('fct_rentals') }} r
join {{ ref('dim_date') }} d on r.date_key = d.date_key
where r.status = 'completed'
group by 1, 2Next, ARPU — note it reuses the revenue definition by referencing the same filtered facts, so it can never disagree with metric_revenue:
-- METRIC: ARPU — average take-rate revenue per active customer, per month.
-- Definition:
-- arpu = sum(rental_amount * take_rate) / count(distinct customer_key)
-- over completed rentals, per year_month.
{{ config(materialized='view') }}
select
d.year_month,
count(distinct r.customer_key) as active_customers,
sum(r.rental_amount * r.take_rate) as revenue,
sum(r.rental_amount * r.take_rate)
/ nullif(count(distinct r.customer_key), 0) as arpu
from {{ ref('fct_rentals') }} r
join {{ ref('dim_date') }} d on r.date_key = d.date_key
where r.status = 'completed'
group by 1And revenue per GPU model, the same contract sliced by a dimension:
-- METRIC: take-rate revenue by GPU model.
-- Same revenue definition as metric_revenue, grouped by gpu_model.
{{ config(materialized='view') }}
select
g.gpu_model,
sum(r.rental_amount * r.take_rate) as revenue,
count(*) as completed_rentals
from {{ ref('fct_rentals') }} r
join {{ ref('dim_gpu') }} g on r.gpu_key = g.gpu_key
where r.status = 'completed'
group by 1
order by revenue descFinally, document the catalog so it shows up in dbt docs and anyone browsing knows these are the blessed definitions:
version: 2
models:
- name: metric_revenue
description: >
SOURCE OF TRUTH for GMV and take-rate revenue, per day.
gmv = sum(rental_amount); revenue = sum(rental_amount * take_rate);
both over completed rentals only. Roll up by summing across rental_date.
columns:
- name: revenue
description: Take-rate revenue (what the marketplace keeps).
tests: [not_null]
- name: gmv
description: Gross Merchandise Value (total customer spend).
tests: [not_null]
- name: metric_arpu
description: Average take-rate revenue per active customer, per month.
- name: metric_revenue_by_model
description: Take-rate revenue sliced by GPU model. Same revenue definition.The point of a semantic layer isn't a particular tool — it's that every consumer reads the metric from one governed definition instead of re-deriving it. Metric models give you exactly that: versioned, tested, documented, and queryable with plain SQL. When you later outgrow it, MetricFlow is a drop-in upgrade of the same idea.
Two consumers, one number
Here's the whole payoff. Two different "consumers" ask for June revenue. Neither one knows or cares how revenue is defined — they just read the metric model. Watch them land on the same number.
Consumer 1 — the BI dashboard wants monthly revenue, so it rolls the daily metric up to a month:
-- "June take-rate revenue" as the dashboard would ask it
select sum(revenue) as june_revenue
from metric_revenue
where year_month = '2026-06';Consumer 2 — an analyst (or AI assistant) comes in through ARPU but only wants the revenue piece. Because ARPU is built on the same filtered facts, its revenue column must match:
-- Different consumer, different entry point, SAME definition underneath
select revenue as june_revenue
from metric_arpu
where year_month = '2026-06';If these two queries ever return different numbers, your semantic layer has a leak — two models are computing revenue two different ways. They should be byte-for-byte identical because both ultimately apply the one contract: sum(rental_amount * take_rate) over completed rentals. That equality is the semantic layer working.
Build & query
Build the new metric models, then run both consumer queries and compare:
cd mini-griddp/dbt
dbt run --select metrics # build the 4 metric views
dbt test --select metrics # the not_null contracts pass
# now query as the two consumers (DuckDB CLI)
duckdb ../warehouse.duckdb "
select 'dashboard' as consumer, sum(revenue) as june_revenue
from metric_revenue where year_month = '2026-06'
union all
select 'ad-hoc/AI', revenue
from metric_arpu where year_month = '2026-06';
"dbt run builds 4 views (revenue, arpu, revenue_by_model — plus whatever you add) with PASS on the tests. The final query prints two rows — dashboard and ad-hoc/AI — with the exact same june_revenue value (e.g. both 18423.50). Two consumers, two entry points, one number. If they differ, one of your metric models isn't using the canonical filter or expression — fix it until they match.
For a sanity sweep, peek at the model breakdown too — it should tell a coherent business story:
duckdb ../warehouse.duckdb "select * from metric_revenue_by_model;"One row per GPU model (H100, A100, …), ordered by revenue descending, with the high-end models earning the most. The model revenues should sum to the GMV-adjacent total you saw above — another quick consistency check.
Commit Lab 07
The semantic layer is now part of the repo's story — commit it.
cd mini-griddp
git add dbt/models/metrics
git commit -m "Lab 07: semantic layer — metric models (GMV, revenue, ARPU, by-model)"This is the commit where your warehouse stops being a pile of tables and becomes a source of truth. A hiring manager reading your history sees you understand that metrics are governed contracts, not ad-hoc SQL — a senior instinct.
✓ Check yourself
- Can you explain the "five definitions" problem and why it destroys trust in a dashboard?
- Why does a metric live in one model instead of being written into each consumer's query?
- Why must the two consumer queries above return the identical number — and what does it mean if they don't?
- What does each metric definition pin down besides the arithmetic (hint: filter and grain)?
Exercise — Add an "avg rental hours" metric to the catalog and implement it
Extend the semantic layer with one new metric: average rental hours — the mean duration of a completed rental, per month. First add it to the catalog table in your head (definition: avg(rental_hours) over completed rentals, grain: per year_month, source: fct_rentals). Then implement it as a metric model and confirm it builds.
-- METRIC: average duration of a completed rental, per month.
-- Definition: avg(rental_hours) over completed rentals, per year_month.
{{ config(materialized='view') }}
select
d.year_month,
avg(r.rental_hours) as avg_rental_hours,
count(*) as completed_rentals
from {{ ref('fct_rentals') }} r
join {{ ref('dim_date') }} d on r.date_key = d.date_key
where r.status = 'completed'
group by 1
order by d.year_month;cd mini-griddp/dbt
dbt run --select metric_avg_rental_hours
duckdb ../warehouse.duckdb "select * from metric_avg_rental_hours;"You should see one row per month with a sensible average (e.g. 6.4 hours) and the matching completed-rental count. Notice you reused the same two patterns — the status = 'completed' filter and the dim_date join — that every metric here shares. That repetition is the contract holding: a churned-customers or utilization metric would follow the identical shape. (Stretch goal: if your fct_rentals lacks a rental_hours column, derive it from start/end timestamps in silver first, then this metric is trivial.)
Next
Your metrics are defined once and proven consistent. Now let's put them in front of a human — a real, clickable dashboard wired straight to the semantic layer. → Lab 08 — Serve It — BI Dashboard