Back to Blog
dbtdbt MeshData EngineeringAnalytics EngineeringSQLData GovernanceData ContractsCI/CDData Meshdbt Core

dbt Mesh — Cross-Project References, Contracts, and Federated Data Ownership

A practical guide to dbt Mesh: splitting monolithic dbt projects into domain-owned sub-projects with model access levels (private, protected, public) that enforce visibility rules at compile time, model groups with named team owners that scope private access within a project, cross-project ref() dependencies declared in dependencies.yml that resolve upstream public models from their compiled manifests without re-running upstream SQL, model contracts with enforced: true that validate column names and data types at dbt compile time before any SQL executes preventing silent breaking changes to public interfaces, a full YAML schema.yml contract declaration for fct_orders and dim_customers with column-level not_null and primary_key constraints, model versioning with latest_version and per-version defined_in pointers that let downstream consumers pin to v1 while migrating to v2 at their own pace, deprecation_date on versioned models that injects CI warnings after the deadline, monorepo layout with platform, marketing, and finance subdirectories each as independent dbt_project.yml roots, dbt_project.yml directory-level access and contract configuration for staging (private), core (public + contract enforced), and marts (protected), GitHub Actions slim CI using state:modified+ and --defer to run only changed models against production state for PR validation, cross-project consumer CI that downloads upstream manifests from S3 before compiling to validate cross-project refs, MetricFlow semantic model and metric definitions on public fct_orders for reusable revenue and order_count metrics across downstream projects, access policy enforcement that emits a compile-time error when a consumer attempts to reference a protected model from a different project, a Makefile for monorepo local development standardizing compile ordering and defer-based slim runs, and a 10-point production checklist covering access level auditing, contract-first public models, S3 manifest storage, dbt version pinning across projects, deprecation date discipline, model group governance, source freshness separation, shared macro packages, and dbt docs as the primary API discovery surface.

2026-06-22

The Monolithic dbt Project Problem

A single dbt project containing hundreds or thousands of models, owned by five or more teams, becomes a coordination bottleneck before you know it. Any team that wants to add a model needs to open a PR into the shared repo. Any team that wants to rename a column in their domain needs to check that no other team's model references it. CI takes 45 minutes because every model runs regardless of what changed. dbt Mesh, introduced with dbt Core 1.5 and expanded in 1.6 and 1.7, solves this by splitting the monolith into independently-owned domain projects that can reference each other's explicitly shared models without needing write access to each other's repositories.

The result is a federated model: the Marketing team owns and deploys their dbt project, the Finance team owns theirs, and both can safely consume stable, versioned, contractually-enforced models from the Platform team's shared project — without merge conflicts, without shared CI queues, and without organizational coupling. This mirrors the Data Mesh principle of domain-oriented decentralized data ownership at the layer of analytical transformation code.

Access Levels

Models are private (same project only), protected (same project + group), or public (referenceable cross-project). Default is protected.

Model Contracts

Public models can declare a contract — a list of columns with exact types. dbt enforces the contract at compile time and optionally at run time, preventing silent breaking changes.

Cross-Project ref()

Downstream projects reference upstream public models with {{ ref('project_name', 'model_name') }}. dbt resolves the relation at compile time without running the upstream project.

Multi-Project Repository Structure

dbt Mesh works equally well in a monorepo (all projects in one Git repository, each in a subdirectory) and in a polyrepo (each project in its own repository). The monorepo approach simplifies local development and cross-project integration testing; the polyrepo approach enforces true team independence and supports separate CI pipelines. Most organizations start with a monorepo for simplicity.

# Monorepo layout for dbt Mesh with three domain projects
# Each subfolder is an independent dbt project with its own dbt_project.yml

