Learning Platform
Глоссарий Troubleshooting
Урок 11.01 · 22 мин
Средний
Model contractsSchema enforcementData contractsGovernance

Model contracts: концепт enforced контракта

Когда модель fct_orders используется в 15 dashboards и 3 ML моделях, обещание schema — это критично. Если data engineer переименовал order_amount -> order_total, downstream ломается. Если кто-то накосячил с типом (NUMERIC(12, 2) -> FLOAT64), агрегаты дрейфуют. Если добавил колонку (new_field), некоторые consumers могут начать использовать её, и теперь её нельзя удалить.

В dbt I мы писали YAML schema с descriptions и tests — это документация + проверки данных, но не контракт. Можно изменить SQL модели без изменения YAML — dbt не возразит, новая колонка просто появится в warehouse.

Model contract (dbt 1.5+) — это enforced schema declaration. Когда contract.enforced: true, dbt во время compile/run проверяет, что:

  1. Все колонки, объявленные в YAML, присутствуют в SQL.
  2. Типы колонок в SQL соответствуют объявленным.
  3. Constraints (PK, FK, NOT NULL, CHECK) применены.
  4. Нет лишних колонок в SQL (которые не в YAML).

Это build-time gate: модель не запустится, если контракт нарушен. Это разница между «надеюсь» и «гарантируется».

Этот урок — про концепт. Следующие — про синтаксис, versions, миграцию, CI.


Контракт vs data tests vs YAML schema

Open Data Contract Standard (ODCS) — formal contract specification SQL constraints: PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE

Три разных уровня enforcement:

Три уровня schema enforcement

Все три комплементарны:

  • YAML schema = документация + базовый parse
  • Data tests = качество данных (значения)
  • Contract = структурная стабильность (форма)

Production-grade модель имеет все три.


Простой пример

Модель без contract:

- name: customer_metrics
  description: "..."
  columns:
    - name: customer_id
      data_type: bigint   # это decorative — не enforced
      description: "..."
    - name: revenue
      data_type: numeric(12, 2)
-- models/marts/customer_metrics.sql
SELECT
    c.customer_id::bigint,
    COALESCE(SUM(o.order_total), 0)::float AS revenue,   -- FLOAT, не NUMERIC!
    c.first_name   -- НЕ объявлено в YAML, но добавится
FROM ...

dbt run пройдёт. Таблица customer_metrics создаётся с колонками: customer_id BIGINT, revenue FLOAT, first_name VARCHAR. YAML говорит revenue numeric(12,2), реально — FLOAT. YAML не объявил first_name, но она есть. Schema дрейфовала.

Модель с contract:

- name: customer_metrics
  config:
    contract:
      enforced: true
  columns:
    - name: customer_id
      data_type: bigint
      constraints: [{type: not_null}, {type: primary_key}]
    - name: revenue
      data_type: numeric(12, 2)
      constraints: [{type: not_null}]

Тот же SQL -> dbt run падает:

Contract enforcement failure:
- Column 'revenue' has type 'double precision' but YAML declares 'numeric(12, 2)'
- Column 'first_name' present in SQL but not declared in YAML

dbt отказывается материализовать модель. Producer obязан исправить либо SQL (привести в соответствие YAML), либо YAML (отразить изменения). Принудительная синхронизация.


Что гарантирует enforced contract

Гарантии enforced contract

Чёткое разграничение: contract = schema, tests = data quality. Они комплементарны.


Когда нужен contract

Contract не нужен на каждой модели — overhead в YAML и поддержке. Используй для:

Тип моделиContract?Почему
Staging (stg_*)НетЧасто меняются, internal layer
Intermediate (int_*)НетЭфемерные, не consumer-facing
Marts (consumer-facing)ДаStability для BI/ML
Public API tablesОбязательноКонтракт с external systems
Shared facts/dims (cross-team)ОбязательноMulti-consumer stability
Snapshot tables (*_snapshot)ДаHistory не должна терять колонки

Эвристика: если 3+ downstream используют модель — contract. Меньше — overhead.


Полный пример contract

# models/marts/_marts__models.yml
version: 2

models:
  - name: fct_orders
    description: "Production fact table для заказов. Used by 15 dashboards."
    config:
      contract:
        enforced: true
      materialized: table
    columns:
      - name: order_id
        data_type: bigint
        description: "Primary key. Unique per order."
        constraints:
          - type: not_null
          - type: primary_key
        data_tests: [unique, not_null]

      - name: customer_id
        data_type: bigint
        description: "FK on dim_customers"
        constraints:
          - type: not_null
          - type: foreign_key
            expression: ref('dim_customers')
            references_field: customer_id
        data_tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id

      - name: order_date
        data_type: date
        description: "Date of order placement"
        constraints:
          - type: not_null

      - name: order_total
        data_type: numeric(12, 2)
        description: "Order total in USD"
        constraints:
          - type: not_null
          - type: check
            expression: "order_total >= 0"

      - name: status
        data_type: varchar(20)
        description: "One of: completed, pending, cancelled, refunded"
        constraints:
          - type: not_null
          - type: check
            expression: "status IN ('completed', 'pending', 'cancelled', 'refunded')"
        data_tests:
          - accepted_values:
              values: ['completed', 'pending', 'cancelled', 'refunded']
