dbt mesh is the multi-project architecture pattern that finally lets a 500-model dbt monolith split into per-domain projects without losing the cross-team lineage that made dbt worth adopting in the first place. Every analytics organisation eventually hits the same wall: one repository, three teams, one CI build, one broken commit that stalls every dashboard for six hours; a dbt_project.yml so overloaded that nobody remembers who owns stg_events_v3; a documentation site that lists 500 models under the same navigation tree. The dbt multi-project model — landed as a first-class feature in dbt-core 1.6 and matured in Cloud through 2024–2026 — reframes the monolith as a federation of smaller projects that expose an explicit, versioned API to each other via dbt public models, model contracts, and cross-project ref() calls.
This guide is the senior analytics-engineering walkthrough you wished existed the first time an interviewer asked "walk me through how you'd split a 500-model monolith into a mesh," "what does access: public do on a model and how does it differ from protected," or "how do you version a public model that fifteen downstream projects depend on without breaking any of them?" It covers why the monolith problem is fundamentally organisational rather than technical, the four "must-answer" axes senior interviewers probe (dbt project splitting, access levels, dbt contract, dbt version), the mechanics of access: public | protected | private, model contracts as an enforced column-type + nullability API, semantic versioning with deprecation windows, cross-project {{ ref('project_name', 'model_name') }} calls wired through dependencies.yml, dbt Cloud Explorer for cross-team lineage and downstream impact analysis, the Discovery API for programmatic governance, and the monolith-to-mesh migration path — extract-one-domain, publish-public-models, cutover-consumers — that senior leads actually ship. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the SQL practice library →, rehearse on the ETL practice library →, and sharpen the modelling axis with the optimization practice library →.
On this page
- Why dbt Mesh landed in 2023-2026
- Splitting a monolith — domains + boundaries
- Public models + access + contracts
- Cross-project refs + dbt Cloud Explorer
- Migration + governance
- Cheat sheet — dbt Mesh recipes
- Frequently asked questions
- Practice on PipeCode
1. Why dbt Mesh landed in 2023-2026
The monolith problem is organisational, not technical — dbt mesh is the architecture answer
The one-sentence invariant: a dbt monolith beyond ~300 models stops scaling with the team, not the warehouse — one repository, one CI build, and one broken commit means every team on the project shares an outage budget, whether their models depend on the broken code or not. Warehouses have kept getting faster; Snowflake, BigQuery, and Databricks all cheerfully compile and materialise thousands of models in a single dbt run. The bottleneck migrated from the compute layer up into the coordination layer — code review queues, dependency conflicts, ownership ambiguity, and the "who owns stg_events?" question that has no answer when a team of forty commits to the same models/ folder.
dbt Mesh is the answer dbt Labs shipped between 2023 and 2026 to reframe the problem: instead of one project with 500 models, ship N projects with 50 models each, each owned by exactly one team, each exposing a small set of public models with column-level contracts, and each consumed by downstream projects via a cross-project ref() call that dbt Cloud tracks in Explorer as a first-class edge in the cross-team lineage graph. The architecture is the same one that saved microservices in the 2010s — bounded contexts, published APIs, versioned contracts — reapplied to analytics engineering.
The four axes interviewers actually probe.
- Project split. How do you draw the boundary between projects? Domain-driven (sales, marketing, finance, product), source-driven (one project per source system), or layer-driven (staging vs marts)? Senior interviewers push hard on the reasoning — the boundary decision is the single most consequential choice in the mesh design.
-
Access levels.
access: public | protected | private— the model-level config that decides who canref()your model. Public is the API; protected is the group; private is the project. Getting the default wrong (accidentally exposing internal staging models as public) is the operational sin senior teams guard against. -
Contracts.
contract: {enforced: true}freezes the column names, types, and nullability at build time — a schema drift in the underlying SQL fails the build instead of silently corrupting downstream consumers. Contracts are the mechanism that turns a public model into a stable API. -
Versioning.
versions:block on a public model letsdim_customerexist as v1 and v2 simultaneously, with a deprecation window that gives downstream consumers time to migrate. Interviewers probe whether you understand deprecation as a migration protocol, not just a config flag.
Why the monolith stops scaling around 300 models.
-
CI time. A single
dbt buildrun over 500 models with tests takes 45–90 minutes on the cheapest warehouse tier. Every pull request pays the full cost, so review queues clog on CI throughput long before code review capacity. -
Ownership diffusion. With 500 models under one
models/folder, CODEOWNERS becomes a game of pattern matching against filename prefixes. Nobody has clear on-call for the whole thing; broken models sit in a Slack channel until someone volunteers. -
Dependency conflicts. Team A adds a column to
stg_orders; team B's mart breaks; team B has no idea A was touching that model until Slack pings arrive. There is no contract between the teams — the graph is one big undifferentiated DAG. -
Documentation collapse.
dbt docs generateon 500 models produces a docs site that is technically complete and practically unnavigable. The taxonomy is flat; consumers cannot tell public API from internal plumbing. - The 300-model threshold. Every organisation we've seen hits this wall between 200 and 400 models. The trigger is usually the second incident where team B's release blocked team A's dashboard. Beyond that point, the ROI on the mesh migration is measured in weeks, not quarters.
What dbt Mesh actually gives you.
- Per-domain repositories. One repo per domain (sales, marketing, finance, product), each owned by one team, each with its own CI budget, each with its own release cadence.
-
A published API. Each domain exposes a small number of
access: publicmodels with contracts and versions. Downstream projects consume only the public surface; internal staging and intermediate models stay private to the owning team. -
Cross-project lineage.
{{ ref('project_name', 'model_name') }}is a first-class cross-project reference. dbt Cloud Explorer renders the lineage across projects as one continuous graph; the Discovery API answers "what breaks if I change this column" queries programmatically. - Slim CI at the project level. Each project runs its own CI. Changes to a public model trigger downstream project CI via a contract check — not a full monolithic rebuild.
- Ownership as code. CODEOWNERS at the project level, plus dbt-level owner metadata on public models, means every model has a clearly named team on the hook for incidents.
What interviewers listen for.
- Do you say "the bottleneck is coordination, not compute" when asked why the mesh matters? — senior signal.
- Do you frame
access: publicas "an API contract, not a permissions flag"? — senior signal. - Do you push back on "why not just split into folders?" with the CI-time and ownership arguments? — required answer.
- Do you describe the mesh migration as "extract one domain first, publish the public models, cut over consumers, repeat" — never as a big-bang split? — required answer.
Worked example — one broken commit stalls three teams
Detailed explanation. The textbook monolith failure mode: a team-A engineer refactors stg_orders.sql, drops a column that team-B's mart quietly depended on, and merges through CI because team-A's tests don't cover the downstream mart. The next dbt build run in production fails at team-B's mart, which team-C's finance dashboard depends on. Three teams are now blocked on one team-A commit. Walk an interviewer through what actually happened and what a mesh split would have caught earlier.
-
The symptom. Production
dbt buildfails atmart_finance_dailywithcolumn "order_amount" does not exist. -
The commit. Team-A merged a refactor of
stg_orders.sqlthat renamedorder_amounttogross_amount. -
The blast radius. Team-B's
int_orders_enrichedbreaks; team-C'smart_finance_dailybreaks; team-C's exec dashboard is empty at 8 AM. - The fix window. Roll back team-A's commit, re-run the affected marts, page team-A to write a proper migration — 4 hours of blast radius, all three teams paged.
Question. A 500-model dbt monolith has three teams (orders, marketing, finance) sharing one repo. Design the mesh split that would have prevented this incident, quantify the CI-time and blast-radius improvement, and name the specific dbt Mesh feature that catches the schema drift at build time.
Input.
| Metric | Monolith | Proposed mesh |
|---|---|---|
| Model count | 500 | 3 × ~170 |
| CI wall time per PR | 60 min | 15 min per project |
| Blast radius of a bad commit | full graph | one project + declared downstream contracts |
| Public API surface | undefined | small set of contract-enforced public models |
| Ownership | ambiguous | one project = one team |
Code.
# Monolith today — one dbt_project.yml, one CI, no contracts
# dbt_project.yml
name: analytics_monolith
version: 1.0.0
profile: prod
model-paths: [models]
# Proposed mesh — three projects with public model contracts
# projects/orders/dbt_project.yml
name: orders
version: 2.0.0
profile: prod
model-paths: [models]
# projects/orders/models/marts/public_orders.yml
version: 2
models:
- name: public_orders
description: "Public orders fact — order_id, customer_id, gross_amount, status, order_ts"
access: public
contract:
enforced: true
columns:
- name: order_id
data_type: bigint
constraints:
- type: not_null
- type: primary_key
- name: customer_id
data_type: bigint
constraints:
- type: not_null
- name: gross_amount
data_type: numeric(18,2)
constraints:
- type: not_null
- name: status
data_type: varchar
- name: order_ts
data_type: timestamp
# projects/finance/dependencies.yml — declares the upstream project
projects:
- name: orders
# projects/finance/models/marts/mart_finance_daily.sql
select
order_date::date as order_date,
sum(o.gross_amount) as gross_revenue,
count(*) as order_count
from {{ ref('orders', 'public_orders') }} as o
group by 1
Step-by-step explanation.
- Under the monolith, team-A's rename
order_amount → gross_amountslipped through because no dbt test asserted the contract betweenstg_ordersand the downstream marts. The failure surfaced only at build time in production, three DAG hops away. - Under the mesh, team-A's public API is
public_orderswithcontract: {enforced: true}. The contract asserts thatgross_amountis a column of typenumeric(18,2). If team-A refactors the SQL but keeps the column name, the contract passes. If team-A drops the column, the contract fails at build time inside the orders project — before merge. - The mesh split reduces the CI time per PR from 60 minutes (full monolith rebuild) to ~15 minutes per project. Team-A's PR builds the orders project only. Team-B (marketing) and team-C (finance) do not wait on team-A's CI at all.
- The blast radius is now bounded by the public API surface. Team-A can freely refactor internal staging models (
stg_orders_v3,int_orders_enriched) without touching consumers; only changes topublic_orderstrigger the cross-project contract check. - Ownership becomes crisp — the orders project is owned by team-A, and
CODEOWNERSat the repository level enforces review requirements. Whenpublic_ordersneeds a change, it is a cross-team API change, and the process reflects that.
Output.
| Dimension | Monolith outcome | Mesh outcome |
|---|---|---|
| Detected schema drift at build time | no | yes (contract enforcement) |
| CI wall time per PR | 60 min | 15 min per project |
| Blast radius of team-A commit | 3 teams | orders project only |
| Fix time for the incident | 4 hours | prevented at CI |
| Cross-team blocking commits per month | 5–8 | 0–1 |
Rule of thumb. The mesh's biggest win is not faster compute — it is catching schema drift at build time inside the producing project, before it reaches consumers. Contracts turn implicit column-name dependencies into explicit, tested APIs.
Worked example — the CI-time cliff at 300 models
Detailed explanation. Another common inflection point: a monolith crosses 300 models and the CI queue starts backing up. Every PR takes 40+ minutes for dbt build --select state:modified+. Reviewers hesitate to approve because context switching across 40 minutes is expensive. The team ships fewer PRs per week. The apparent "warehouse cost" problem is really a "coordination throughput" problem — and the mesh split fixes it by turning one 40-minute CI into three 12-minute CIs, run in parallel.
- The CI observation. Median PR CI wall time crossed 40 minutes at model count 320.
- The throughput observation. Merged PRs per week dropped from 25 to 14 over the same period.
- The root cause. The CI queue is a shared resource; every team pays the tail cost of every other team's changes.
Question. Given a 500-model monolith with a 60-minute CI, design the mesh split that reduces per-PR CI time by 4× and quantify the throughput gain.
Input.
| Parameter | Monolith | Mesh (3 projects) |
|---|---|---|
| Model count | 500 | ~170 per project |
| CI wall time per PR | 60 min | 15 min per project (parallel) |
| Merged PRs per week | 14 | 40+ (throughput bounded by review capacity) |
| Cross-project CI trigger | full rebuild | only if public model contract changes |
Code.
# CI config — GitHub Actions, one workflow per project
# .github/workflows/orders-ci.yml
name: orders-ci
on:
pull_request:
paths:
- 'projects/orders/**'
- 'projects/orders/dependencies.yml'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: projects/orders
steps:
- uses: actions/checkout@v4
- name: dbt build (project-scoped, slim CI)
run: |
dbt deps
dbt build --select state:modified+ --state ./target-prod
# .github/workflows/finance-ci.yml — triggers on finance changes OR upstream contract changes
name: finance-ci
on:
pull_request:
paths:
- 'projects/finance/**'
workflow_run:
workflows: ['orders-contract-changed']
types: [completed]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: projects/finance
steps:
- uses: actions/checkout@v4
- run: |
dbt deps
dbt build --select state:modified+
Step-by-step explanation.
- The mesh split moves each project into its own directory (
projects/orders,projects/marketing,projects/finance). Each project has its owndbt_project.yml, its ownmodels/, and its own CI workflow. - The
pathsfilter on the GitHub Actions workflow means the orders CI only runs when files underprojects/orders/**change. A team-B PR that touches only marketing models does not trigger the orders CI — the shared-resource problem disappears. - Slim CI (
--select state:modified+) further bounds the CI to only the modified models plus their downstream neighbours within the project. On average, an orders PR touches 5–10 models; the CI builds 5–10, not 170. - Cross-project contract changes trigger downstream CI via
workflow_run. If team-A changespublic_orders's contract (a breaking change), the finance CI runs as a downstream contract check — not a full monolithic rebuild. - The throughput gain is roughly 4× — CI wall time drops from 60 minutes to 15, and the three projects run in parallel. Review capacity, not CI throughput, is now the bottleneck — which is the correct place for the constraint to live.
Output.
| Metric | Monolith | Mesh |
|---|---|---|
| CI wall time per PR (median) | 60 min | 15 min |
| Cross-team PRs blocked by CI queue | 40% | 5% |
| Merged PRs per week (across all teams) | 14 | 40+ |
| Contract violation caught pre-merge | no | yes |
Rule of thumb. The CI-time cliff at 300 models is the leading indicator that the monolith has outgrown its coordination model. Split the projects before the CI becomes the bottleneck, not after — retrofitting slim CI onto an established monolith is 2× the work.
Worked example — the ownership matrix falls apart at 15 engineers
Detailed explanation. A dbt monolith with 8 engineers has clear informal ownership — everyone knows who wrote what, and Slack pings resolve most conflicts. At 15 engineers, informal ownership collapses; nobody remembers who wrote stg_events_v3 two years ago. CODEOWNERS with folder-based patterns is a partial fix but breaks down when models cross folder boundaries. dbt Mesh makes ownership project-level, so the question "who owns this model" has a one-word answer: the project's team.
- The 8-engineer state. Informal ownership works. Nobody argues.
- The 15-engineer state. Model authorship is a git-blame archaeology exercise. Cross-team dependencies leak.
-
The mesh answer. One project = one team. The
dbt_project.ymlnames the owning team; the CODEOWNERS file enforces review.
Question. Design the ownership model for a 4-team, 20-engineer analytics organisation using dbt Mesh. Show the CODEOWNERS file, the project-level owner metadata, and the escalation path for a cross-project incident.
Input.
| Component | Value |
|---|---|
| Teams | orders, marketing, finance, product |
| Engineers per team | 5 |
| Projects | one per team |
| Public model count per project | 3–8 |
Code.
# projects/orders/dbt_project.yml
name: orders
version: 2.1.0
profile: prod
config-version: 2
# Project-level ownership metadata — surfaced in dbt Cloud Explorer
vars:
owner_team: "team-orders"
owner_slack: "#team-orders"
on_call_rotation: "team-orders-oncall"
models:
orders:
+meta:
owner: "team-orders"
slack: "#team-orders"
# CODEOWNERS — repository level, enforces review requirements
# projects/orders is owned by @team-orders
projects/orders/** @org/team-orders
projects/marketing/** @org/team-marketing
projects/finance/** @org/team-finance
projects/product/** @org/team-product
# Cross-cutting infrastructure
.github/workflows/** @org/data-platform
packages.yml @org/data-platform
# projects/orders/models/marts/public_orders.yml — per-model owner metadata
version: 2
models:
- name: public_orders
access: public
meta:
owner: "team-orders"
slack: "#team-orders"
escalation: "team-orders-oncall"
sla_hours: 4
contract:
enforced: true
columns:
- name: order_id
data_type: bigint
constraints: [{type: not_null}]
- name: gross_amount
data_type: numeric(18,2)
constraints: [{type: not_null}]
Step-by-step explanation.
- Project-level ownership is expressed in three places:
dbt_project.ymlvars, model-levelmetaon public models, and the repository-levelCODEOWNERSfile. All three point at the same team so there is exactly one source of truth. -
CODEOWNERSwith theprojects/<team>/**pattern means any PR that touches a project's files requires review from that team's GitHub group. A cross-team PR (e.g.dependencies.ymlchange in finance that adds orders as a dependency) requires review from both teams. - Per-model
metafields (owner,slack,escalation,sla_hours) are surfaced in dbt Cloud Explorer and via the Discovery API. When a downstream consumer looks uppublic_orders, they see who owns it and how to escalate — no git-blame required. - The escalation path for a cross-project incident is: consumer team detects the issue → looks up
ownerin Explorer → pingsslackchannel → the producer team's on-call responds withinsla_hours. This is the same runbook microservices teams have used for a decade. - The organisational structure now maps 1:1 onto the dbt project structure. Team growth (from 15 to 30 engineers) is absorbed by splitting a project (orders becomes orders-domestic + orders-international), not by adding to the shared monolith.
Output.
| Dimension | Monolith | Mesh |
|---|---|---|
| Ownership resolution time | git blame (hours) | Explorer lookup (seconds) |
| Cross-team PR review path | ambiguous | CODEOWNERS-enforced |
| Escalation runbook | tribal knowledge | meta.escalation |
| Team growth absorption | painful | split a project |
Rule of thumb. When the engineering headcount crosses ~10, ownership must be coded, not tribal. dbt Mesh gives you three complementary places to code it — project vars, model meta, and repository CODEOWNERS — and Explorer surfaces all three back to consumers.
Senior interview question on dbt mesh and the monolith split
A senior interviewer often opens with: "You inherit a 500-model dbt monolith with 20 engineers, three teams, and 60-minute CI. Walk me through the architecture case for dbt Mesh, the specific dbt features you'd lean on, and the first four weeks of the migration."
Solution Using a domain-driven three-project split with public contracts
# Week 1 — extract the "orders" domain into its own project
# projects/orders/dbt_project.yml
name: orders
version: 2.0.0
profile: prod
config-version: 2
# Week 2 — define the public API of the orders project
# projects/orders/models/marts/public_orders.yml
version: 2
models:
- name: public_orders
access: public
contract:
enforced: true
versions:
- v: 1
- v: 2
columns:
- name: order_id
data_type: bigint
constraints: [{type: not_null}, {type: primary_key}]
- name: customer_id
data_type: bigint
constraints: [{type: not_null}]
- name: gross_amount
data_type: numeric(18,2)
constraints: [{type: not_null}]
- name: status
data_type: varchar
- name: order_ts
data_type: timestamp
# Week 3 — extract finance and marketing, declare cross-project deps
# projects/finance/dependencies.yml
projects:
- name: orders
# projects/marketing/dependencies.yml
projects:
- name: orders
# Week 4 — CI per project, cross-project contract check, sunset monolith
# .github/workflows/orders-ci.yml → triggered on projects/orders/**
# .github/workflows/finance-ci.yml → triggered on projects/finance/** + workflow_run(orders)
# .github/workflows/marketing-ci.yml → triggered on projects/marketing/** + workflow_run(orders)
Step-by-step trace.
| Week | Milestone | Deliverable |
|---|---|---|
| 1 | Extract orders | orders project builds green in isolation |
| 2 | Publish orders API |
public_orders with contract v1 + v2 declared |
| 3 | Extract finance + marketing | Both projects declare orders as dependencies.yml
|
| 4 | Per-project CI + sunset monolith | Monolithic repo archived; three projects in production |
After week 4, the monolith is archived; three domain projects live in their own repos; each project's CI runs in 15 minutes; the public API surface between projects is public_orders (contract v2) plus a handful of similar public models per domain. Cross-team incidents drop from 5–8 per month to 0–1.
Output:
| Metric | Monolith | Mesh (after 4 weeks) |
|---|---|---|
| Projects | 1 | 3 |
| CI wall time per PR | 60 min | 15 min per project |
| Cross-team blocking commits | 5–8 / month | 0–1 / month |
| Ownership clarity | ambiguous | project = team |
| Public API surface | undefined | 12 public models w/ contracts |
Why this works — concept by concept:
- Domain-driven split — the boundary between projects follows the team boundary, not the layer boundary (staging vs marts). One team = one project = one release cadence.
-
Public models as API —
access: public+contract: {enforced: true}turns the intra-team public surface into a stable, versioned API. Consumers depend on the contract, not on the internal SQL. -
Cross-project ref —
{{ ref('orders', 'public_orders') }}is the mesh-native equivalent of an API call. dbt tracks the edge; Cloud Explorer renders it; Discovery API answers impact-analysis queries. -
Slim CI per project —
state:modified+bounds the build to the changed models plus their in-project downstream. Cross-project impact is a separate downstream CI trigger, not a full rebuild. - Cost — the migration is ~4 senior-engineer-weeks for a 500-model monolith. The recurring cost is O(projects) in CI runtime and O(1) in coordination overhead. Compared to the ~40 engineer-hours per month lost to monolithic-CI-blocking events, the migration pays back in one quarter.
SQL
Topic — sql
SQL modelling and dbt-shaped problems
2. Splitting a monolith — domains + boundaries
Domain-driven partitioning — the split boundary is the team boundary, not the schema boundary
The mental model in one line: dbt project splitting succeeds when every project owns one domain (sales, marketing, finance, product) with one team, one release cadence, one CI, and one small set of published public models — and fails when the split follows technical boundaries (staging vs marts, source system vs consumer) that cross team lines. Every other decision in the mesh migration is a consequence of this boundary call.
The four candidate boundaries.
- Domain-driven. One project per business domain (orders, marketing, finance, product). This is the pattern that mirrors team structure and the one dbt Labs recommends.
- Source-driven. One project per source system (salesforce, stripe, shopify). Works for the staging layer but creates awkward marts that pull from many source projects.
- Layer-driven. One project for staging, one for intermediate, one for marts. Trivial to implement but the projects have no team ownership; the split is technical, not organisational.
- Consumer-driven. One project per consumer (finance-mart, growth-mart). Works if consumers rarely share upstream models; breaks down if they do.
Why domain-driven wins for teams above 10 engineers.
- Team ↔ project mapping. The team boundary and the project boundary are the same line. Ownership, on-call, and release cadence align 1:1.
- Public API stays small. Each domain publishes 3–8 public models — the aggregated facts and canonical dimensions that other domains genuinely need. Internal staging and intermediate models stay private.
- Release cadence is per-team. Finance can freeze during quarter-close; marketing can ship 10 PRs a day. Neither blocks the other.
-
CI is per-team. Slim CI (
state:modified+) bounds the build to the changed models plus downstream within the project. Cross-project impact is a separate, cheap downstream CI trigger.
The "must-answer" checklist before splitting.
-
Does every model have a clear domain? Ambiguous models (e.g., a
dim_customerthat both marketing and finance use) need a decision — one domain owns it; the other consumes viaref('domain', 'dim_customer'). Splitting ownership 50/50 across domains is the single most common mistake. - Are the public models identified? Before splitting, run the whole team through the exercise: which 3–8 models per domain will you publish? If the list is empty ("we don't know yet"), you're not ready to split.
-
Is CODEOWNERS ready? Every project's
**glob maps to exactly one GitHub team. If your team structure is fluid, harden it first. -
Is dbt Cloud (or an equivalent Discovery API) available? dbt Core supports Mesh via
dbt deps+ manifest passing, but the observability story (Explorer, cross-project lineage graph) is native to Cloud. Plan the observability layer before the split.
The upstream/downstream contract.
-
The producer's obligation. Publish stable public models with contracts and versions; deprecate old versions with a runway; document each public model's
owner,slack, andsla_hours. -
The consumer's obligation. Declare the upstream project in
dependencies.yml; useref('project', 'model')— not raw SQL against the warehouse; pin to a version and upgrade via a controlled migration. - The mesh's obligation. dbt Cloud tracks cross-project edges; contract failures surface at the producer's build time; deprecation warnings surface at the consumer's compile time.
Common interview probes on the split.
- "Domain-driven vs layer-driven — which do you pick and why?" — domain-driven; layer-driven creates ownership gaps.
- "What's the minimum team size that justifies a mesh split?" — usually ~10 engineers or ~300 models, whichever comes first.
- "How many public models per project?" — 3–8 is typical; more than 15 suggests the domain itself needs splitting.
- "How do you handle a model that legitimately belongs to two domains?" — one domain owns it; the other consumes via cross-project
ref(). Shared ownership is the wrong answer.
Worked example — mapping a 400-model project onto four domains
Detailed explanation. A concrete exercise: a 400-model monolith serves an e-commerce business. The models span raw source data (Stripe, Shopify, Salesforce), staging, intermediate transformations, and marts consumed by finance, marketing, and product. The team must pick the domain boundaries, assign every model to exactly one project, and identify the public model surface.
-
The current state. 400 models under one
models/folder; three teams share the repo; no clear ownership. -
The proposed split. Four projects:
orders(Shopify + orders-related),payments(Stripe + finance-facing),marketing(Salesforce + growth),platform(shared dims + operational metadata). - The public surface. Each project publishes ~5 public models — canonical facts and dimensions.
Question. Design the mapping of 400 models onto four projects, list the public models per project, and show the cross-project dependency graph.
Input.
| Existing folder | Model count | Proposed home |
|---|---|---|
models/staging/shopify/ |
40 | orders |
models/staging/stripe/ |
30 | payments |
models/staging/salesforce/ |
25 | marketing |
models/intermediate/orders/ |
60 | orders |
models/intermediate/payments/ |
40 | payments |
models/intermediate/marketing/ |
50 | marketing |
models/marts/finance/ |
45 | payments (consumes orders) |
models/marts/marketing/ |
35 | marketing (consumes orders) |
models/marts/product/ |
30 | platform |
models/dims/ |
25 | platform |
models/utils/ |
20 | platform |
Code.
# projects/orders/dbt_project.yml
name: orders
version: 2.0.0
profile: prod
config-version: 2
# Public API
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
access: public
contract: {enforced: true}
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: gross_amount, data_type: numeric(18,2), constraints: [{type: not_null}]}
- {name: currency, data_type: varchar}
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
- name: public_order_lines
access: public
contract: {enforced: true}
columns:
- {name: order_line_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: order_id, data_type: bigint}
- {name: product_id, data_type: bigint}
- {name: quantity, data_type: int}
- {name: unit_price, data_type: numeric(18,2)}
- name: public_customers
access: public
contract: {enforced: true}
columns:
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: email, data_type: varchar}
- {name: signup_ts, data_type: timestamp}
# projects/payments/dependencies.yml
projects:
- name: orders
- name: platform
# projects/payments/models/marts/mart_finance_daily.sql
select
o.order_date::date as order_date,
sum(o.gross_amount) as gross_revenue,
sum(p.amount - p.refund_amount) as net_revenue,
count(distinct o.order_id) as order_count
from {{ ref('orders', 'public_orders') }} as o
join {{ ref('payments', 'public_payments') }} as p using (order_id)
group by 1
Step-by-step explanation.
- The domain mapping starts from the source: Shopify data is orders' concern; Stripe is payments'; Salesforce is marketing's. Cross-cutting dims (
dim_date,dim_currency) go toplatform— the shared-services project every other domain consumes. - The
financemarts move intopaymentsbecause payments is the domain that owns revenue reporting.financewas a consumer name, not a domain; the mesh split rejects consumer-named projects because they blur team ownership. - Each project publishes ~5 public models. Orders publishes
public_orders,public_order_lines,public_customers(customers move with orders because the customer lifecycle is owned by the orders team). Payments publishespublic_payments,public_refunds. Marketing publishespublic_leads,public_campaigns. Platform publishesdim_date,dim_currency,dim_country. - The cross-project dependency graph:
payments → orders + platform;marketing → orders + platform;platform → (root). The DAG is a shallow tree; platform is the shared root; orders sits one level down; payments and marketing each depend on orders and platform. - Internal staging and intermediate models stay
access: protected(the default) — visible within the project but not to other projects. Only the ~5 public models per project cross project boundaries. The API surface is deliberately small.
Output.
| Project | Total models | Public models | Depends on |
|---|---|---|---|
| platform | 45 (dims + utils) | 3 (dim_date, dim_currency, dim_country) | (none) |
| orders | 100 (staging + int + marts) | 3 (public_orders, public_order_lines, public_customers) | platform |
| payments | 115 (staging + int + finance marts) | 2 (public_payments, public_refunds) | orders + platform |
| marketing | 110 (staging + int + growth marts) | 2 (public_leads, public_campaigns) | orders + platform |
| product marts | 30 | 0 (product marts are terminal) | orders + platform |
Rule of thumb. Aim for a shallow tree of projects — one root (platform), a middle layer (domains), and terminal consumers (product-facing marts). Deep chains (A → B → C → D) create long deprecation windows and hard-to-reason-about blast radii.
Worked example — resolving a model that belongs to two domains
Detailed explanation. The mesh boundary decision gets ugly when a model genuinely serves two domains. dim_customer is the classic example: marketing wants it for growth analytics; finance wants it for revenue attribution; both teams have historically extended it with domain-specific columns. Under the mesh, one team owns it; the other consumes via cross-project ref(). Walk through the decision framework and the ownership handoff.
-
The conflict.
dim_customerhas 25 columns; marketing added 8; finance added 5; orders added 12 (the core customer identity + signup + status columns). -
The correct answer. Orders owns
dim_customer(orpublic_customers) — the customer identity is an orders-domain concept. Marketing and finance consume it viaref('orders', 'public_customers')and extend it inside their own projects with a view that adds their domain-specific columns. - The wrong answer. A shared "customers" project owned jointly by all three teams. Shared ownership violates the "one project = one team" invariant.
Question. Design the migration for dim_customer where orders publishes the core customer surface and finance / marketing extend it inside their own projects.
Input.
| Column category | Column count | Owner |
|---|---|---|
| Core identity | 12 | orders |
| Marketing extensions | 8 | marketing |
| Finance extensions | 5 | finance |
| Total | 25 | ambiguous today |
Code.
# projects/orders/models/marts/public_customers.yml
version: 2
models:
- name: public_customers
access: public
contract: {enforced: true}
columns:
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: email, data_type: varchar}
- {name: first_name, data_type: varchar}
- {name: last_name, data_type: varchar}
- {name: signup_ts, data_type: timestamp}
- {name: signup_source, data_type: varchar}
- {name: country_code, data_type: char(2)}
- {name: locale, data_type: varchar}
- {name: status, data_type: varchar}
- {name: churn_ts, data_type: timestamp}
- {name: last_seen_ts, data_type: timestamp}
- {name: customer_tier, data_type: varchar}
# projects/marketing/models/marts/dim_customer_marketing.sql
{{ config(materialized='view') }}
select
c.customer_id,
c.email,
c.signup_ts,
c.signup_source,
c.country_code,
c.customer_tier,
-- Marketing extensions live in the marketing project
m.attribution_channel,
m.first_campaign_id,
m.lifetime_touchpoints,
m.email_engagement_score,
m.growth_segment,
m.acquisition_cost_cents,
m.marketing_persona,
m.opt_in_status
from {{ ref('orders', 'public_customers') }} as c
left join {{ ref('int_customer_marketing_attrs') }} as m using (customer_id)
# projects/payments/models/marts/dim_customer_finance.sql
{{ config(materialized='view') }}
select
c.customer_id,
c.email,
c.signup_ts,
c.country_code,
c.customer_tier,
-- Finance extensions live in the payments project
f.lifetime_gross_revenue,
f.lifetime_refund_amount,
f.first_purchase_ts,
f.last_purchase_ts,
f.payment_risk_tier
from {{ ref('orders', 'public_customers') }} as c
left join {{ ref('int_customer_finance_attrs') }} as f using (customer_id)
Step-by-step explanation.
- The decision framework: pick the domain that creates the entity, not the domain that consumes it. Customers are created when someone signs up — an orders/commerce concern. Marketing and finance are consumers, not creators.
-
public_customersin the orders project owns the canonical identity columns (customer_id, email, signup_ts, status, country) — the 12 columns every downstream project needs. Contract enforcement guarantees these columns remain stable. - Marketing extends the customer surface inside its own project with
dim_customer_marketing— a view that joinspublic_customerswith marketing-internal attribution and engagement models. The marketing team owns the extension logic without pushing marketing columns into orders' public model. - Payments similarly extends with
dim_customer_finance— a view that joinspublic_customerswith payment-internal revenue and risk models. Finance owns the extension without polluting orders. - The three projects now have three domain-specific views of the same customer entity, each owned by the domain that uses it, and all anchored on orders' canonical
public_customers. Ownership is crisp; the public API stays small.
Output.
| View | Owner | Columns | Depends on |
|---|---|---|---|
| public_customers | orders | 12 canonical | (root of customer graph) |
| dim_customer_marketing | marketing | 12 canonical + 8 marketing | orders + marketing-internal |
| dim_customer_finance | payments | 12 canonical + 5 finance | orders + payments-internal |
Rule of thumb. When a model serves multiple domains, the entity's creator owns the canonical public model; consumers extend it inside their own projects with domain-specific views. Shared ownership is always the wrong answer — someone will end up on-call for something they don't understand.
Worked example — the "platform" project as a shared root
Detailed explanation. Every mesh needs a small platform project that owns the shared plumbing every other domain consumes — canonical calendars, currency conversion tables, country/region dimensions, and macro utilities. This project is the root of the dependency graph. It is owned by the data platform team (not any single domain), has the strictest change-control policy (breaking changes require RFC), and publishes the widest range of public models. The failure mode of not having a platform project is duplicated calendar tables in every domain and inconsistent country codes across marts.
- The need. Ten kinds of "shared plumbing" every domain needs — none of which belongs to any single domain.
-
The answer. A
platformproject owned by the data platform team; every public model in this project is a stable, versioned, contract-enforced API. - The change-control. Breaking changes go through a two-week RFC + downstream-notification workflow.
Question. Design the platform project's public API (5–8 public models) and the change-control policy for breaking changes.
Input.
| Shared plumbing | Belongs in platform? |
|---|---|
| dim_date | yes |
| dim_currency | yes |
| dim_country | yes |
| dim_locale | yes |
| macro fx_convert() | yes |
| macro safe_divide() | yes |
| stg_billing_events | no — payments |
| dim_customer | no — orders |
Code.
# projects/platform/dbt_project.yml
name: platform
version: 3.0.0
profile: prod
config-version: 2
# projects/platform/models/dims/schema.yml
version: 2
models:
- name: dim_date
access: public
contract: {enforced: true}
columns:
- {name: date_day, data_type: date, constraints: [{type: not_null}, {type: primary_key}]}
- {name: day_of_week, data_type: int}
- {name: iso_week, data_type: int}
- {name: fiscal_quarter, data_type: varchar}
- {name: is_business_day, data_type: boolean}
- name: dim_currency
access: public
contract: {enforced: true}
columns:
- {name: currency_code, data_type: char(3), constraints: [{type: not_null}, {type: primary_key}]}
- {name: currency_name, data_type: varchar}
- {name: symbol, data_type: varchar}
- name: dim_country
access: public
contract: {enforced: true}
columns:
- {name: country_code, data_type: char(2), constraints: [{type: not_null}, {type: primary_key}]}
- {name: country_name, data_type: varchar}
- {name: region, data_type: varchar}
- {name: subregion, data_type: varchar}
- name: fx_rates_daily
access: public
contract: {enforced: true}
columns:
- {name: rate_date, data_type: date, constraints: [{type: not_null}]}
- {name: from_currency, data_type: char(3), constraints: [{type: not_null}]}
- {name: to_currency, data_type: char(3), constraints: [{type: not_null}]}
- {name: rate, data_type: numeric(18,8), constraints: [{type: not_null}]}
# projects/platform/CHANGELOG.md — the change-control policy
# Change control for platform project
#
# Breaking changes to `access: public` models require:
# 1. RFC in #data-platform-rfc — 2 week comment period
# 2. Downstream project notification (all consumers named + Slacked)
# 3. New version (v_N+1) published; old version kept with `deprecation_date`
# 4. 6-week deprecation window during which consumers migrate
# 5. Only then the old version is dropped
#
# Non-breaking changes (adding a nullable column) require:
# 1. Standard PR review from the data platform team
# 2. Announcement in #data-consumers
Step-by-step explanation.
- The
platformproject owns the shared plumbing every other domain depends on. Everything in this project isaccess: publicby default — the project is the API. - Contract enforcement is the strictest of any project. Breaking
dim_date's column set breaks every mart in every domain simultaneously — the change-control policy reflects that. - The RFC + 6-week deprecation window is the operational protocol for breaking changes. Consumers get plenty of runway; the platform team gets to make breaking changes eventually, but never surprise-breaks them.
- The public models are chosen for stability, not feature richness.
dim_datehas 5 columns; adding a 6th is a minor version bump. If a consumer needs a project-specific fiscal calendar, they build it inside their own project as a view overdim_date. - The
platformproject's team is the data platform team — deliberately not any single domain's team. This prevents any domain from unilaterally shipping breaking changes to the shared root.
Output.
| Public model | Downstream projects using | Change control |
|---|---|---|
| dim_date | all 5 domains | RFC + 6-week window |
| dim_currency | orders, payments, marketing | RFC + 6-week window |
| dim_country | orders, marketing | RFC + 6-week window |
| fx_rates_daily | payments, orders | RFC + 6-week window |
Rule of thumb. Every mesh needs a platform project at the root of the DAG, owned by the platform team, with the strictest change-control policy. If you can't name the platform project on day one, your split will accumulate duplicated dimensions across domains within six months.
Senior interview question on domain-driven splits
A senior interviewer might ask: "You're picking the boundary for splitting a 400-model monolith. Walk me through the decision framework, name the domains you'd pick, and explain how you'd handle a model that legitimately belongs to two domains."
Solution Using a domain-driven split with a platform root and creator-owned public models
# The mesh topology — one root, three domains, terminal marts
#
# platform
# / | \
# / | \
# / | \
# orders payments marketing
# | | |
# v v v
# (public_orders, public_payments, public_leads etc.)
# |
# v
# product/analytics marts
# projects/platform/dbt_project.yml → owned by data platform team
# projects/orders/dbt_project.yml → owned by orders team
# projects/payments/dbt_project.yml → owned by payments team
# projects/marketing/dbt_project.yml → owned by marketing team
# The creator-owned rule for cross-domain entities:
# customer_id → orders (creator)
# order_id → orders (creator)
# payment_id → payments (creator)
# lead_id → marketing (creator)
# product_id → platform (canonical catalog)
Step-by-step trace.
| Decision | Answer | Rationale |
|---|---|---|
| Split boundary | domain (not layer, not consumer) | Team ↔ project 1:1 |
| Number of projects | 4 (platform + 3 domains) | Fewer than teams = ambiguity; more = orphaned projects |
| Platform ownership | data platform team | Prevents any domain from unilaterally breaking shared plumbing |
| Creator-owned entities | customer_id → orders, payment_id → payments | Creator of the entity owns the public surface |
| Downstream extensions | views inside consuming project | Adds domain-specific columns without polluting the public model |
The final topology is a shallow tree: platform at the root, three domain projects one level down, and terminal marts consuming the domain public models. Ownership is crisp; the public API surface is small; every model has exactly one owner.
Output:
| Project | Owner | Public models | Depends on |
|---|---|---|---|
| platform | data platform | dim_date, dim_currency, dim_country, fx_rates_daily | (root) |
| orders | orders team | public_orders, public_order_lines, public_customers | platform |
| payments | payments team | public_payments, public_refunds | orders + platform |
| marketing | marketing team | public_leads, public_campaigns | orders + platform |
Why this works — concept by concept:
- Creator-owned rule — the domain that creates an entity owns the canonical public surface. Consumers extend via views inside their own projects. This resolves every "which team owns this?" argument in constant time.
- Platform as shared root — a dedicated project for shared plumbing (calendars, currencies, countries) prevents duplicated dims across domains. The platform team's strict change-control keeps the root stable.
- Shallow-tree DAG — root + one middle layer + terminals. Deep chains (A → B → C → D) create long deprecation windows and make impact-analysis hard. A shallow tree keeps blast radius bounded.
-
One project = one team — ownership is coded via CODEOWNERS +
dbt_project.ymlvars + per-modelmeta. Team growth is absorbed by splitting a project, not by adding to a shared repo. - Cost — the boundary decision is ~1 week of senior-engineer + team-lead time to work through with all stakeholders. The recurring cost is O(1) — the tree structure means new domains slot in as new branches without re-organising existing projects.
SQL
Topic — sql
SQL modelling and dimensional-design problems
3. Public models + access + contracts
access: public | protected | private — the model-level access modifier is the mesh's API surface
The mental model in one line: dbt public models are the API of a project — anything marked access: public can be referenced by any other project; anything marked protected (the default) can only be referenced within the same group; anything marked private can only be referenced within the same project — and contracts + versions turn those public models from accidental exposure into *deliberate, stable APIs*. Every downstream project that reaches for a ref('project', 'model') call fails unless the target is access: public.
The three access levels.
-
public. Any project (declaring your project independencies.yml) mayref()this model. Public models are the API surface; they should have contracts and versions. Every public model is a promise the owning team must maintain. -
protected. The default. The model can be referenced within the same group (a dbt-native grouping mechanism inside a project). Cross-projectref()fails. Use for models that are internal to the project but shared across sub-teams. -
private. The model can only be referenced from within the same group in the same project. The most restrictive access — appropriate for staging models, intermediates, and one-off transformations.
The default is protected — and that's usually wrong for staging models.
-
The dbt default. If you don't set
access:, the model isprotected. That's a safe default from a leakage perspective — nothing accidentally becomes public. -
The trap. Teams often forget to mark staging models
private.protectedstaging models can beref()d by other groups within the same project — creating hidden dependencies that survive the mesh split unintentionally. -
The rule. Mark every staging and intermediate model
access: privateunless you specifically want it shared across groups. Explicit is better than default.
Groups — the intra-project access unit.
-
Definition. A
groupis a named collection of models within a single project, owned by a specific person or team. Defined in agroups:block indbt_project.yml. -
Access interaction.
protectedandprivateare scoped by group. Models in group A cannot referenceprivatemodels in group B — even within the same project. - When to use. Larger projects (100+ models) benefit from groups for intra-project ownership. Smaller projects (<50 models) can skip groups entirely.
Model contracts — the API's type signature.
-
What they lock down. Column names, column data types, nullability constraints (
not_null), primary/unique keys, foreign keys. A schema drift in the underlying SQL fails the build. - What they don't lock down. Row-level values, row counts, business logic. Contracts are type-level, not value-level — data tests (Great Expectations, dbt tests) handle the row-level assertions.
-
The build-time check. When
contract: {enforced: true}is set,dbt buildruns an implicitcreate table ... (schema definition) as select ...and fails if the SQL output doesn't match. No configuration; no separate test step.
Versions — the deprecation protocol.
-
The pattern. Public model
dim_customercan exist asdim_customer_v1anddim_customer_v2simultaneously. Both are built; both are materialised; both are queryable. -
The consumer choice. Downstream projects
ref('project', 'dim_customer', v=1)orv=2. The version is part of the reference. -
The deprecation. Set
deprecation_date:on a version. dbt compile emits warnings for consumers still on the deprecated version. After the date, the version can be removed. -
The migration protocol. Publish v2; give consumers 4–6 weeks; set v1's
deprecation_date; watch Discovery API for laggards; drop v1 after consumers migrate.
Common interview probes on public models + contracts.
- "What's the difference between
access: protectedandaccess: private?" — protected is cross-group within a project; private is same-group only. - "What does
contract: {enforced: true}actually enforce at build time?" — columns, types, nullability; fails the build on drift. - "Contract vs schema test — what's the difference?" — contract is type-level build-time; schema tests are value-level post-build.
- "How do you deprecate a public model?" — publish new version, set
deprecation_dateon old, watch Discovery API for consumers, drop after migration.
Worked example — a public model with a full contract
Detailed explanation. The canonical public-model definition: public_customers in the orders project, access: public, contract: {enforced: true}, with column types, nullability, and a primary key constraint. Walk through what happens at build time when the underlying SQL drifts.
- The desired shape. 6 columns: customer_id (bigint, not_null, PK), email (varchar), first_name (varchar), signup_ts (timestamp, not_null), country_code (char(2)), tier (varchar).
-
The drift scenario. A developer refactors the SQL and accidentally omits
country_code. -
The expected outcome.
dbt buildfails inside the orders project with a contract-mismatch error. The developer's PR cannot merge until the SQL matches the contract or the contract is updated (which requires cross-team review because it's a breaking change to the public API).
Question. Write the full schema.yml for public_customers with a contract, then simulate a drift and walk through the build-time failure.
Input.
| Column | Type | Nullability | Constraint |
|---|---|---|---|
| customer_id | bigint | not_null | primary key |
| varchar | nullable | — | |
| first_name | varchar | nullable | — |
| signup_ts | timestamp | not_null | — |
| country_code | char(2) | nullable | — |
| tier | varchar | nullable | — |
Code.
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_customers
description: "Public customer dimension — canonical identity + signup + tier."
access: public
contract:
enforced: true
meta:
owner: "team-orders"
slack: "#team-orders"
sla_hours: 4
columns:
- name: customer_id
description: "Surrogate customer key."
data_type: bigint
constraints:
- type: not_null
- type: primary_key
- name: email
description: "Primary email address; may be null for guests."
data_type: varchar
- name: first_name
description: "First name from signup form."
data_type: varchar
- name: signup_ts
description: "Timestamp of first successful signup event."
data_type: timestamp
constraints:
- type: not_null
- name: country_code
description: "ISO 3166-1 alpha-2 country code."
data_type: char(2)
- name: tier
description: "Customer loyalty tier."
data_type: varchar
-- projects/orders/models/marts/public_customers.sql — CORRECT version
select
customer_id::bigint as customer_id,
lower(email)::varchar as email,
first_name::varchar as first_name,
signup_ts::timestamp as signup_ts,
upper(country_code)::char(2) as country_code,
tier::varchar as tier
from {{ ref('int_customers_enriched') }}
-- --------------------------------------------------------------
-- DRIFTED version (accidentally omits country_code)
-- select
-- customer_id::bigint as customer_id,
-- lower(email)::varchar as email,
-- first_name::varchar as first_name,
-- signup_ts::timestamp as signup_ts,
-- tier::varchar as tier
-- from {{ ref('int_customers_enriched') }}
Step-by-step explanation.
- The
schema.ymldeclares the contract: six columns, exact types, nullability, and a primary key constraint oncustomer_id.contract: {enforced: true}tells dbt to compare the SQL output against this declaration at build time. - The correct SQL (top block) produces exactly the six columns with the exact types. dbt runs
create table public_customers (customer_id bigint not null, ... ) as select ...— the DDL matches the contract; the build passes. - The drifted SQL (commented block) omits
country_code. When dbt runs the build, it prepares the DDL withcountry_code char(2)in the target table schema — then theselectstatement doesn't produce that column, so the insert fails with a warehouse-level error, wrapped by dbt as a contract-mismatch failure. - The error message names the missing column and the SQL file. The developer's PR is red; the merge is blocked. The developer must either add the column back to the SQL (restoring the contract) or open a separate PR to update the contract (which counts as a breaking API change and triggers the deprecation protocol).
- The consumer projects (payments, marketing) are unaffected because the drift was caught inside orders' build. This is the mesh's superpower — contract violations surface at the producer, not at the consumer three DAG hops downstream.
Output.
| Scenario | Build outcome | Consumer impact |
|---|---|---|
| Correct SQL matches contract | passes | consumers unaffected |
| Drifted SQL (missing column) | fails inside orders | consumers unaffected (drift never merges) |
| Contract update PR | passes if reviewed and merged | consumers migrate on next version bump |
Rule of thumb. Every public model must have contract: {enforced: true}. The 5 minutes it takes to write the schema.yml is the cheapest insurance against 4-hour outages caused by upstream schema drift.
Worked example — access: public vs protected vs private in practice
Detailed explanation. The three access levels have distinct downstream visibility, and getting them wrong is the most common mesh mis-configuration. Walk through a real project with a mix of staging, intermediate, and mart models, assign the correct access level to each, and show what happens when a downstream project tries to ref() a protected or private model.
-
The staging models.
stg_shopify_orders,stg_shopify_customers— internal, project-only. Access:private. -
The intermediate models.
int_orders_enriched,int_customers_enriched— shared across groups within the project. Access:protected. -
The mart models.
public_orders,public_customers— cross-project API. Access:public. -
The negative test. A payments project tries to
ref('orders', 'stg_shopify_orders')— should fail at compile time.
Question. Design the access-level assignments for a 100-model orders project split across three groups (staging, marts, exports), and show the compile-time failure for a cross-project ref to a private model.
Input.
| Layer | Model count | Correct access | Which groups can ref? |
|---|---|---|---|
| staging | 40 | private | staging group only |
| intermediate | 30 | protected | any group in project |
| marts (internal) | 20 | protected | any group in project |
| marts (public) | 10 | public | any project |
Code.
# projects/orders/dbt_project.yml
name: orders
version: 2.0.0
groups:
- name: staging
owner:
name: "team-orders-eng"
- name: marts
owner:
name: "team-orders-analytics"
- name: exports
owner:
name: "team-orders-analytics"
# Model paths → group + default access
models:
orders:
staging:
+group: staging
+access: private
intermediate:
+group: marts
+access: protected
marts:
+group: marts
# public/protected set per-model below
exports:
+group: exports
+access: protected
# projects/orders/models/marts/schema.yml — explicit public marks
version: 2
models:
- name: public_orders
access: public
contract: {enforced: true}
- name: public_customers
access: public
contract: {enforced: true}
- name: mart_orders_daily_internal
access: protected # only exposed within the orders project's marts group
# projects/payments/models/marts/mart_finance.sql
# Legitimate: refs the public model
select * from {{ ref('orders', 'public_orders') }}
# Illegal: refs a private staging model — compile-time error
# select * from {{ ref('orders', 'stg_shopify_orders') }}
#
# dbt compile error:
# Node `mart_finance` attempted to reference node `stg_shopify_orders`
# which has access `private`. This node cannot be referenced from
# project `payments`.
Step-by-step explanation.
- The
dbt_project.ymldeclares three groups (staging, marts, exports) and assigns default access per model path.models/staging/**defaults toprivate;models/intermediate/**toprotected;models/marts/**to a group-level default that individual models override. - Public marts (
public_orders,public_customers) are explicitly markedaccess: publicinschema.yml. Internal marts stayprotected— they're shared inside the project but not exposed as cross-project API. - Staging models are
private— the staging group is the only group in the orders project that can reference them. The marts group cannot; the exports group cannot; and certainly no other project can. - When
paymentstries toref('orders', 'public_orders')— the public model — the reference resolves. dbt compiles the query, wires the cross-project lineage edge, and the build succeeds. - When
paymentstries toref('orders', 'stg_shopify_orders')— a private model — dbt fails at compile time with an access-violation error. The check is deterministic and cheap; no build runs; no data moves. The mesh's access model is enforced at compile, not at build.
Output.
| Access level | Cross-project ref? | Same-project cross-group ref? | Same-group ref? |
|---|---|---|---|
| public | yes | yes | yes |
| protected | no | yes | yes |
| private | no | no | yes |
Rule of thumb. Always mark staging models access: private explicitly. The protected default is safer than nothing, but explicit private prevents the "why is marketing referencing my staging model?" bug that appears six months after the mesh split.
Worked example — versioning a public model with deprecation
Detailed explanation. The most operationally intense mesh scenario: public_orders_v1 has been in production for a year, fifteen downstream projects depend on it, and the orders team wants to change the gross_amount column from numeric(18,2) to numeric(20,4) (higher precision for international multi-currency). This is a breaking change — the SQL type differs, downstream marts that cast the column will change behaviour. The correct migration ships v2 alongside v1, gives consumers a deprecation window, and watches the Discovery API for laggards.
- The trigger. International expansion requires higher-precision decimal.
-
The breaking change.
gross_amount numeric(18,2) → numeric(20,4). -
The consumers. 15 downstream projects with
ref('orders', 'public_orders'). -
The protocol. Publish v2; set v1's
deprecation_datefor 6 weeks out; migrate consumers; drop v1.
Question. Show the versions: block for public_orders, the deprecation-date-driven migration protocol, and how a downstream project pins to a specific version.
Input.
| Version | gross_amount type | Status | Deprecation date |
|---|---|---|---|
| v1 | numeric(18,2) | deprecating | 2026-08-15 |
| v2 | numeric(20,4) | current | (none) |
Code.
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
latest_version: 2
access: public
contract: {enforced: true}
columns:
- name: order_id
data_type: bigint
constraints: [{type: not_null}, {type: primary_key}]
- name: customer_id
data_type: bigint
constraints: [{type: not_null}]
- name: status
data_type: varchar
- name: order_ts
data_type: timestamp
versions:
- v: 1
deprecation_date: "2026-08-15"
columns:
# v1 overrides gross_amount type
- include: all
- name: gross_amount
data_type: numeric(18,2)
- v: 2
columns:
- include: all
- name: gross_amount
data_type: numeric(20,4)
-- projects/orders/models/marts/public_orders_v1.sql
select
order_id::bigint as order_id,
customer_id::bigint as customer_id,
round(gross_amount, 2)::numeric(18,2) as gross_amount,
status::varchar as status,
order_ts::timestamp as order_ts
from {{ ref('int_orders_enriched') }}
-- projects/orders/models/marts/public_orders_v2.sql
select
order_id::bigint as order_id,
customer_id::bigint as customer_id,
gross_amount::numeric(20,4) as gross_amount,
status::varchar as status,
order_ts::timestamp as order_ts
from {{ ref('int_orders_enriched') }}
# Downstream consumer — pins to a specific version
# projects/payments/models/marts/mart_finance_daily.sql
# Legacy consumer still on v1 (warning emitted at compile because deprecation_date is set)
# select * from {{ ref('orders', 'public_orders', v=1) }}
# Migrated consumer on v2
select
order_id,
customer_id,
gross_amount::numeric(20,4) as gross_amount,
status,
order_ts
from {{ ref('orders', 'public_orders', v=2) }}
Step-by-step explanation.
- The
schema.ymldeclares both versions: v1 withgross_amount numeric(18,2)and v2 withnumeric(20,4). Both versions are built, materialised, and queryable in the warehouse. -
latest_version: 2tells dbt that a consumer who omits the version defaults to v2. Explicit pinning (v=1) still works and returns v1's shape. -
deprecation_date: "2026-08-15"on v1 tells dbt to emit a compile-time warning for any consumer still on v1. The warning names the consumer project and model — so the producer team can build a migration dashboard from the Discovery API. - Downstream projects migrate at their own pace within the window: they change their
ref()fromv=1(or unversioned) tov=2, adjust downstream SQL to handle the new type, and merge. The producer watches the Discovery API for the count of consumers still on v1. - After the deprecation date, the producer PR that removes
public_orders_v1.sqland the v1 entry fromschema.ymlmerges. Any consumer still on v1 gets a compile error (not a silent breakage) and must migrate to v2 immediately. The old materialised table is dropped by a cleanup script.
Output.
| Timeline | v1 status | v2 status | Consumers on v1 | Consumers on v2 |
|---|---|---|---|---|
| Day 0 | current | (none) | 15 | 0 |
| Day 1 (v2 ships) | current | current | 15 | 0 |
| Day 14 | deprecated | current | 10 | 5 |
| Day 42 (deprecation date) | scheduled removal | current | 1 | 14 |
| Day 45 (post-window) | removed | current | 0 | 15 |
Rule of thumb. Every breaking change to a public model goes through the versions: block with a 4–6 week deprecation window. Big-bang migrations of shared APIs are the mesh's failure mode; the version + deprecation protocol is how you avoid them.
Senior interview question on public models + contracts + versions
A senior interviewer might ask: "You own the orders project in a dbt Mesh. A dozen downstream projects depend on public_orders. You need to change a column's data type. Walk me through the process — the SQL changes, the schema.yml changes, how consumers get notified, and how you know when it's safe to remove v1."
Solution Using versioned public models with a deprecation-date-driven migration
# Step 1 — publish v2 alongside v1
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
latest_version: 2
access: public
contract: {enforced: true}
versions:
- v: 1
deprecation_date: "2026-08-15" # 6 weeks out
columns:
- include: all
- {name: gross_amount, data_type: numeric(18,2)}
- v: 2
columns:
- include: all
- {name: gross_amount, data_type: numeric(20,4)}
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
# Step 2 — query the Discovery API to find consumers still on v1
# https://cloud.getdbt.com/api/discovery
query ConsumersOnV1 {
environment(id: "prod") {
definition {
models(filter: {access: PUBLIC, name: "public_orders", version: "1"}) {
edges {
node {
name
dependencies {
projectName
modelName
}
}
}
}
}
}
}
# Step 3 — after deprecation date, drop v1
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
latest_version: 2
access: public
contract: {enforced: true}
versions:
- v: 2
columns:
- include: all
- {name: gross_amount, data_type: numeric(20,4)}
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: gross_amount, data_type: numeric(20,4), constraints: [{type: not_null}]}
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
Step-by-step trace.
| Step | Action | Consumer effect |
|---|---|---|
| 1 | Publish v2 in schema.yml + SQL file | Both v1 and v2 materialise; consumers unchanged |
| 2 | Set deprecation_date on v1 |
Compile-time warnings for v1 consumers |
| 3 | Post migration guide in Slack + Explorer | Consumers migrate ref() calls at their own pace |
| 4 | Query Discovery API weekly for v1 consumers | Watch the count decline; ping laggards individually |
| 5 | After deprecation date, drop v1 | Any straggler consumer fails compile; migrates immediately |
At the end of the six-week window, all 15 downstream projects have migrated to v2, the compile-time warnings are gone, and the producer PR that drops v1 merges cleanly. The migration protocol scales linearly with consumer count — 15 consumers is manageable manually; 150 needs automation via Discovery API.
Output:
| Metric | Before migration | Day 14 | Deprecation date | After |
|---|---|---|---|---|
| Consumers on v1 | 15 | 10 | 1 | 0 |
| Consumers on v2 | 0 | 5 | 14 | 15 |
| Compile warnings | 0 | 10 | 1 | 0 |
| Contract violations | 0 | 0 | 0 | 0 |
Why this works — concept by concept:
- Versions as first-class artifacts — v1 and v2 are both materialised in the warehouse, both queryable, both contract-enforced. The dual-existence period is what gives consumers the runway to migrate.
-
Deprecation date as a soft deadline —
deprecation_dateemits compile-time warnings without breaking anything. Consumers see a nudge every compile; producers get to enforce a hard deadline after the date passes. - Discovery API for observability — the producer team runs a weekly Discovery API query to count v1 consumers. The count is the migration progress meter; when it hits zero, v1 is safe to drop.
- Compile-time compile-time compile-time — every check in this flow (access, contract, deprecation, version pinning) is a compile-time check. Nothing runs data. Nothing costs warehouse compute to validate. The mesh's checks are cheap.
- Cost — 6 weeks of runway, one Discovery API query per week, one Slack ping per laggard. The cost scales with consumer count linearly; big-bang breaking changes cost 10× more because they trigger downstream outages.
SQL
Topic — sql
SQL contract and schema-drift problems
4. Cross-project refs + dbt Cloud Explorer
{{ ref('project_name', 'model_name') }} — the cross-project reference wired through dependencies.yml
The mental model in one line: cross-project ref is the mesh-native call that lets one project reference another project's public model — declared upstream in dependencies.yml, resolved at compile time by dbt's dependency graph, and rendered in dbt Cloud Explorer as a first-class edge in the cross-team lineage graph — and it fails cleanly at compile if the target isn't access: public or if the target version has been deprecated. Every mesh consumer relationship reduces to this one call.
The three-part call.
-
dependencies.yml. Declares the upstream projects.projects: [{name: orders}, {name: platform}]tells dbt: "our project depends on these two upstream projects." dbt pulls their manifests at compile. -
ref('project_name', 'model_name'). The Jinja call inside SQL. dbt resolves the reference by (a) looking up the model in the upstream project's manifest, (b) checking the access level, (c) checking the version, (d) writing the fully qualified table name into the compiled SQL. -
Optional
v=argument.ref('orders', 'public_orders', v=2)pins to a specific version. Omittingvpickslatest_version.
How dbt Cloud resolves cross-project refs at compile time.
-
Manifest lookup. dbt Cloud has access to every project's manifest (via the mesh-native integration). At compile, the consumer's
ref('orders', 'public_orders')triggers a lookup in the orders project'smanifest.json. -
Access check. If the target isn't
access: public, compile fails with an access-violation error naming both projects. -
Version pin resolution. If
v=is set, dbt resolves to that specific version; otherwiselatest_version. If the requested version is deprecated, a warning is emitted; if it's been removed, compile fails. -
Physical resolution. The compiled SQL contains the fully qualified table name (e.g.
orders_prod.public_orders_v2). No runtime lookup; no metadata fetch during query execution.
dbt Core vs dbt Cloud.
-
dbt Core support. Mesh is supported in dbt Core via
dbt depspulling the upstream project as a package and passing its manifest. The mechanism works; the ergonomics are rough — you have to build the manifest exchange yourself. - dbt Cloud native. Cloud's mesh-native integration handles manifest exchange, access enforcement, version resolution, and Explorer lineage automatically. The UI overhead disappears.
- The recommendation. For production Mesh deployments, dbt Cloud is the ergonomic default. Mesh on Core is achievable but requires more custom tooling (CI-time manifest exchange, Discovery API replacement, etc.).
dbt Cloud Explorer — the cross-team lineage graph.
- What it shows. Every project's models rendered as a single continuous DAG. Cross-project edges are marked visually. Filters for project, group, access level, and owner.
- The impact-analysis view. Click a public model; see every downstream consumer across all projects. The blast-radius question ("what breaks if I change this column?") becomes a UI click.
-
The catalog view. All
access: publicmodels across all projects, searchable by name, description, or column. The mesh'scatalog— new consumers discover which public models exist without reading source code. - The freshness + status view. Every model's last-successful build timestamp, contract compliance, and test status. On-call teams monitor the graph, not the log stream.
Discovery API — programmatic governance.
-
The GraphQL surface.
https://cloud.getdbt.com/api/discovery— a GraphQL API that answers "who depends on this model?", "how many consumers are on v1?", "which public models were built successfully in the last 24 hours?". - Common queries. Impact analysis (downstream consumers of X), migration tracking (consumers on deprecated versions), governance dashboards (public models per project, contract coverage).
- Automation surface. Wire the Discovery API into CI checks ("this PR removes a public model — are there consumers?"), into Slack notifications ("v1 is 5 days from deprecation, 3 consumers haven't migrated"), and into governance dashboards.
Common interview probes on cross-project refs.
- "What does
dependencies.ymldo?" — declares upstream projects so dbt pulls their manifests at compile. - "How does dbt Cloud enforce access at cross-project ref time?" — compile-time manifest lookup + access-level check.
- "Explorer vs Discovery API — when do you reach for each?" — Explorer for humans (interactive lineage), Discovery API for automation (CI checks, dashboards).
- "What happens if a consumer refs a version that's been removed?" — compile fails with a version-not-found error; the consumer must migrate to the current version.
Worked example — a four-project mesh with cross-project refs
Detailed explanation. A concrete end-to-end mesh: platform publishes dim_date and dim_currency; orders publishes public_orders and consumes platform; payments publishes public_payments and consumes orders + platform; analytics (the consumer terminal) consumes payments, orders, and platform. Walk through the dependencies.yml, the cross-project ref() calls, and the resulting lineage graph.
-
Topology.
platform → orders → payments → analytics;platformis also a direct dep of payments and analytics. - The DAG. Shallow-tree: platform at the root, orders one level down, payments and analytics as terminals.
- The public API. Each intermediate project publishes 2–3 public models.
Question. Produce the four dependencies.yml files, the cross-project ref() calls in the analytics project, and describe how Explorer renders the lineage.
Input.
| Project | Depends on | Publishes |
|---|---|---|
| platform | (root) | dim_date, dim_currency |
| orders | platform | public_orders, public_customers |
| payments | orders, platform | public_payments |
| analytics | payments, orders, platform | (terminal marts) |
Code.
# projects/platform/dependencies.yml
# (empty — platform is the root)
projects: []
# projects/orders/dependencies.yml
projects:
- name: platform
# projects/payments/dependencies.yml
projects:
- name: orders
- name: platform
# projects/analytics/dependencies.yml
projects:
- name: payments
- name: orders
- name: platform
-- projects/analytics/models/marts/mart_finance_daily.sql
with
d as (
select date_day
from {{ ref('platform', 'dim_date') }}
where date_day between date '2026-01-01' and date '2026-12-31'
),
o as (
select
order_id,
customer_id,
order_ts::date as order_date,
gross_amount
from {{ ref('orders', 'public_orders', v=2) }}
where order_ts >= date '2026-01-01'
),
p as (
select
order_id,
amount,
refund_amount
from {{ ref('payments', 'public_payments') }}
),
c as (
select currency_code, currency_name
from {{ ref('platform', 'dim_currency') }}
)
select
d.date_day,
sum(o.gross_amount) as gross_revenue,
sum(p.amount - coalesce(p.refund_amount, 0)) as net_revenue,
count(distinct o.order_id) as order_count,
count(distinct o.customer_id) as unique_customers
from d
left join o on o.order_date = d.date_day
left join p using (order_id)
group by 1
order by 1
Step-by-step explanation.
- Each project's
dependencies.ymldeclares its upstream projects. dbt uses this at compile to pull each upstream project'smanifest.jsonand register their public models in the current project's namespace. - The
analyticsproject references four public models across three projects:platform.dim_date,platform.dim_currency,orders.public_orders(pinned to v=2), andpayments.public_payments. Eachref()is resolved against the corresponding upstream manifest. - The compiled SQL replaces each
ref()with the fully qualified table name.ref('orders', 'public_orders', v=2)compiles toorders_prod.public_orders_v2— the physical location in the warehouse. No runtime lookup. - dbt Cloud Explorer renders the lineage as a single continuous DAG: analytics's
mart_finance_dailyhas four incoming edges from four upstream public models across three upstream projects. Cross-project edges are visually distinct (different colour or line style) from in-project edges. - The impact-analysis view for
public_orders_v2shows analytics'smart_finance_dailyas a downstream consumer. If the orders team plans a breaking change, they can query Discovery API for the consumer count before opening the PR.
Output.
| Compiled reference | Resolves to | Explorer edge type |
|---|---|---|
{{ ref('platform', 'dim_date') }} |
platform_prod.dim_date |
cross-project |
{{ ref('orders', 'public_orders', v=2) }} |
orders_prod.public_orders_v2 |
cross-project |
{{ ref('payments', 'public_payments') }} |
payments_prod.public_payments |
cross-project |
{{ ref('platform', 'dim_currency') }} |
platform_prod.dim_currency |
cross-project |
Rule of thumb. Every cross-project reference is one dependencies.yml entry + one ref('project', 'model') call. If a project has more than ~5 upstream project dependencies, the mesh boundary is probably drawn wrong — one project shouldn't need to know about half the mesh.
Worked example — dbt Cloud Explorer for impact analysis
Detailed explanation. The orders team is planning to add a column to public_orders (nullable, non-breaking) and rename a column in public_customers (breaking). Walk through how they use Explorer to enumerate the downstream consumers, plan the deprecation for the breaking change, and communicate the non-breaking addition.
-
The two changes. Add
discount_amount numeric(18,2)topublic_orders(non-breaking); renamefirst_name → given_nameinpublic_customers(breaking). - The tool. Explorer's impact-analysis view + Discovery API for automation.
- The output. A migration plan with consumer counts, timelines, and communication points.
Question. Show the Explorer-driven impact analysis, the Discovery API query, and the migration plan.
Input.
| Change | Type | Consumers on public_orders | Consumers on public_customers |
|---|---|---|---|
| Add discount_amount | non-breaking | 8 | — |
| Rename first_name | breaking | — | 12 |
Code.
# Discovery API — enumerate consumers of both public models
# https://cloud.getdbt.com/api/discovery
query ConsumersOfPublicModels {
environment(id: "prod") {
definition {
publicModelsConsumers: applied {
publicModels {
edges {
node {
name
projectId
projectName
access
versions {
v
deprecationDate
}
dependents {
nodes {
name
projectName
materializedType
filePath
}
}
}
}
}
}
}
}
}
# Migration plan for the breaking change — schema.yml
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_customers
latest_version: 2
access: public
contract: {enforced: true}
versions:
- v: 1
deprecation_date: "2026-09-01"
columns:
- include: all
- {name: first_name, data_type: varchar}
- v: 2
columns:
- include: all
- {name: given_name, data_type: varchar} # renamed from first_name
columns:
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: email, data_type: varchar}
- {name: signup_ts, data_type: timestamp, constraints: [{type: not_null}]}
# Non-breaking addition — no version bump needed
- name: public_orders
latest_version: 2
access: public
contract: {enforced: true}
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: gross_amount, data_type: numeric(20,4), constraints: [{type: not_null}]}
- {name: discount_amount, data_type: numeric(18,2)} # new column, nullable
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
# Automation — post migration reminders to Slack for consumers still on v1
import requests
import os
def get_consumers_on_v1(model_name: str, project_name: str) -> list[dict]:
query = """
query {
environment(id: "prod") {
definition {
publicModels(filter: {name: "%s", projectName: "%s", version: "1"}) {
edges {
node {
dependents {
nodes {
projectName
filePath
}
}
}
}
}
}
}
}
""" % (model_name, project_name)
resp = requests.post(
"https://cloud.getdbt.com/api/discovery",
json={"query": query},
headers={"Authorization": f"Bearer {os.environ['DBT_CLOUD_TOKEN']}"},
)
edges = resp.json()["data"]["environment"]["definition"]["publicModels"]["edges"]
return [d for e in edges for d in e["node"]["dependents"]["nodes"]]
# Weekly Slack ping
consumers = get_consumers_on_v1("public_customers", "orders")
for c in consumers:
print(f"WARN: {c['projectName']} / {c['filePath']} still on public_customers v1 — deprecation 2026-09-01")
Step-by-step explanation.
- The Explorer impact-analysis view lists both public models with their downstream consumers.
public_ordershas 8 consumers across 4 projects;public_customershas 12 consumers across 5 projects. - For the non-breaking
discount_amountaddition, the migration plan is trivial: add the column to the contract; update the SQL; ship. Consumers can start using the column on their nextdbt buildwithout any code change; a Slack announcement in#data-consumersis the only communication. - For the breaking
first_name → given_namerename, the migration plan uses the version protocol: publish v2 (withgiven_name); set v1'sdeprecation_datefor 6 weeks out; migrate consumers; drop v1. Every step is code, none is manual coordination. - The Discovery API query enumerates consumers of v1 programmatically. Wired into a weekly cron, the query outputs a list of consumer projects and their file paths — feeding into a Slack reminder pipeline that names the laggards without human intervention.
- Explorer shows the migration progress visually: as consumers migrate their
ref()calls from v1 to v2, the edge count on v1 declines. When it reaches zero, v1 is safe to drop.
Output.
| Change | Type | Consumers | Plan |
|---|---|---|---|
| Add discount_amount | non-breaking | 8 | Ship on next PR; Slack announcement |
| Rename first_name → given_name | breaking | 12 | v2 + 6-week deprecation + Discovery API monitoring |
Rule of thumb. Explorer is for humans (visual lineage, planning, communication). Discovery API is for automation (CI checks, deprecation reminders, governance dashboards). Every mesh needs both.
Worked example — Discovery API for governance dashboards
Detailed explanation. A senior analytics engineering lead wants a governance dashboard — one view that shows, across every project in the mesh, (a) how many public models have contracts, (b) how many are on the latest version, (c) how many downstream consumers each has, and (d) any public models built with test failures in the last 24 hours. The Discovery API is the source; a scheduled query populates a small governance table, and a BI tool renders the dashboard.
- The metrics. Contract coverage %, latest-version %, consumer count per public model, test failure count.
- The source. Discovery API GraphQL queries.
-
The materialisation. A small
governance_public_modelstable refreshed nightly.
Question. Build the GraphQL query, the loading Python job, and the SQL for the dashboard.
Input.
| Metric | Query source |
|---|---|
| Contract coverage | Discovery API — contract.enforced field |
| Latest version | Discovery API — latestVersion vs versions
|
| Consumer count | Discovery API — dependents field |
| Test status | Discovery API — runResults field |
Code.
# Discovery API — governance query
query GovernanceMetrics {
environment(id: "prod") {
definition {
publicModels: applied {
publicModels(first: 200) {
edges {
node {
name
projectName
access
contract {
enforced
}
latestVersion
versions {
v
deprecationDate
}
dependents {
totalCount
}
runResults(last: 1) {
status
testResults {
status
testName
}
}
}
}
}
}
}
}
}
# Load into a governance table nightly
import requests
import os
import json
from datetime import datetime
def load_governance_snapshot(warehouse_conn):
resp = requests.post(
"https://cloud.getdbt.com/api/discovery",
json={"query": open("governance.graphql").read()},
headers={"Authorization": f"Bearer {os.environ['DBT_CLOUD_TOKEN']}"},
)
edges = resp.json()["data"]["environment"]["definition"]["publicModels"]["publicModels"]["edges"]
with warehouse_conn.cursor() as cur:
cur.execute("truncate table governance_public_models")
for e in edges:
m = e["node"]
cur.execute(
"""
insert into governance_public_models (
snapshot_ts, project_name, model_name, contract_enforced,
latest_version, deprecated_versions, dependent_count,
last_run_status, failed_test_count
) values (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
datetime.utcnow(),
m["projectName"],
m["name"],
m["contract"]["enforced"],
m["latestVersion"],
[v["v"] for v in m["versions"] if v.get("deprecationDate")],
m["dependents"]["totalCount"],
m["runResults"][0]["status"] if m["runResults"] else None,
sum(1 for t in (m["runResults"][0]["testResults"] if m["runResults"] else []) if t["status"] == "fail"),
),
)
-- Governance dashboard queries
-- Metric 1: contract coverage per project
select
project_name,
count(*) as total_public_models,
sum(case when contract_enforced then 1 else 0 end) as with_contract,
round(
sum(case when contract_enforced then 1 else 0 end)::numeric
/ nullif(count(*), 0)::numeric * 100.0,
1
) as contract_coverage_pct
from governance_public_models
group by project_name
order by contract_coverage_pct;
-- Metric 2: models with deprecated versions still around
select project_name, model_name, deprecated_versions, dependent_count
from governance_public_models
where cardinality(deprecated_versions) > 0
and dependent_count > 0
order by dependent_count desc;
-- Metric 3: public models with failing tests in the last 24h
select project_name, model_name, dependent_count, failed_test_count
from governance_public_models
where failed_test_count > 0
order by failed_test_count desc, dependent_count desc;
Step-by-step explanation.
- The GraphQL query pulls every public model across every project in the environment, along with contract status, versions, dependent count, and latest run result. The response is a single JSON blob keyed by project + model.
- The Python loader (a) hits the Discovery API endpoint with the query, (b) parses the response, and (c) writes one row per public model into
governance_public_models. Truncate-and-load pattern; the table is a snapshot, not a slowly-changing dimension. - The dashboard SQL runs three governance queries: contract coverage per project (should be 100% for a healthy mesh), models with deprecated versions still having consumers (the migration-progress dashboard), and public models with failing tests in the last 24h (the on-call view).
- The job runs on a nightly schedule. The governance dashboard reflects yesterday's state; for real-time incidents, the on-call still queries Discovery API directly. The materialised table is for the executive-facing weekly review, not for live incidents.
- The whole pipeline — GraphQL, Python loader, SQL dashboard — is roughly 100 lines of code. The mesh governance story is not a separate product; it's a thin layer over the existing Discovery API. Every senior analytics engineering team should ship this on day one of Mesh.
Output.
| Governance metric | Healthy value | Alert threshold |
|---|---|---|
| Contract coverage per project | 100% | < 90% |
| Public models with active consumers on deprecated versions | 0 | > 0 after deprecation date |
| Public models with failing tests in last 24h | 0 | > 0 for 2 consecutive scrapes |
| Public models on latest version | 100% | < 95% |
Rule of thumb. Every mesh needs a governance dashboard powered by Discovery API. Contract coverage, deprecation-window compliance, and public-model test health are the three metrics an engineering lead actually needs to see weekly.
Senior interview question on cross-project refs + Explorer + Discovery API
A senior interviewer might ask: "You're rolling out a new mesh with 4 projects and 15 public models. Walk me through how you'd wire cross-project refs, what you'd build in Explorer, and what governance you'd automate via the Discovery API — including one CI check that would prevent a bad public-model change from merging."
Solution Using cross-project refs + Explorer + Discovery-API-driven CI
# projects/analytics/dependencies.yml
projects:
- name: platform
- name: orders
- name: payments
- name: marketing
-- projects/analytics/models/marts/mart_customer_360.sql
with
o as (select customer_id, sum(gross_amount) as gross_ltv
from {{ ref('orders', 'public_orders', v=2) }}
group by customer_id),
p as (select customer_id, sum(amount - coalesce(refund_amount, 0)) as net_ltv
from {{ ref('payments', 'public_payments') }}
group by customer_id),
m as (select customer_id, first_campaign_id, attribution_channel
from {{ ref('marketing', 'public_lead_attribution') }}),
c as (select customer_id, email, signup_ts, country_code
from {{ ref('orders', 'public_customers', v=2) }})
select
c.customer_id,
c.email,
c.signup_ts,
c.country_code,
coalesce(o.gross_ltv, 0) as gross_ltv,
coalesce(p.net_ltv, 0) as net_ltv,
m.attribution_channel,
m.first_campaign_id
from c
left join o using (customer_id)
left join p using (customer_id)
left join m using (customer_id)
# CI check — Discovery API query that fails a PR removing a public model with consumers
# .github/actions/public-model-safety/main.py
import os
import sys
import subprocess
import requests
def public_models_in_pr() -> set[str]:
"""Detect public models touched by this PR."""
diff = subprocess.check_output(["git", "diff", "--name-only", "origin/main..."]).decode()
return {f for f in diff.splitlines() if f.endswith(".sql") and "/marts/public_" in f}
def dependents_of(model_name: str) -> int:
query = """
query {
environment(id: "prod") {
definition {
publicModels(filter: {name: "%s"}) {
edges {
node { dependents { totalCount } }
}
}
}
}
}
""" % model_name
resp = requests.post(
"https://cloud.getdbt.com/api/discovery",
json={"query": query},
headers={"Authorization": f"Bearer {os.environ['DBT_CLOUD_TOKEN']}"},
)
edges = resp.json()["data"]["environment"]["definition"]["publicModels"]["edges"]
return sum(e["node"]["dependents"]["totalCount"] for e in edges)
def main():
removed_or_broken = [f for f in public_models_in_pr() if not os.path.exists(f)]
for m in removed_or_broken:
model_name = os.path.basename(m).replace(".sql", "")
count = dependents_of(model_name)
if count > 0:
print(f"BLOCK: PR removes public model {model_name} — {count} downstream consumers")
sys.exit(1)
print("OK: no public models with active consumers were removed")
if __name__ == "__main__":
main()
Step-by-step trace.
| Step | Action | Effect |
|---|---|---|
| 1 |
dependencies.yml declares 4 upstream projects |
dbt pulls their manifests at compile |
| 2 | Cross-project ref() calls across 4 projects |
Explorer renders the DAG with cross-project edges |
| 3 | Discovery API weekly governance dashboard | Contract coverage, deprecation compliance visible |
| 4 | CI check on public-model removal | PR blocked if consumers exist |
| 5 | Slack reminder for consumers on deprecated versions | Migration progress tracked without human coordination |
The end-state is a mesh where every cross-project reference is deliberate, every access violation is caught at compile, every breaking change goes through the version protocol, and every governance decision is code — not tribal knowledge.
Output:
| Surface | Result |
|---|---|
| Cross-project compile failures | zero (all refs resolve cleanly) |
| Contract coverage | 100% across public models |
| Deprecation window compliance | tracked weekly, zero missed windows |
| PR-time public-model removals with consumers | zero (CI blocks) |
| Explorer lineage completeness | one continuous DAG across all 4 projects |
Why this works — concept by concept:
-
dependencies.ymlas first-class dependency graph — the file is the single source of truth for cross-project deps. dbt uses it to fetch manifests; Explorer uses it to render the DAG; Discovery API uses it to answer "who depends on whom." - Compile-time enforcement — access levels, contracts, and version pins are all checked at compile. No compute runs; no data moves. Bad references are caught before any warehouse cost is incurred.
- Explorer for humans + Discovery API for automation — Explorer is the interactive view; Discovery API is the programmatic view. Every mesh needs both, and the automation surface is where the mesh's operational leverage lives.
- CI check as a safety net — the public-model-removal CI check is 30 lines of Python. Not writing it is the reason "we accidentally deleted a public model with 12 consumers" incidents happen. Ship it on day one.
- Cost — the infrastructure cost is O(1) — one Discovery API endpoint, one Explorer UI, one CI check. The recurring cost is one weekly dashboard review. The avoided cost is one 4-hour production incident per quarter, comfortably.
SQL
Topic — sql
SQL lineage and dependency-graph problems
5. Migration + governance
The five governance patterns every mesh lead ships — migration path, ownership matrix, deprecation policy, slim CI, contract enforcement
The mental model in one line: a healthy dbt data mesh in production is the cumulative effect of a domain-by-domain migration path, an ownership matrix coded in CODEOWNERS + dbt_project.yml, a deprecation policy with a 4–6 week runway, slim CI at each project boundary, and contract enforcement on every public model — miss any one, and the mesh degrades to "monolith with more repos". None of these is exotic; missing any one is a governance failure waiting to happen.
Pattern 1 — the migration path in full.
- Extract one domain first. Never split all domains simultaneously — the coordination cost is quadratic. Pick the domain with the clearest ownership, the fewest cross-team dependencies, and the smallest surface area.
- Publish the public models. Before flipping consumers, ensure the extracted domain's public API is stable. Contracts on every public model; versions declared even for v1.
-
Cutover consumers one at a time. Consumer projects migrate their
ref()calls from monolithicref('model')to cross-projectref('project', 'public_model'). One consumer PR per week. - Archive the monolith for that domain. After all consumers migrate, delete the domain's models from the monolith. The monolith shrinks with each domain extracted.
- Repeat. Extract the next domain. The playbook stabilises after the first extraction; extractions 2, 3, 4 are progressively faster.
Pattern 2 — the ownership matrix.
-
Project-level.
CODEOWNERSat the repository level mapsprojects/<name>/**to exactly one GitHub team. -
Model-level. Every public model has
meta.owner,meta.slack,meta.escalation,meta.sla_hours— surfaced in Explorer and Discovery API. -
Project-level metadata.
dbt_project.ymlvarsrecords the owning team, primary Slack channel, and on-call rotation. - The invariant. Every model has exactly one owner. Shared ownership is disallowed; disputes are resolved by moving the model to the domain that creates the underlying entity.
Pattern 3 — the deprecation policy.
- Runway. 4 weeks minimum, 6 weeks recommended. Long enough for downstream consumers to plan and ship migrations; short enough that v1 and v2 don't linger indefinitely.
-
Announcement.
deprecation_date:inschema.yml(compile-time warnings) + Slack announcement in#data-consumers+ Discovery-API-driven migration progress dashboard. -
The escalation. Consumers still on v1 within 1 week of deprecation date get a personal Slack ping from the producer's owner (surfaced from
meta.owner). - The removal. After the deprecation date, the producer's PR that removes v1 merges cleanly. Any straggler consumer fails compile — a hard nudge to migrate immediately.
Pattern 4 — slim CI at the project boundary.
-
In-project CI.
dbt build --select state:modified+— builds only the changed models plus their in-project downstream. 5–15 minutes wall time. -
Cross-project CI trigger. Public-model contract changes trigger a downstream
dbt compilein every consumer project. If the compile fails, the PR is blocked. This is the mesh's "cross-team CI" — cheap, targeted, and surface-only (no data movement). -
Full-mesh CI on schedule. Once a day, run a full-mesh
dbt build --select +tag:publicacross every project. Catches drift that in-PR checks might miss.
Pattern 5 — contract enforcement on every public model.
-
The invariant. Every
access: publicmodel hascontract: {enforced: true}. Contract without enforcement is theatre. - The CI check. A governance check enumerates public models and asserts contract enforcement; a PR that adds a public model without a contract is blocked.
- The compliance report. Discovery-API-driven weekly governance dashboard shows contract coverage per project; leadership expects 100% and any dip is an audit trail.
Common interview probes on migration + governance.
- "How do you extract the first domain from a monolith?" — pick a domain with clear ownership, publish public models, cutover consumers, archive.
- "What's the deprecation window for a breaking change?" — 4–6 weeks; enforced via
deprecation_dateand Discovery API monitoring. - "How do you enforce that every public model has a contract?" — CI check against Discovery API; PR blocks if a public model lacks
contract: {enforced: true}. - "What's slim CI in a mesh context?" — per-project
state:modified+build plus cross-project contract compile check on changed public models.
Worked example — extracting the first domain from a monolith
Detailed explanation. The team decides to extract orders first (clearest ownership, largest CI-time saver). Walk through the four-week extraction, week by week, including the monorepo layout change, the moved models, the published contracts, and the consumer cutover.
-
Week 1. Scaffold the orders project inside the monorepo (
projects/orders); move the orders-domain models; ensure it builds green in isolation. -
Week 2. Define the public API (
public_orders,public_order_lines,public_customers) with contracts; declare v1 with a placeholder for future versions. -
Week 3. Migrate downstream consumers one at a time — payments' marts, marketing's marts, product's marts. Each migration is a PR that changes
ref('orders_model')toref('orders', 'public_orders'). - Week 4. Archive orders-domain models in the old monolith; enable per-project CI; ship the governance dashboard.
Question. Produce the week-by-week deliverables, the file-tree changes, and the CI configuration.
Input.
| Week | Deliverable | Success criterion |
|---|---|---|
| 1 | orders project scaffolded |
cd projects/orders && dbt build green |
| 2 | Public API published | 3 public models with contracts |
| 3 | Consumers migrated | 100% of consumer refs use ref('orders', ...)
|
| 4 | Monolith cleanup | Orders models removed from monolith; per-project CI live |
Code.
# Week 1 — file tree change
# BEFORE:
# /
# ├── dbt_project.yml (monolith)
# ├── models/
# │ ├── staging/orders/
# │ ├── intermediate/orders/
# │ └── marts/orders/
# └── ...
# AFTER (week 1):
# /
# ├── dbt_project.yml (monolith, orders models removed)
# ├── projects/
# │ └── orders/
# │ ├── dbt_project.yml
# │ ├── models/
# │ │ ├── staging/
# │ │ ├── intermediate/
# │ │ └── marts/
# │ └── dependencies.yml (empty for now)
# └── ...
# projects/orders/dbt_project.yml
name: orders
version: 1.0.0
profile: prod
model-paths: [models]
# Week 2 — publish the public API
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
access: public
contract: {enforced: true}
versions:
- v: 1
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: gross_amount, data_type: numeric(18,2), constraints: [{type: not_null}]}
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
- name: public_order_lines
access: public
contract: {enforced: true}
versions: [{v: 1}]
columns:
- {name: order_line_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: order_id, data_type: bigint}
- {name: product_id, data_type: bigint}
- {name: quantity, data_type: int}
- {name: unit_price, data_type: numeric(18,2)}
- name: public_customers
access: public
contract: {enforced: true}
versions: [{v: 1}]
columns:
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: email, data_type: varchar}
- {name: signup_ts, data_type: timestamp, constraints: [{type: not_null}]}
# Week 3 — consumer migration (payments PR)
# projects/payments/dependencies.yml
projects:
- name: orders
# projects/payments/models/marts/mart_finance_daily.sql
# BEFORE (monolithic ref)
# select ... from {{ ref('public_orders') }}
# AFTER (cross-project ref)
select ...
from {{ ref('orders', 'public_orders', v=1) }}
# Week 4 — CI per project
# .github/workflows/orders-ci.yml
name: orders-ci
on:
pull_request:
paths: ['projects/orders/**']
jobs:
build:
runs-on: ubuntu-latest
defaults: {run: {working-directory: projects/orders}}
steps:
- uses: actions/checkout@v4
- run: |
dbt deps
dbt build --select state:modified+
# .github/workflows/monolith-ci.yml
name: monolith-ci
on:
pull_request:
paths: ['models/**', 'dbt_project.yml']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
dbt deps
dbt build --select state:modified+
Step-by-step explanation.
- Week 1 focuses on the mechanical scaffold: create
projects/orders/, move the orders-domain models, adjust ref paths, ensure the project builds green in isolation. No consumer changes yet. The old monolith still contains a copy of the orders models — deliberately, so consumers keep working during the extraction. - Week 2 focuses on the public API. Every model destined to be consumed by other domains is marked
access: publicwithcontract: {enforced: true}and aversions:block starting at v1. This is the promise the orders team is making to the rest of the mesh; get it right before flipping consumers. - Week 3 migrates consumers one at a time. Each consumer PR (a) adds
- name: orderstodependencies.ymland (b) changesref('public_orders')toref('orders', 'public_orders', v=1). The change is mechanical and non-breaking — the data is the same; the SQL is one line different. Reviewers focus on lineage correctness, not on migration risk. - Week 4 archives the orders models in the old monolith. Once all consumers use the cross-project ref, the monolith's
stg_orders,int_orders,public_ordersetc. can be removed; the CI reflects the new topology (monolith-cifor what's left;orders-cifor the extracted project). - The governance dashboard goes live at end of week 4: contract coverage for orders' public models is 100%; consumer count per public model matches expectations; the migration success metric is "zero downstream references to the monolithic
public_orders."
Output.
| Week | Deliverable | Metric |
|---|---|---|
| 1 | orders project scaffolded | Green in-isolation build |
| 2 | 3 public models with contracts | 100% contract coverage |
| 3 | Consumers migrated | 0 remaining monolithic refs |
| 4 | Monolith cleaned | orders models deleted from monolith |
Rule of thumb. Extract one domain per month, not one per week. The bottleneck is consumer migration coordination, not the mechanical work of moving files. Move faster than the consumers can absorb and you accumulate half-migrated consumer projects that block future extractions.
Worked example — the deprecation policy in action
Detailed explanation. Six months into the mesh, orders wants to change public_orders's gross_amount type. The deprecation policy — 6 weeks, Discovery-API monitoring, Slack pings for laggards — turns a potentially chaotic breaking change into a predictable, documented migration.
-
Change.
gross_amount numeric(18,2) → numeric(20,4)(higher precision for international multi-currency). -
Consumers. 12 downstream projects use
public_orders. -
Policy. Publish v2; set v1's
deprecation_datefor 6 weeks out; weekly Discovery API check; personal Slack ping for laggards; drop v1 after deprecation date.
Question. Show the deprecation policy as code — schema.yml, CI check, and the Slack ping automation.
Input.
| Metric | Day 0 | Day 21 | Day 42 |
|---|---|---|---|
| Consumers on v1 | 12 | 6 | 0 |
| Consumers on v2 | 0 | 6 | 12 |
| Compile warnings emitted | 12 | 6 | 0 |
| Slack pings sent | 0 | 3 | 0 |
Code.
# projects/orders/models/marts/schema.yml
version: 2
models:
- name: public_orders
latest_version: 2
access: public
contract: {enforced: true}
versions:
- v: 1
deprecation_date: "2026-08-15"
columns:
- include: all
- {name: gross_amount, data_type: numeric(18,2)}
- v: 2
columns:
- include: all
- {name: gross_amount, data_type: numeric(20,4)}
columns:
- {name: order_id, data_type: bigint, constraints: [{type: not_null}, {type: primary_key}]}
- {name: customer_id, data_type: bigint, constraints: [{type: not_null}]}
- {name: status, data_type: varchar}
- {name: order_ts, data_type: timestamp}
# scripts/deprecation_slack.py — weekly cron
import os
import requests
from datetime import date, timedelta
DEPRECATION_DATE = date(2026, 8, 15)
def consumers_on_deprecated_version(model_name: str, project: str, version: int) -> list[dict]:
query = """
query {
environment(id: "prod") {
definition {
publicModels(filter: {name: "%s", projectName: "%s", version: "%d"}) {
edges { node { dependents { nodes { projectName filePath } } } }
}
}
}
}
""" % (model_name, project, version)
resp = requests.post(
"https://cloud.getdbt.com/api/discovery",
json={"query": query},
headers={"Authorization": f"Bearer {os.environ['DBT_CLOUD_TOKEN']}"},
)
edges = resp.json()["data"]["environment"]["definition"]["publicModels"]["edges"]
return [d for e in edges for d in e["node"]["dependents"]["nodes"]]
def post_to_slack(msg: str, channel: str):
requests.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {os.environ['SLACK_TOKEN']}"},
json={"channel": channel, "text": msg},
)
def main():
days_left = (DEPRECATION_DATE - date.today()).days
consumers = consumers_on_deprecated_version("public_orders", "orders", 1)
if not consumers:
return
if days_left <= 7:
# Personal ping
for c in consumers:
post_to_slack(
f":warning: {c['projectName']} — {c['filePath']} still on public_orders v1 "
f"({days_left} days until removal). Please migrate.",
channel=f"#team-{c['projectName']}"
)
else:
# Weekly summary in shared channel
summary = f"{len(consumers)} consumers still on public_orders v1 (deprecation: {DEPRECATION_DATE})"
post_to_slack(summary, "#data-consumers")
if __name__ == "__main__":
main()
Step-by-step explanation.
- Day 0: orders team ships the schema.yml change +
public_orders_v2.sql. Both versions build. Consumers see no immediate change — v1 continues to work. - Day 0–35:
deprecation_dateon v1 emits compile-time warnings on every downstreamdbt compile. The weekly Slack summary posts to#data-consumers. Consumers migrate at their own pace; the count of v1 consumers declines steadily. - Day 35 (T-7 days): the automation switches from "weekly summary" to "personal ping." Each remaining v1 consumer gets a Slack message in their team channel naming the specific file that still refs v1.
- Day 42 (deprecation date): the producer PR that removes v1 merges. If any consumer is still on v1, their next compile fails — a hard nudge to migrate immediately. In practice, the personal-ping cadence usually gets everyone across the line by T-1 day.
- Day 45: the Discovery API dashboard shows zero consumers on v1. The producer team runs
dbt run-operation drop_deprecated_v1(or the warehouse-specific cleanup script) to drop the old materialised table.
Output.
| Day | Consumers on v1 | Slack action |
|---|---|---|
| 0 | 12 | Weekly summary posted |
| 7 | 10 | Weekly summary posted |
| 14 | 8 | Weekly summary posted |
| 21 | 6 | Weekly summary posted |
| 28 | 4 | Weekly summary posted |
| 35 (T-7) | 3 | Personal pings to each consumer team |
| 42 (deprecation) | 0 | v1 removed |
Rule of thumb. The deprecation window is 6 weeks + personal pings in the last week. Shorter windows create panic; longer windows create indifference. Six weeks matches the typical sprint cadence and gives every consumer team two sprint boundaries to plan around.
Worked example — slim CI across projects
Detailed explanation. The mesh's CI story has two layers: per-project CI (fast, isolated, changes-only) and cross-project CI (contract compile check on public-model changes). The combination catches all breakages that a monolithic CI would catch, but with a fraction of the wall time and shared-resource contention.
-
Per-project CI.
dbt build --select state:modified+on each project's PRs. 5–15 minutes. -
Cross-project CI. When a PR modifies a
access: publicmodel, a downstreamdbt compileruns in every consumer project. Compile only; no data. 2–5 minutes per consumer. -
Nightly full-mesh CI. Full
dbt build --select +tag:publicacross every project. Catches drift missed by in-PR checks. 30–60 minutes; runs during off-peak hours.
Question. Configure the GitHub Actions workflows for the three CI layers, and show what happens when a PR modifies public_orders.
Input.
| Layer | Trigger | Runtime | Cost |
|---|---|---|---|
| Per-project | project's own PR | 5–15 min | in-PR |
| Cross-project | public-model change | 2–5 min per consumer | in-PR |
| Nightly full-mesh | cron | 30–60 min | off-peak |
Code.
# .github/workflows/orders-ci.yml — per-project
name: orders-ci
on:
pull_request:
paths: ['projects/orders/**']
jobs:
build:
runs-on: ubuntu-latest
defaults: {run: {working-directory: projects/orders}}
outputs:
public_changed: ${{ steps.detect.outputs.public_changed }}
steps:
- uses: actions/checkout@v4
- name: Detect public-model change
id: detect
run: |
if git diff --name-only origin/main... | grep -E 'projects/orders/models/marts/public_'; then
echo "public_changed=true" >> "$GITHUB_OUTPUT"
else
echo "public_changed=false" >> "$GITHUB_OUTPUT"
fi
- name: dbt build (project-scoped)
run: |
dbt deps
dbt build --select state:modified+
# Trigger downstream cross-project compile if a public model changed
cross-project-check:
needs: build
if: needs.build.outputs.public_changed == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
consumer: [payments, marketing, analytics]
steps:
- uses: actions/checkout@v4
- name: dbt compile downstream
working-directory: projects/${{ matrix.consumer }}
run: |
dbt deps
dbt compile
# .github/workflows/nightly-mesh.yml — full-mesh build
name: nightly-mesh
on:
schedule:
- cron: '0 6 * * *' # 06:00 UTC daily
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
project: [platform, orders, payments, marketing, analytics]
steps:
- uses: actions/checkout@v4
- name: dbt build (full public surface)
working-directory: projects/${{ matrix.project }}
run: |
dbt deps
dbt build --select +tag:public
Step-by-step explanation.
- The per-project CI runs on every PR to that project.
state:modified+bounds the build to models the PR changed plus their in-project downstream. Typical wall time is 5–15 minutes. - If the PR touches a
public_*model (detected by filename convention or dbt manifest diff), the CI triggers a downstream matrix job — onedbt compileper consumer project. Compile only; no data; no cost. Any consumer whose SQL breaks against the new public-model contract fails at compile. - The cross-project check is compile, not build. Building would require running the upstream project first, which defeats the "no data" property. Compile verifies that the SQL is valid against the new manifest, which is enough to catch contract-shape breakages.
- The nightly full-mesh CI runs a full
dbt buildon every project's public models. This catches any drift that in-PR checks might miss — for example, a manifest exchange bug or a subtle contract-vs-warehouse discrepancy that only surfaces on a real build. - The three-layer CI has the same coverage as a monolithic CI (every model is built at least once daily) but the per-PR cost is 4–10× cheaper. Reviewers pay for their own changes; nightly automation pays for the shared drift check.
Output.
| Scenario | Layers triggered | Wall time |
|---|---|---|
| PR modifies orders internal model | orders per-project | 8 min |
| PR modifies orders public model | orders per-project + 3 consumer compiles | 8 + 3×3 = 17 min |
| Nightly full-mesh | 5 projects in parallel | 40 min |
| Full monolithic CI (hypothetical) | 500 models | 60 min |
Rule of thumb. Slim CI has three layers: per-project build (5–15 min), cross-project compile on public-model changes (2–5 min per consumer), and nightly full-mesh build (30–60 min). Every mesh needs all three; skipping any one creates a coverage gap that inevitably becomes a production incident.
Senior interview question on migration + governance
A senior interviewer might ask: "You're the analytics engineering lead. Walk me through the first quarter of a mesh migration from a 500-model monolith — the extraction sequence, the governance dashboards you'd stand up, the deprecation policy, and one CI check you'd ship on day one to protect the public API."
Solution Using a domain-per-month extraction, coded governance, and Discovery-API-driven CI
Quarter-1 mesh migration — 500-model monolith → 4-project mesh
==============================================================
Month 1 — Extract orders (the clearest ownership)
Week 1 — scaffold + move models + green in-isolation build
Week 2 — publish public API (3 public models, contracts, v1)
Week 3 — migrate 8 downstream consumers to ref('orders', ...)
Week 4 — archive orders models in monolith + per-project CI live
Deliverable — orders project in production; monolith down 100 models
Month 2 — Extract payments (depends on orders' public API)
Week 1 — scaffold + move models + declare orders as dep
Week 2 — publish public API (2 public models, contracts, v1)
Week 3 — migrate 6 downstream consumers
Week 4 — archive payments models in monolith
Deliverable — payments project in production; monolith down 200 models
Month 3 — Extract marketing + platform (parallel, low-risk)
Weeks 1–4 — same playbook, run in parallel
Deliverable — full 4-project mesh; monolith archived
Continuous — Governance
- Discovery API weekly dashboard: contract coverage, deprecation compliance
- Slack automation: v1 laggard pings 7 days before deprecation
- CI check on day 1: block PR if public model removed with active consumers
- Nightly full-mesh CI: catch drift
# Day-1 CI check — block PR that removes a public model with active consumers
# .github/actions/public-model-safety/main.py (repeated for emphasis)
import os
import sys
import subprocess
import requests
def removed_public_models() -> set[str]:
diff = subprocess.check_output(["git", "diff", "--diff-filter=D", "--name-only", "origin/main..."]).decode()
return {os.path.basename(f).replace(".sql", "")
for f in diff.splitlines()
if "/marts/public_" in f and f.endswith(".sql")}
def dependents_of(model_name: str) -> int:
query = """
query {
environment(id: "prod") {
definition {
publicModels(filter: {name: "%s"}) {
edges { node { dependents { totalCount } } }
}
}
}
}
""" % model_name
resp = requests.post(
"https://cloud.getdbt.com/api/discovery",
json={"query": query},
headers={"Authorization": f"Bearer {os.environ['DBT_CLOUD_TOKEN']}"},
)
edges = resp.json()["data"]["environment"]["definition"]["publicModels"]["edges"]
return sum(e["node"]["dependents"]["totalCount"] for e in edges)
for model in removed_public_models():
n = dependents_of(model)
if n > 0:
print(f"BLOCK: public model {model} removed with {n} active consumers")
sys.exit(1)
print("OK")
Step-by-step trace.
| Month | Activity | Cumulative state |
|---|---|---|
| 1 | Extract orders | orders project live; monolith -100 models |
| 2 | Extract payments | payments project live; monolith -200 models |
| 3 | Extract marketing + platform | 4-project mesh in production; monolith archived |
| Continuous | Governance dashboard + Slack ping + CI check | 100% contract coverage; zero un-migrated consumers |
At end of quarter 1, the monolith is archived; four projects are in production; each project's CI runs in 15 minutes; the governance dashboard shows 100% contract coverage; and the day-one CI check has already caught one attempted breaking removal, saving a downstream outage.
Output:
| Metric | Start of Q1 | End of Q1 |
|---|---|---|
| Projects | 1 (monolith) | 4 (mesh) |
| Model count | 500 | 500 (distributed) |
| CI wall time per PR | 60 min | 15 min per project |
| Contract coverage on public models | undefined | 100% |
| Cross-team blocking commits per month | 5–8 | 0–1 |
| Governance dashboard | none | live |
| Day-one CI checks | none | 3 (contract, removal, deprecation) |
Why this works — concept by concept:
- One domain per month — the sequenced extraction lets each extraction absorb the coordination cost of consumer migrations without piling them up. Big-bang splits accumulate 4-project consumer migrations and stall.
- Public API before consumer cutover — publishing the public models with contracts before flipping any consumer means the API is stable when consumers migrate. Migrating consumers to a moving target creates rework.
- Governance as code — every governance decision (contract coverage, deprecation compliance, public-model removal) is a Discovery API query wired into a CI check or a scheduled dashboard. Tribal knowledge is disallowed.
- Day-one CI check — the public-model-removal safety net is 30 lines of Python. Ship it before the first extraction so no future PR can silently delete a public model with consumers.
- Cost — one senior engineer plus each domain team's involvement for one quarter. The avoided cost is 5–8 cross-team blocking commits per month, saving ~40 engineer-hours monthly. The migration pays back in ~2 quarters and compounds thereafter.
SQL
Topic — sql
SQL mesh-migration and governance problems
Optimization
Topic — optimization
Optimization problems on multi-project pipelines
Cheat sheet — dbt Mesh recipes
- When to split. ~300 models, ~10 engineers, or the second cross-team CI-blocking incident — whichever comes first. Direct-monolith work is fine below these thresholds; the mesh's operational overhead only pays back beyond them.
-
Domain-split boundary. One project per business domain (orders, payments, marketing, product) plus one
platformproject owning shared plumbing. The team boundary is the project boundary. Layer-driven (staging vs marts) and consumer-driven (finance-mart vs growth-mart) splits are anti-patterns. -
Public API surface per project. 3–8
access: publicmodels per project. Every public model hascontract: {enforced: true}and aversions:block. More than 15 public models per project suggests the domain itself needs splitting. -
Access-level defaults. Staging models =
access: private; intermediate models =access: protected(default); public marts =access: public(explicit). Never rely on theprotecteddefault for staging — mark themprivateexplicitly. -
Public model config template.
access: public,contract: {enforced: true},latest_version: N,versions: [{v: 1}, {v: 2, ...}],meta: {owner, slack, escalation, sla_hours}. Contract lists every column withdata_type,constraints, and description. -
Cross-project ref template.
dependencies.yml:projects: [{name: orders}, {name: platform}]. SQL:{{ ref('orders', 'public_orders', v=2) }}. Version pin is optional but recommended for breaking-change safety. -
Deprecation window. 4 weeks minimum, 6 weeks recommended. Set
deprecation_dateon the old version in schema.yml. Emit compile-time warnings; run weekly Discovery API check; personal Slack ping to laggards in the final week; drop old version after date. - Contract vs data test. Contract = type-level (columns, types, nullability) enforced at build time; data test = value-level (row counts, distinct values, cross-model consistency) enforced post-build. Every public model needs both.
-
Discovery API impact-analysis query.
publicModels(filter: {name: "X", projectName: "Y"}) { edges { node { dependents { nodes { projectName filePath } } } } }. Returns every downstream consumer file across every project. Use before breaking changes. -
Slim CI stack. Per-project
state:modified+build (5–15 min), cross-projectdbt compileon public-model change (2–5 min per consumer), nightly full-meshdbt build --select +tag:public(30–60 min, off-peak). Three layers, complete coverage, 4–10× faster per-PR than monolithic CI. -
Ownership matrix.
CODEOWNERSat repository level (projects/<name>/**→ team);dbt_project.ymlvarsfor project owner + Slack + on-call; per-modelmetafor owner + Slack + escalation + SLA. Three complementary places; Explorer surfaces all three. - Governance dashboard. Discovery-API-fed weekly snapshot: contract coverage % per project, public models with active v1 consumers past deprecation date, public models with failing tests in 24h. Contract coverage should be 100%; any dip is an audit trail.
- Day-one CI check. Discovery API query in a GitHub Action that blocks a PR removing a public model with active consumers. 30 lines of Python; ship on day one before the first extraction; catches the "I didn't know anyone was using that" class of incidents.
- dbt Core vs dbt Cloud. Mesh works on both; ergonomics differ. Cloud has native manifest exchange, Explorer, and Discovery API. Core requires custom tooling for the manifest exchange and a self-hosted Discovery-API-like surface. For production Mesh, Cloud is the low-friction default.
Frequently asked questions
What is dbt Mesh and when do I actually need it?
dbt Mesh is the multi-project architecture pattern shipped by dbt Labs across dbt-core 1.6 and dbt Cloud 2023–2026, where a single logical data warehouse is modelled as N domain-aligned dbt projects that expose small, versioned, contract-enforced public APIs to each other via access: public models and cross-project {{ ref('project', 'model') }} calls. You need it when the monolith stops scaling with the team rather than the warehouse — typically around 300 models, 10 engineers, or the second cross-team CI-blocking incident in a quarter. Below those thresholds, a monolithic dbt project with folder-based ownership and shared CI is fine and simpler. Above them, the mesh pays for itself in weeks: 4× faster CI, crisp ownership, contract-enforced schema drift catching, and cross-team lineage in Explorer. The right way to think about Mesh is as "microservices for analytics engineering" — bounded contexts, published APIs, versioned contracts — reapplied to the dbt DAG.
Do I need dbt Cloud for Mesh, or does dbt Core support it?
dbt Core supports Mesh mechanically — dbt deps can pull an upstream project as a package, manifest exchange between projects is possible, and {{ ref('project', 'model') }} resolves against the imported manifest. In practice, Core-only Mesh requires significant custom tooling: manifest exchange in CI, a Discovery-API-like surface for governance queries, and a lineage visualisation layer. dbt Cloud is the ergonomic default for production Mesh — native manifest exchange between projects (no CI plumbing), dbt Cloud Explorer for cross-team lineage and impact analysis (no self-hosted UI), Discovery API for programmatic governance (no custom GraphQL server), and access + contract enforcement wired end-to-end. For most senior analytics engineering teams evaluating Mesh, the recommendation is Cloud for production and Core for local development / smaller experiments.
Public vs protected vs private models — when do I pick each?
access: public is the API — any project (declaring your project in dependencies.yml) can {{ ref('project', 'model') }} it. Public models must have contract: {enforced: true} and a versions: block; they are a promise the owning team maintains under a deprecation policy. Use for 3–8 mart-layer models per project that other domains genuinely consume. access: protected is the default; the model is referenceable within the same group in the same project. Use for intermediate models and internal marts that sub-teams inside the project share. access: private is the most restrictive — the model can only be referenced from within the same group in the same project. Use for staging models and one-off transformations. The most common mistake is leaving staging models at the protected default; explicit private prevents accidental intra-project coupling that survives the mesh split unintentionally. The rule: staging = private, intermediate = protected, public marts = public, and always be explicit.
How do I version a public model without breaking downstream consumers?
Every breaking change to an access: public model goes through the versions: block with a deprecation window. Publish v2 alongside v1 by adding - v: 2 to the versions: block and creating public_model_v2.sql with the new shape. Both versions build, materialise, and are queryable. Set deprecation_date on v1 for 4–6 weeks out — dbt emits compile-time warnings for every consumer still on v1, and the latest_version moves to 2. Monitor consumer migration via Discovery API — a weekly GraphQL query returns the count of consumers still on v1, and a Slack automation pings laggards individually 7 days before the deprecation date. Drop v1 after the deprecation date in a producer PR; any straggler consumer fails compile — a hard nudge to migrate immediately. The whole protocol is code — schema.yml, Discovery API, Slack — not manual coordination. The recommendation is to reserve the version protocol for genuinely breaking changes (type changes, column removals) and to ship non-breaking additions (new nullable columns) as a normal PR without a version bump.
Contract vs schema test — what's the difference and which do I need?
Model contracts are type-level declarations enforced at build time: contract: {enforced: true} in schema.yml lists every column with data_type, nullability, and constraints. dbt runs create table (schema) as select ... and fails the build if the SQL output doesn't match the declaration. Contracts freeze the shape of the model — column names, types, and nullability. Schema tests (unique, not_null, accepted_values, relationships, dbt_utils.expression_is_true, etc.) are value-level assertions run after the build — they check that the data satisfies constraints beyond just the schema. Tests can catch data-quality issues that a contract cannot — a unique test catches accidental duplicates; a relationships test catches broken foreign keys; a custom expression catches business-rule violations. Every public model needs both — a contract to freeze the shape (schema drift = build fails inside the producing project) and a test suite to validate the values (data quality = post-build alert). Ship them together on every public model; contracts without tests are shape without substance; tests without contracts are substance without shape.
Should I always split my dbt project into Mesh?
No — the mesh is not universally the right answer. Below ~300 models and ~10 engineers, a monolithic dbt project with folder-based ownership, CODEOWNERS, and a shared CI is simpler and pays back faster than a mesh migration. The operational overhead of the mesh — separate dbt_project.ymls, cross-project ref discipline, contract governance, deprecation windows, Discovery API monitoring — has fixed setup cost and only amortises across many teams. The trigger to split is one of three signals: (1) the second cross-team CI-blocking incident in a quarter, (2) 300+ models where per-PR CI wall time exceeds 30 minutes, or (3) 10+ engineers where informal ownership is producing "who owns this model?" arguments weekly. Even when the trigger hits, extract one domain first, prove the pattern, and only then extract the second and third. Big-bang splits into 4 projects simultaneously usually fail at the coordination-cost step; the domain-per-month cadence is what actually ships. For teams below the threshold, invest in CODEOWNERS, groups, access: private/protected/public, and contracts inside the monolith — you'll get 60% of the mesh's benefits with 10% of the setup cost, and you'll be ready to split cleanly when the threshold arrives.
Practice on PipeCode
- Drill the SQL practice library → for the modelling, dimensional-design, and schema-drift problems that translate directly to dbt Mesh public-model contracts.
- Rehearse on the ETL practice library → for the multi-project pipeline patterns, orchestration boundaries, and cross-team data contracts that mesh migrations depend on.
- Sharpen the modelling axis with the optimization practice library → for the data-model architecture, versioned API, and cross-team governance problems senior interviewers probe.
- Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the domain-split + contracts + versioning intuition against real graded inputs.
Lock in dbt Mesh muscle memory
dbt docs explain the config. PipeCode drills explain the decision — when to split a monolith, which model becomes `access: public`, when a contract change needs a version bump, and how to migrate 15 downstream consumers on a 6-week deprecation window. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the modelling and governance trade-offs senior analytics engineers actually face.





Top comments (0)