data-platform/
├── platform/                    # Shared infrastructure project (producer)
│   ├── dbt_project.yml
│   ├── models/
│   │   ├── staging/             # private: internal raw layer
│   │   │   └── stg_orders.sql
│   │   ├── core/                # public: stable interfaces consumed downstream
│   │   │   ├── dim_customers.sql
│   │   │   └── fct_orders.sql
│   │   └── marts/               # protected: internal team use only
│   │       └── int_order_enriched.sql
│   ├── packages.yml
│   └── profiles.yml
│
├── marketing/                   # Marketing domain project (consumer + producer)
│   ├── dbt_project.yml
│   ├── dependencies.yml         # declares dependency on 'platform' project
│   ├── models/
│   │   ├── staging/             # private staging from marketing sources
│   │   │   └── stg_campaigns.sql
│   │   ├── core/                # public: campaign attribution models for Finance
│   │   │   └── fct_campaign_attribution.sql
│   │   └── reports/             # private: marketing dashboards
│   │       └── rpt_channel_roi.sql
│   └── profiles.yml
│
└── finance/                     # Finance domain project (consumer)
    ├── dbt_project.yml
    ├── dependencies.yml         # depends on 'platform' and 'marketing'
    ├── models/
    │   ├── staging/
    │   │   └── stg_invoices.sql
    │   └── reports/
    │       └── rpt_revenue_by_channel.sql  # joins platform + marketing public models
    └── profiles.yml

Access Levels, Model Groups, and Visibility Rules

Every dbt model has an access level, set either in the model's YAML config or in dbt_project.yml. The three levels are private (only models in the same group within the same project can reference it), protected (any model in the same project, the default), and public (any model in any project). Model groups let you scope private access to a named group rather than the full project — essential for large projects where you want to enforce module boundaries within a single codebase.

# platform/models/core/schema.yml
# Declaring public models with contracts and access levels

version: 2

groups:
  - name: core_platform
    owner:
      name: Platform Team
      email: platform@company.com

models:
  - name: dim_customers
    description: >
      Conformed customer dimension — single authoritative source for customer
      attributes across all downstream domains. Public interface.
    access: public
    group: core_platform
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        data_type: bigint
        description: Surrogate key — stable across source system changes
        constraints:
          - type: not_null
          - type: primary_key
      - name: email_hash
        data_type: varchar
        description: SHA-256 of email, lowercased — never expose raw email downstream
        constraints:
          - type: not_null
      - name: customer_segment
        data_type: varchar
        description: "Values: enterprise, smb, consumer"
      - name: first_order_at
        data_type: timestamp
      - name: lifetime_orders
        data_type: integer
      - name: lifetime_revenue_usd
        data_type: numeric

  - name: fct_orders
    description: Daily order fact — immutable append-only grain at order level
    access: public
    group: core_platform
    config:
      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
          - type: foreign_key
            to: ref('dim_customers')
            to_columns: [customer_id]
      - name: ordered_at
        data_type: timestamp
        constraints:
          - type: not_null
      - name: status
        data_type: varchar
      - name: gross_revenue_usd
        data_type: numeric
      - name: net_revenue_usd
        data_type: numeric

  - name: int_order_enriched
    description: Intermediate enrichment — internal use only, do NOT reference cross-project
    access: protected
    group: core_platform
# platform/dbt_project.yml
# Set default access per directory — override per-model where needed

name: platform
version: "1.0.0"
config-version: 2

profile: platform

model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]

target-path: "target"
clean-targets: ["target", "dbt_packages"]

models:
  platform:
    staging:
      +access: private          # staging models are internal implementation details
      +materialized: view
    core:
      +access: public           # core models are the domain's public API
      +materialized: table
      +contract:
        enforced: true
    marts:
      +access: protected        # marts are intra-project use only
      +materialized: table

Note

Setting +contract: enforced: true at the directory level in dbt_project.yml ensures every model in that directory must declare its columns explicitly. This prevents developers from accidentally publishing a public model without a contract — the compile step fails until columns are declared.

Cross-Project References with dependencies.yml

A downstream project declares its upstream dependencies in dependencies.yml. dbt resolves the referenced models by looking up their manifests — either from dbt Cloud artifacts or from a local path in monorepo mode. The downstream project does not rerun the upstream models; it reads the compiled relation identifier (schema + table name) from the upstream manifest and uses it directly. This means Finance can reference Platform's fct_orderswithout ever touching Platform's source code.

# finance/dependencies.yml
# Declare upstream project dependencies for cross-project ref() resolution

projects:
  - name: platform
    # In monorepo: point to the sibling directory
    # In polyrepo / dbt Cloud: omit this and dbt resolves via the registry
  - name: marketing

# If running locally with dbt-core (not dbt Cloud):
# 1. Run 'dbt compile' in the platform project first to generate target/manifest.json
# 2. Run 'dbt compile' in the marketing project
# 3. Then run dbt in finance — it picks up the sibling manifests automatically
# when projects share a common parent directory in monorepo layout.

# For CI in polyrepo, pass manifest paths explicitly:
# dbt deps && dbt compile --target prod \
#   --project-dir finance \
#   --manifest platform/target/manifest.json \
#   --manifest marketing/target/manifest.json
-- finance/models/reports/rpt_revenue_by_channel.sql
-- Cross-project ref() syntax: ref('project_name', 'model_name')
-- dbt validates that the referenced model is public and the contract is satisfied

with orders as (
    -- Cross-project reference to platform project's public fct_orders model
    select
        order_id,
        customer_id,
        ordered_at::date                as order_date,
        gross_revenue_usd,
        net_revenue_usd
    from {{ ref('platform', 'fct_orders') }}
    where ordered_at >= dateadd('day', -90, current_date)
),

attributions as (
    -- Cross-project reference to marketing project's public model
    select
        order_id,
        channel,
        campaign_id,
        attribution_weight
    from {{ ref('marketing', 'fct_campaign_attribution') }}
),

customers as (
    select
        customer_id,
        customer_segment
    from {{ ref('platform', 'dim_customers') }}
),

revenue_by_channel as (
    select
        o.order_date,
        coalesce(a.channel, 'direct')   as acquisition_channel,
        c.customer_segment,
        sum(o.gross_revenue_usd * coalesce(a.attribution_weight, 1.0)) as attributed_gross_revenue,
        sum(o.net_revenue_usd  * coalesce(a.attribution_weight, 1.0)) as attributed_net_revenue,
        count(distinct o.order_id)      as order_count,
        count(distinct o.customer_id)   as unique_customers
    from orders o
    left join attributions a using (order_id)
    left join customers    c using (customer_id)
    group by 1, 2, 3
)

select * from revenue_by_channel

Model Contracts — Schema Enforcement at the Public Boundary

A model contract is a list of column names and data types declared in YAML that dbt validates at compile time and, when supported by the adapter, enforces at the database level using CREATE TABLE ... CONSTRAINT DDL. When contract.enforced: true, dbt will refuse to compile a model run if the SQL's output columns do not match the declared contract — wrong column names, extra columns, or type mismatches all fail at dbt compile before any SQL runs. This prevents a developer from silently removing a column that downstream consumers depend on.

Contracts integrate naturally with dbt's incremental models and CI/CD testing patterns — you declare the contract alongside your existing schema tests, and dbt treats a contract violation as a compile error, not a test failure. This distinction matters: test failures are caught at runtime after SQL has run; contract violations are caught before any SQL executes.

# platform/models/core/fct_orders.sql
# Contract-enforced model — the SELECT must produce exactly these columns in order

{{
  config(
    materialized = 'incremental',
    unique_key   = 'order_id',
    incremental_strategy = 'merge',
    on_schema_change = 'fail',    -- refuse to run if schema drifts vs. contract
    contract = {'enforced': true}
  )
}}

-- Contract requires: order_id, customer_id, ordered_at, status,
--                    gross_revenue_usd, net_revenue_usd
-- Column order and types must match schema.yml declarations exactly

with source as (
    select * from {{ ref('stg_orders') }}
    {% if is_incremental() %}
        where ordered_at > (select max(ordered_at) from {{ this }})
    {% endif %}
),

enriched as (
    select
        order_id::bigint                                       as order_id,
        customer_id::bigint                                    as customer_id,
        ordered_at::timestamp                                  as ordered_at,
        status::varchar                                        as status,
        (line_items_total + shipping_fee)::numeric             as gross_revenue_usd,
        (line_items_total + shipping_fee - discount_total)::numeric as net_revenue_usd
    from source
    where order_id is not null
)

select * from enriched
# What happens when a contract is violated

# Developer accidentally drops net_revenue_usd from the SELECT:
# $ dbt compile --select fct_orders
#
# Error:
# Compilation Error in model fct_orders (models/core/fct_orders.sql)
#   Contract violation in model fct_orders:
#   Column 'net_revenue_usd' is required by the contract but was not found
#   in the model's columns.
#   Declared in:   platform/models/core/schema.yml
#   Model SQL:     models/core/fct_orders.sql
#
# This error happens before any SQL runs — the model is never materialized.

# Developer changes gross_revenue_usd from numeric to float:
# Error:
#   Contract violation in model fct_orders:
#   Column 'gross_revenue_usd' has data type 'double precision' in the model
#   but 'numeric' in the contract.
#
# Fix: update the CAST in the SQL to match the declared contract type.
# Do NOT change the contract to match the wrong SQL — the contract is the
# authoritative definition of the public interface.

Model Versioning — Safe Evolution of Public Interfaces

When a public model's contract needs a breaking change — removing a column, changing a type, renaming a field — dbt Mesh provides model versioning. You create a new version (e.g., v2) while keeping the old version active. Downstream consumers migrate at their own pace. When all consumers have migrated, you deprecate and eventually remove v1. This is the same semantic versioning discipline applied to APIs, now available natively in dbt. Combined with dbt macros and custom tests, versioned models can carry automated deprecation warnings into every consumer's CI run.

# platform/models/core/schema.yml — declaring model versions

models:
  - name: fct_orders
    latest_version: 2
    deprecation_date: "2026-09-01"  # v1 deprecation deadline — CI warns consumers after this date
    versions:
      - v: 1
        defined_in: fct_orders_v1   # points to fct_orders_v1.sql
        description: "Original schema — deprecated, migrate to v2 before 2026-09-01"
      - v: 2
        defined_in: fct_orders_v2   # points to fct_orders_v2.sql (default when unversioned ref is used)
        description: "v2 adds currency_code column and breaks gross/net into separate line-item fields"

# Downstream consumers explicitly pin to a version during migration:
# {{ ref('platform', 'fct_orders', v=1) }}  -- old consumers, still on v1
# {{ ref('platform', 'fct_orders', v=2) }}  -- migrated consumers
# {{ ref('platform', 'fct_orders') }}        -- resolves to latest_version (v2)
# platform/models/core/fct_orders_v1.sql
# Legacy version — kept alive while consumers migrate to v2
# Materialized in schema as fct_orders_v1_1 (dbt appends version suffix)

{{
  config(
    materialized = 'table',
    contract     = {'enforced': true}
  )
}}

select
    order_id::bigint     as order_id,
    customer_id::bigint  as customer_id,
    ordered_at::timestamp as ordered_at,
    status::varchar      as status,
    gross_revenue_usd::numeric as gross_revenue_usd,
    net_revenue_usd::numeric   as net_revenue_usd
from {{ ref('stg_orders') }}

---

# platform/models/core/fct_orders_v2.sql
# v2: adds currency_code and splits revenue into product/shipping components

{{
  config(
    materialized = 'incremental',
    unique_key   = 'order_id',
    incremental_strategy = 'merge',
    contract     = {'enforced': true}
  )
}}

select
    order_id::bigint              as order_id,
    customer_id::bigint           as customer_id,
    ordered_at::timestamp         as ordered_at,
    status::varchar               as status,
    currency_code::varchar        as currency_code,         -- NEW in v2
    product_revenue_usd::numeric  as product_revenue_usd,   -- replaces gross_revenue_usd
    shipping_revenue_usd::numeric as shipping_revenue_usd,  -- NEW in v2
    discount_total_usd::numeric   as discount_total_usd,    -- NEW in v2
    net_revenue_usd::numeric      as net_revenue_usd
from {{ ref('stg_orders') }}
{% if is_incremental() %}
    where ordered_at > (select max(ordered_at) from {{ this }})
{% endif %}

Note

When deprecation_date passes, dbt emits a warning for every model that still references the deprecated version. This warning appears in CI output and in dbt Cloud job logs, creating natural pressure on consuming teams to migrate without requiring the platform team to actively chase them down.

CI/CD Pipelines for Multi-Project dbt Mesh

The key CI/CD challenge in a dbt Mesh monorepo is avoiding unnecessary full-project runs when only a subset of models changed. The solution is slim CI: dbt's --select state:modified+ flag runs only modified models and their downstream dependents, deferring unchanged upstream models to the production environment. In a polyrepo, each project has its own CI pipeline, and cross-project consumers trigger re-validation when the upstream project publishes a new manifest artifact.

# .github/workflows/dbt-platform-ci.yml
# CI for the platform project — slim runs on PR, full run on merge to main

name: dbt Platform CI

on:
  pull_request:
    paths:
      - "platform/**"
  push:
    branches: [main]
    paths:
      - "platform/**"

jobs:
  dbt-ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: platform

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # needed for state:modified comparison

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dbt
        run: pip install dbt-snowflake==1.8.0

      - name: dbt deps
        run: dbt deps

      - name: Download production manifest (for state comparison)
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          aws s3 cp s3://company-dbt-artifacts/platform/prod/manifest.json \
            ./prod-manifest/manifest.json --create-dirs

      - name: dbt compile (contract validation)
        env:
          DBT_SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
          DBT_SNOWFLAKE_USER:    ${{ secrets.SNOWFLAKE_USER }}
          DBT_SNOWFLAKE_ROLE:    ${{ secrets.SNOWFLAKE_ROLE }}
          DBT_SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }}
        run: |
          dbt compile --target ci \
            --profiles-dir .

      - name: dbt run (slim CI — modified models + downstream)
        if: github.event_name == 'pull_request'
        run: |
          dbt run \
            --target ci \
            --select "state:modified+" \
            --state ./prod-manifest \
            --defer \
            --profiles-dir .

      - name: dbt test (modified models only)
        if: github.event_name == 'pull_request'
        run: |
          dbt test \
            --target ci \
            --select "state:modified+" \
            --state ./prod-manifest \
            --defer \
            --profiles-dir .

      - name: dbt run (full — on merge to main)
        if: github.event_name == 'push'
        run: dbt run --target prod --profiles-dir .

      - name: dbt test (full — on merge to main)
        if: github.event_name == 'push'
        run: dbt test --target prod --profiles-dir .

      - name: Upload manifest artifact
        if: github.event_name == 'push'
        run: |
          aws s3 cp ./target/manifest.json \
            s3://company-dbt-artifacts/platform/prod/manifest.json
# Cross-project consumer CI — finance project validates against platform's manifest
# .github/workflows/dbt-finance-ci.yml

name: dbt Finance CI

on:
  pull_request:
    paths: ["finance/**"]
  # Triggered when platform publishes a new manifest (cross-project validation)
  repository_dispatch:
    types: [platform-manifest-updated]

jobs:
  dbt-finance-ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: finance

    steps:
      - uses: actions/checkout@v4

      - name: Install dbt
        run: pip install dbt-snowflake==1.8.0

      - name: dbt deps
        run: dbt deps

      - name: Download upstream manifests
        run: |
          aws s3 cp s3://company-dbt-artifacts/platform/prod/manifest.json  \
            ../platform/target/manifest.json --create-dirs
          aws s3 cp s3://company-dbt-artifacts/marketing/prod/manifest.json \
            ../marketing/target/manifest.json --create-dirs

      - name: dbt compile (resolves cross-project refs against upstream manifests)
        run: dbt compile --target ci --profiles-dir .

      - name: dbt run (slim CI)
        if: github.event_name == 'pull_request'
        run: |
          dbt run --target ci \
            --select "state:modified+" \
            --state ./prod-manifest \
            --defer \
            --profiles-dir .

      - name: dbt test
        run: dbt test --target ci --select "state:modified+" --defer --profiles-dir .

Governance — Ownership, Metrics Layer, and Access Policies

dbt Mesh adds a governance layer that was previously informal. Model groups with named owners mean you can trace every public model back to a team. The dbt MetricFlow semantic layer integrates with dbt Mesh: metrics defined in one project (e.g., revenue in Platform) are reusable across downstream projects without copy-pasting metric logic. Access policies prevent unauthorized cross-project references at compile time — a consuming team cannot quietly reference a protected model; dbt emits an error.

# platform/models/core/metrics.yml
# MetricFlow metric definitions on public models — reusable across projects

version: 2

metrics:
  - name: gross_revenue
    label: Gross Revenue (USD)
    description: Total gross revenue from all completed orders
    type: simple
    type_params:
      measure:
        name: gross_revenue_usd
        agg: sum
    filter: |
      {{ Dimension('fct_orders__status') }} = 'completed'

  - name: order_count
    label: Order Count
    type: simple
    type_params:
      measure:
        name: order_id
        agg: count_distinct

  - name: revenue_per_order
    label: Revenue per Order
    type: derived
    type_params:
      expr: "gross_revenue / order_count"
      metrics:
        - name: gross_revenue
        - name: order_count

semantic_models:
  - name: fct_orders
    description: Order-grain semantic model for MetricFlow
    model: ref('fct_orders')
    entities:
      - name: order_id
        type: primary
      - name: customer_id
        type: foreign
        entity: customer
    dimensions:
      - name: ordered_at
        type: time
        type_params:
          time_granularity: day
      - name: status
        type: categorical
    measures:
      - name: gross_revenue_usd
        agg: sum
        expr: gross_revenue_usd
      - name: order_id
        agg: count_distinct
        expr: order_id
# Governance enforcement — what happens when access policies are violated

# finance/models/reports/rpt_internal_attempt.sql
# Attempting to reference a protected model from a different project:
#
# select * from {{ ref('platform', 'int_order_enriched') }}
#
# $ dbt compile --select rpt_internal_attempt
#
# Error:
#   Compilation Error in model rpt_internal_attempt
#   Node model.platform.int_order_enriched has access 'protected' and
#   is not accessible to package 'finance'.
#
#   To fix this: either use a public model, or ask the platform team
#   to create a public model that exposes the data you need.
#
# This prevents stealth dependencies on implementation internals
# and forces the platform team to make explicit what is a public API.

# Group-level private access (intra-project enforcement):
# If int_order_enriched is in group 'core_platform' with access 'private',
# even other models in the platform project outside that group cannot ref it.

Local Development and Integration Testing

Working with cross-project references locally requires that upstream project manifests are available. In a monorepo, the standard workflow is to run dbt compile in the upstream project first, then work in the downstream project. A Makefile or shell script per project standardizes this for new team members. For integration tests that span project boundaries, dbt's --defer flag lets you run only the changed downstream models, deferring unchanged upstream models to the production environment rather than re-running the full upstream project locally.

# Makefile for monorepo — standardize multi-project dev workflows

.PHONY: compile-all run-platform run-marketing run-finance test-all

# Compile upstream projects first (generates manifests for cross-project resolution)
compile-platform:
	cd platform && dbt deps && dbt compile --target dev

compile-marketing: compile-platform
	cd marketing && dbt deps && dbt compile --target dev

compile-all: compile-platform compile-marketing
	cd finance && dbt deps && dbt compile --target dev

# Run individual projects (assumes manifests already compiled)
run-platform:
	cd platform && dbt run --target dev --select "core"

