Consumer-Driven Contracts Break on Shared Schemas

Pact has been the de-facto standard for consumer-driven contract testing since around 2015. Most teams that adopt it do so correctly at first: one consumer, one provider, one contract. The model holds cleanly until a second consumer appears — and then a third — and they all read from the same shared Avro or Protobuf schema published to a Confluent Schema Registry. At that point the contract model doesn't fail loudly. It fails by becoming meaningless.

The specific failure mode is this: when multiple consumers register contracts against a provider that publishes a single canonical schema, any individual consumer's contract is trivially satisfied — not because the provider is compatible, but because the schema already enforces the superset. Pact's verification step passes. CI is green. A consumer-breaking change ships anyway.

By the end of this article you'll understand exactly where the consumer-driven contract model cracks under shared-schema pressure, how to detect the failure in your current suite, and what architectural corrections restore meaningful signal — including where Kafka-driven validation needs to replace or supplement Pact entirely.

Build an API Automation Framework in Python

Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.

Learn more

Why Shared Schemas Undermine the Contract Model

Consumer-driven contract testing assumes a bilateral relationship: one consumer defines expectations, the provider verifies it can satisfy them. Pact encodes this as a JSON pact file — a recorded interaction with request matchers and response assertions. The provider replays those interactions against its real implementation. If the provider's response matches the consumer's expectations, the contract passes. The guarantee is narrow and intentional: the provider only needs to satisfy this consumer's view of the API.

Shared schemas break that bilateralism. When a provider's response shape is governed by a centrally-managed Avro or Protobuf schema — and every consumer is expected to parse the same envelope — the schema becomes the de facto contract. Pact contracts written on top of it are redundant at best. At worst, they create false confidence: a consumer that only reads three fields from a 40-field schema will have a Pact contract that passes even after the provider silently drops one of those three fields, because the schema registry still accepts the new version as backward-compatible. Schema registry drift is a distinct failure class from contract drift, and conflating them is where most teams lose signal.

Diagnosing and Rebuilding Contract Coverage for Shared-Schema Providers

Start by auditing your existing Pact contracts for schema-overlap. If your provider publishes to a Confluent Schema Registry and your Pact interactions use like() or eachLike() matchers that mirror the registered schema fields exactly, you have redundant coverage — not additive coverage. The test is verifying that the schema matches the schema.

# Problematic Pact consumer test (Python, pact-python 2.x)
# This mirrors the Avro schema exactly — adds zero signal beyond registry validation.
(pact
  .upon_receiving("an order event")
  .with_request("GET", "/orders/42")
  .will_respond_with(
    status=200,
    body={
      "orderId": like("abc-123"),
      "customerId": like("cust-99"),
      "lineItems": each_like({"sku": like("X1"), "qty": like(1)}),
      "status": like("PENDING"),
      "createdAt": like("2024-01-01T00:00:00Z"),
    }
  )
)

Every field here is in the Avro schema. If the provider drops customerId and bumps the schema version, Confluent's backward-compatibility check still passes (the field was nullable). Pact's verification also passes because the provider's new response still satisfies the like() matcher on the remaining fields. The contract test told you nothing the schema registry didn't already tell you — and the schema registry lied.

The fix is to write contracts that express consumer-specific semantic invariants, not structural echo. Your billing service doesn't need lineItems; it needs customerId and status. Write the Pact interaction to assert only those fields, and assert them with value constraints, not just type matchers. For Kafka-based providers, Pact's message pact support (available in pact-jvm 4.x and pact-python 2.x) lets you validate the deserialized message payload against consumer-specific expectations:

# Message Pact for a Kafka consumer (pact-python 2.x)
pact = MessagePact(consumer, provider)

(pact
  .given("an order is confirmed")
  .expects_to_receive("an order confirmed event")
  .with_content({
    "customerId": like("cust-99"),
    "status": term(matcher=r"CONFIRMED|SHIPPED", generate="CONFIRMED"),
  })
  .with_metadata({"contentType": "application/json"})
)