-- models/marts/fct_orders.sql
SELECT
    o.order_id::bigint,
    o.customer_id::bigint,
    o.order_date::date,
    o.order_total::numeric(12, 2),
    o.status::varchar(20)
FROM {{ ref('stg_orders') }} o
WHERE o.order_total IS NOT NULL
  AND o.status IS NOT NULL

Чтобы contract прошёл, SQL должен:

  1. Содержать ровно те колонки, что в YAML (нет extras, не пропустить declared).
  2. Колонки иметь точные типы (через :: casts если нужно).
  3. Соответствовать constraints в данных (NOT NULL — WHERE filter, или COALESCE).

При dbt run:

  • dbt компилирует SQL.
  • Проверяет actual columns/types vs YAML declarations.
  • При mismatch — build fails с подробным сообщением.
  • Применяет DDL constraints (где supports warehouse).

DuckDB и constraints: partial support

В курсе мы используем DuckDB. Constraints support:

ConstraintDuckDB
NOT NULLok
PRIMARY KEYok
CHECKok
UNIQUEok
FOREIGN KEYpartial (см. ниже)

FOREIGN KEY на DuckDB:

  • Поддерживается в локальной DuckDB
  • Не поддерживается на MotherDuck (cloud version)
  • При cross-database refs (attach Postgres) FK не enforced

Это означает: contract на DuckDB declares FK в metadata, но enforcement зависит от scenario. На MotherDuck FK становится declarative-only — нужны data tests relationships для actual enforcement.

В Snowflake / Postgres / BigQuery — full FK support, но Snowflake/BigQuery — metadata-only (DDL DECLARE FK, но не enforced — это для documentation/optimizer hints).

WARNING

В большинстве warehouses (Snowflake, BigQuery) constraints в model contract — metadata-only. PK / FK / CHECK объявляются в DDL, но warehouse не блокирует insertions, нарушающих constraints. Это false sense of security: contract объявляет, но проверка fact-данных — задача data tests. Только Postgres / DuckDB (locally) enforce CHECK / FK.


Антипаттерны

  1. Contract на каждой модели: добавляет overhead в YAML без ROI на эфемерных моделях. Использовать только на consumer-facing.

  2. Contract без точных типов: data_type: numeric без precision. dbt parse ok, но семантически — broken. Всегда numeric(precision, scale).

  3. Contract + breaking change: removed колонку из SQL, обновил YAML. Build pass, но downstream ломается. Нужны model versions (следующие уроки).

  4. Contract без consumer notification: добавил/удалил колонку через contract, не сообщил consumers через exposures. Downstream ломается на проде.

  5. Contract на view materialization: некоторые DDL constraints (PK, FK) не работают на views в некоторых warehouses. Test перед prod.

  6. Constraints вместо tests: NOT NULL constraint в YAML -> false sense of security в Snowflake (metadata-only). Дублирование через data test not_null обеспечивает real check.

  7. Numeric precision drift: numeric(12, 2) в YAML, numeric(10, 4) в SQL — contract caught. Но если оба numeric без precision — silent drift. Always specify precision/scale.


Migration: enable contract на existing model

Не делать в один PR. Phased approach:

  1. PR 1 — добавить YAML declarations (без enforced: true):

    columns:
      - name: order_id
        data_type: bigint
        constraints: [{type: primary_key}]
      ...

    dbt parse runs, no enforcement. Можно review declarations.

  2. PR 2 — align SQL с YAML (если есть drift):

    SELECT order_id::bigint, ...

    Cast all columns to declared types. Run + tests.

  3. PR 3 — enable contract:

    config:
      contract:
        enforced: true

    Build должен пройти. Если нет — drift remained, fix.

  4. CI lock: добавить gate, что enforced: true нельзя отключить без specific approval.

Это safe migration — постепенный rollout с rollback на каждом шаге.


Попробуй сам

В своём dbt-проекте:

  1. Выбрать критическую mart-модель (например, customer_metrics или fct_orders).

  2. Phase 1: добавить полное declaration в YAML без enforced: true:

    • Все колонки с data_type
    • PK / FK constraints
    • CHECK constraints где meaningful (revenue не меньше 0)
  3. Phase 2: align SQL — добавить ::type casts, убедиться что нет лишних колонок.

  4. Phase 3: включить enforced: true. Run.

  5. Tester: попробовать сломать contract:

    • Изменить тип колонки в SQL (numeric -> float) — build должен fail
    • Добавить лишнюю колонку в SQL — build должен fail
    • Изменить тип в YAML без SQL update — build должен fail
  6. Recover: restore correct alignment, run snova — pass.

  7. Observe error messages: они точно показывают, где mismatch.


Ключевые выводы

  1. Model contract (1.5+) — enforced schema declaration. Build fails при mismatch SQL vs YAML.
  2. Что проверяет: presence of columns, types, constraints (PK/FK/CHECK/NOT NULL).
  3. Не проверяет: data quality (это data tests), correctness logic (это unit tests), backward compatibility (это model versions).
  4. Когда нужен: marts (consumer-facing), public API tables, shared cross-team models. Эвристика — 3+ downstream consumers.
  5. DuckDB constraint support: partial — FK работает локально, не на MotherDuck. Snowflake/BigQuery — metadata-only (declarative, не enforced).
  6. Phased enable: PR 1 declare без enforce -> PR 2 align SQL -> PR 3 enable enforced. Safe rollout.
  7. Антипаттерны: contract на всех моделях, без точных типов (precision), без consumer notification, на views (problematic), как замена data tests (constraints metadata-only).
  8. Production = contract + data tests + unit tests — три слоя для разной защиты.
Проверка знанийKnowledge check
Junior говорит: 'мы добавили `data_type: bigint` в YAML для customer_id. Теперь schema enforced'. Правильно ли это?
ОтветAnswer
Не совсем. **Чтобы contract enforce — нужен явный config**:\n\n```yaml\n- name: customer_metrics\n config:\n contract:\n enforced: true # обязательно!\n columns:\n - name: customer_id\n data_type: bigint # без enforced: true — decorative\n```\n\nБез `contract.enforced: true`:\n- `data_type` — это **documentation hint**, не enforcement\n- `constraints` — игнорируются при materialization\n- SQL может содержать любые колонки/типы — dbt не возразит\n- Schema drift возможен silent\n\nС `contract.enforced: true`:\n- Build fails при mismatch\n- Constraints применяются как DDL (где supports)\n- Strict gate перед материализацией\n\n**Migration pattern**:\n\n1. Add declarations без enforce (PR 1) — review YAML\n2. Align SQL с YAML (PR 2)\n3. Enable enforced (PR 3)\n\nЭто phased rollout, safe.\n\n**Что junior пропустил**: declaration ≠ enforcement. `data_type: bigint` без `enforced: true` — это просто documentation, как column description. dbt не проверяет.\n\nДополнительно: **constraints в большинстве warehouses metadata-only**. Snowflake/BigQuery declare CHECK/FK но не enforce — это для optimizer / documentation. Для real enforcement — data tests `not_null`, `unique`, `relationships`.
Проверка знанийKnowledge check
Senior говорит: 'каждая модель должна иметь enforced contract'. Junior спрашивает, есть ли overhead. Что senior должен ответить?
ОтветAnswer
Senior **wrong** — не каждая модель. Overhead есть, и для большинства моделей contract не нужен.\n\n**Overhead contract**:\n\n1. **YAML burden**: каждая колонка должна быть declared с типом и constraints. На моделях с 30+ колонок — 50+ строк YAML.\n\n2. **Maintenance**: при изменении SQL обязательно обновить YAML. Else build fails. Это lock-in.\n\n3. **DDL overhead**: dbt issues constraint DDL — comments OK, but FK / CHECK на больших таблицах = compute overhead на каждом run.\n\n4. **False sense of security**: на Snowflake/BigQuery constraints metadata-only. Contract "NOT NULL" не блокирует NULL data — нужны data tests.\n\n**Когда contract нужен** (decision matrix):\n\n| Модель | Contract? | Reason |\n|--------|-----------|--------|\n| Staging `stg_*` | NO | Меняется часто, internal layer |\n| Intermediate `int_*` | NO | Эфемерные, не consumer-facing |\n| Marts (consumer-facing) | **YES** | Stability для BI/ML |\n| Public API tables | **YES** | Контракт с external |\n| Shared cross-team | **YES** | Multi-consumer stability |\n| One-off analyses | NO | Throwaway |\n\n**Эвристика**: 3+ downstream consumers -> contract worth it. 1-2 -> overhead.\n\n**Правильный совет senior'a**:\n- 'Contract on marts and public APIs, where stability matters'\n- 'For staging — YAML schema без enforce + data tests'\n- 'Phased rollout — declare без enforce -> align SQL -> enable enforce'\n\n**Production реальность**: на 200-моделей проекте contract на 30-50 (15-25%) — это marts + public APIs. Остальное — без contract, но с data tests.\n\nContract — **strategic tool для stability**, не default.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Junior добавил `data_type: bigint` в YAML для customer_id и говорит 'теперь schema enforced'. Что не так?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5