run-finance-slim:
	# Defer unchanged upstream to prod; only run modified finance models
	cd finance && dbt run \
	  --target dev \
	  --select "state:modified+" \
	  --state ./prod-manifest \
	  --defer

test-all:
	cd platform  && dbt test --target dev
	cd marketing && dbt test --target dev
	cd finance   && dbt test --target dev

# Validate all cross-project contracts are satisfied
validate-contracts:
	cd platform  && dbt compile --target dev
	cd marketing && dbt compile --target dev
	cd finance   && dbt compile --target dev
	@echo "All contracts validated successfully"

# Generate dbt docs across all projects
docs:
	cd platform  && dbt docs generate --target dev
	cd marketing && dbt docs generate --target dev
	cd finance   && dbt docs generate --target dev

Production Checklist

1

Start with access levels before adding cross-project refs. Audit your current monolith and mark every model as public, protected, or private before splitting into projects. The access level annotation alone adds governance value without any project restructuring.

2

Add contracts to all public models on day one of going multi-project. A public model without a contract can have its columns silently changed by a developer, breaking downstream consumers with no compile-time warning. Enforce contracts before the first consumer references a model cross-project.

3

Store compiled manifests in S3 or GCS as CI artifacts. Downstream project CI pipelines need upstream manifests to resolve cross-project refs. Use consistent S3 paths per project and environment (e.g., s3://artifacts/platform/prod/manifest.json) and update them on every successful main-branch deploy.

4

Use --defer with slim CI for all downstream project PR pipelines. Without deferral, Finance CI would need to run the full Platform project on every PR — defeating the purpose of splitting. With --defer, only the changed Finance models run; unchanged upstream tables are read from production.

5

Pin dbt-core and adapter versions across all projects in the mesh. A version mismatch between projects (e.g., Platform on dbt 1.7, Finance on dbt 1.8) can cause manifest schema incompatibilities when Finance tries to resolve Platform's cross-project refs.

6

Set deprecation_date on all versioned model v1 instances before releasing v2. Without a deadline, consumers never feel urgency to migrate. The deprecation date triggers CI warnings starting on that date, creating automatic pressure without manual follow-up from the platform team.

7

Use model groups to enforce intra-project modularity before splitting projects. If your platform project has staging, intermediate, and mart layers, assign them to groups with appropriate access levels. This prevents tight coupling within the project that would make future splits painful.

8

Run dbt source freshness and dbt test as separate CI steps with separate failure policies. Source freshness failures should alert on-call but not block deploys; contract violations should always block deploys. Mixing them in a single dbt test run obscures which failure type occurred.

9

Create a shared dbt packages repo for macros used across projects. Cross-project ref() solves model sharing; it does not solve macro sharing. Macros (generic tests, utility functions) should be extracted into a shared dbt package published to a private GitHub Packages or Artifactory registry and listed in each project's packages.yml.

10

Document the public model API in the model's description field, not just in comments. dbt docs generates a searchable catalog from description fields. Downstream teams use dbt docs as the primary discovery interface — a model with no description is invisible to consumers who haven't read the source code.

Running a monolithic dbt project shared by five teams where every column rename breaks downstream consumers silently, CI takes 45 minutes because unrelated models always run, or multiple domains need to share stable table interfaces without merge conflicts?

We design and implement dbt Mesh architectures — from monolith-to-mesh decomposition planning and model access level auditing to model contract definition for all public interfaces, cross-project ref() dependency wiring with dependencies.yml, model versioning with deprecation timelines, slim CI pipeline setup with state:modified+ and --defer in GitHub Actions, S3 manifest artifact storage for polyrepo cross-project compilation, MetricFlow semantic layer configuration on shared public models, shared dbt macro package extraction and private registry setup, and production governance including model group ownership, access policy enforcement, and dbt docs catalog integration. Let’s talk.

Let's Talk

Related Articles

DataSOps Consulting

Need help implementing this in production?

We build and operate data pipelines, AI systems, and observability stacks for engineering teams. Reach out for a free 30-minute architecture review.