This contract fails if customerId is removed or status is renamed — which is exactly what you need. For a real Kafka topology, pair message pacts with event-driven flow validation to catch ordering and consistency violations that contract tests can't see. A team at a mid-size fintech reduced false-green contract runs from roughly 30% of their suite to under 4% after scoping Pact interactions to consumer-specific fields and adding a separate schema-drift check in CI. Their provider verification step dropped from 18 minutes to 4 minutes as a side effect — fewer interactions to replay, each one semantically meaningful.

CI Integration

In GitHub Actions, gate provider verification on both Pact Broker results and a schema compatibility check as separate steps — not a single combined gate. If they're merged, a schema regression can mask a contract regression or vice versa:

# .github/workflows/provider-verify.yml (excerpt)
jobs:
  contract-verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify Pact contracts
        run: pytest tests/pact/provider_verify.py
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}

  schema-compat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check schema compatibility
        run: |
          docker run --rm confluentinc/cp-schema-registry:7.6.0 \
            kafka-schema-registry-check \
            --schema src/schemas/order.avsc \
            --subject order-value \
            --compatibility BACKWARD

Keeping these as separate jobs means a schema-only failure doesn't silently suppress a contract failure in the logs — a subtle but important separation that pays off when you're debugging at 2am.

Where Senior Engineers Still Get This Wrong

The most common mistake is treating Pact Broker's "can-i-deploy" check as a comprehensive compatibility gate. It isn't. can-i-deploy answers one question: does the provider's latest verified pact satisfy the consumer's recorded interactions? It says nothing about whether the schema registry's compatibility rules are strict enough to protect your consumers. Teams that run can-i-deploy and skip schema-level checks are operating with a gap. This happens because Pact is well-documented and the schema registry tooling is less ergonomic — engineers default to the path of least resistance.

A second failure is provider-side test pollution. When multiple consumers share a provider, teams sometimes merge all consumer contracts into a single verification run without isolating failures per consumer. A failing interaction from Consumer A blocks Consumer B's deployment even when Consumer B's contract is clean. This is an org-level problem masquerading as a tooling problem: it usually means no one owns the contract verification pipeline end-to-end. The fix is per-consumer verification jobs with tagged pact versions in the broker — Pact Broker supports consumer version selectors in pact-jvm 4.x and pact-python 2.x that make this straightforward to configure.

Myths That Persist in Shared-Schema Environments

Myth 1: "Backward-compatible schema changes are always safe for consumers." Confluent's backward compatibility means a new schema can read data written with the old schema. It does not mean every consumer application can handle the change. A consumer that pattern-matches on an enum value that gets a new variant added will break at runtime even though the schema registry reports full compatibility. Contract tests should cover application-level semantics, not just structural compatibility — and those are different things.

Myth 2: "More consumers on a provider means stronger contract coverage." It often means the opposite. When ten consumers each write contracts that mirror the full schema, the provider verification suite becomes slow, brittle, and redundant. Each additional consumer adds noise without adding signal. The right mental model is that each consumer contract should cover the delta between what the schema guarantees and what that specific consumer actually depends on. If you're evaluating how AI tooling might help trim that redundancy systematically, the approach described in context-driven test generation with LLMs is worth examining — though it requires careful scoping to avoid generating exactly the schema-echo problem described here.

Consumer-driven contracts are still the right tool for API compatibility — but only when each contract expresses something the schema registry can't. If your Pact interactions mirror your Avro schema field-for-field, you're paying the maintenance cost of two systems for the coverage of one. The next thing worth measuring after tightening your contracts is how often your provider verification and schema-compat checks diverge in CI — that divergence rate is a direct proxy for how much hidden risk your current setup is carrying. A high divergence rate often surfaces architecture debt in the test suite that's worth addressing before it compounds.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles