Contracts on a Legacy Pipeline
Concepts covered: Schema Evolution
Greenfield contracts are easy. Contracts on a legacy pipeline that has run for four years and has dozens of unknown consumers are hard. The mistake most teams make is treating the rollout as a one-shot migration: write the contract, declare the producer compliant, declare consumers responsible for catching up. The mistake produces breakage and erodes the credibility of the contract program. The disciplined rollout treats existing consumer behavior as the starting contract, evolves toward the desired contract over time, and uses the same shape that semver software libraries have used for decades. The exercise below walks through the rollout for a customer events stream that is the lifeblood of three downstream consumers and an unknown number of analyst queries. The legacy character of the pipeline is what makes the rollout interesting. A greenfield rollout has the luxury of starting from a clean schema and an enumerable consumer set. A legacy rollout has neither. The discipline below is what compensates for the missing luxury, and the principles transfer to any production system that has been running long enough that the original authors have moved on and the original assumptions have drifted.
The Starting State
Step 1 is to document the current contract. The first contract is not the contract the team wants; it is the contract the producer is currently delivering, including the drift. The team writes it by inspecting recent production data: every field that has appeared in the last 30 days, every type variation seen, every value range observed. The result is messy and accurate. The contract version is 1.0.0, and it captures reality, not aspiration.
# Contract version 1.0.0: documents what is, not what should be
contract:
name: customer_events
version: 1.0.0
status: documented_legacy
schema:
primary_key: event_id
fields:
- { name: event_id, type: string, nullable: false }
- { name: customer_id, type: string, nullable: true,
observed_null_rate: 0.008 }
- { name: event_type, type: string, nullable: true,
observed_values: [signup, login, purchase, churn,
page_view, click, error] }
- { name: ts, type: union[timestamp, string],
nullable: false,
notes: 'producers send both ISO-8601 strings and epoch ms' }
- { name: amount, type: union[decimal, string],
nullable: true,
notes: 'string for older clients; decimal for newer' }
guarantees:
freshness: 'best effort, typically <= 30 minutes'
volume: 'no enforced bound; observed 1.5 to 4M per day'
consumers_known:
- leadership_dashboard
- churn_model
- billing_aggregatorStep 2 is to inventory every consumer. The three named consumers are easy. The unknown analyst queries are not. Lineage tooling helps: parsing recent query logs against the table catches most analyst queries; manual outreach catches the rest. The output is a list of every reader, ranked by frequency, with an owner per consumer. Step 3 adds quality gates in observation-only mode. The gates run, log results, and do not block. After two to four weeks of observation, the team has data on which gates would have fired and tunes against it. Once tuned, gates switch from observation to enforcing. Skipping the observation phase is the most common rollout mistake.
# Gates start in observation mode
quality_gates:
- name: row_count_within_baseline
layer: raw
mode: observe # later: 'block'
threshold: 0.50_to_2.00_of_dow_baseline
- name: customer_id_null_rate
layer: raw
mode: observe
threshold: 0.01
- name: amount_distribution_z_shift
layer: curated
mode: observe
block_threshold: 5.0
warn_threshold: 3.0
- name: event_type_accepted_values
layer: raw
mode: observe
threshold: enum_present_in_recent_historyStep 4 plans the cleanups as versioned changes. Legacy problems (union types on ts and amount, unwhitelisted event_types, ambiguous nulls) are each a versioned change. Renaming ts to event_timestamp with strict ISO-8601 is a breaking change; it ships in version 2.0.0 with a 90-day notice. Whitelisting accepted_values for event_type is backward-compatible if the producer commits to not emitting new values without a contract update; it ships as minor 1.x. Each change has a migration plan naming affected consumers and their actions. Cleanups happen one at a time, not as a single rewrite.
| Change | Version | Type | Consumer Action |
|---|---|---|---|
| Whitelist accepted_values for event_type | 1.1.0 | Backward-compatible (additive guarantee) | None; consumers can adopt the tighter contract or stay on 1.0 |
| Tighten customer_id null rate to <= 1 percent | 1.2.0 | Backward-compatible (tighter guarantee) | None; consumers benefit |
| Rename ts to event_timestamp; strict ISO-8601 | 2.0.0 | Breaking | Pin to 1.x or migrate within 90 days |
| Rename amount to amount_usd; strict decimal | 2.0.0 | Breaking | Pin to 1.x or migrate within 90 days |
| Drop legacy event_types (page_view, click, error) | 3.0.0 | Breaking | Pin to 2.x or migrate within 180 days |
Step 5 migrates consumers behind the versions. Each consumer migrates to the next major version on its own schedule within the announced notice period. The producer maintains both old and new versions during the transition window; dual-publishing is more expensive than single-publishing, and that maintenance burden falls on the producer. The benefit is that no consumer breaks in production; each migration is a deliberate code change with tests, not a surprise in an incident channel. Some consumers migrate in a week; others take the full notice period. Both are acceptable; a unilateral cutover is not. The producer tracks migration progress with a simple consumer-version query against the contract registry.
SELECT contract_version,
COUNT(DISTINCT consumer_team) AS pinned_consumers,
MIN(last_seen) AS earliest_pin,
MAX(last_seen) AS latest_pin
FROM contract_registry.pins
WHERE contract_name = 'customer_events'
AND last_seen >= CURRENT_DATE - 7
GROUP BY contract_version
ORDER BY contract_version;Producer publishes:
customer_events 1.x -+----------+----------+----------+----------+ deprecation
customer_events 2.x -+----------+----------+----------+----------+ live
customer_events 3.x -+ pre-announce ----+----------+ live -- ~
Day 0 Day 30 Day 60 Day 90 Day 180 Day 270
Consumer A pins 1.0 ---> migrates to 2.0 around Day 60 ---> migrates to 3.0 by 270
Consumer B pins 1.0 ---> migrates to 2.0 by Day 90 ---> stays on 2.x
Consumer C pins 2.0 ---> migrates to 3.0 by Day 270 Step 6 locks in the new state. Once consumers are migrated and the legacy version is deprecated, the producer flips the gates from observation-only to enforcing on the new contract. CI now rejects producer changes that violate the contract. Schema drift becomes impossible; incidents that used to come every six to eight weeks become rare. The rollout is complete when the producer can commit to the contract as a CI-enforced guarantee, not documentation alone.
- Before The Rollout
- Wrong-number incident every six to eight weeks
- Three named consumers; unknown analyst surface
- Schema drifts continuously; producers add fields without notice
- Quality is best-effort; no SLA possible
- Rolling back a producer change requires consumer-by-consumer triage
- After The Rollout
- Wrong-number incidents are rare; most are caught in CI
- Every consumer is registered; lineage covers analyst queries
- Schema evolves through versioned changes with notice
- Operational and quality SLAs are stated and tracked
- Producer can roll back any version because all consumers are pinned
- Do
- Treat the first contract as a documentation of reality, not aspiration
- Use semver explicitly; minor for additive, major for breaking, with notice
- Maintain old and new versions in parallel during the migration window
- Avoid
- Cut over consumers unilaterally; the rollout is a negotiation, not a mandate
- Skip observation mode; thresholds need real data before they can be enforced
- Treat the rollout as one project; treat each version bump as its own change
Contracts and quality become a system only when the producer can roll back any version because every consumer is pinned. The concise statement of the rollout principle: a legacy rollout earns its trust by treating consumers as parties to a negotiation, not as inventory to be migrated, structured by versions and notice periods. The trust accumulates over the months in which no consumer is broken by surprise.
About this lesson
This section is part of the Data Quality: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.