Home/Learn/Microservices/Backward Compatibility & Contract Testing

Backward Compatibility & Contract Testing

Intermediate
Patterns

Consumer-Driven Contract Testing (Pact) verifies that provider APIs remain compatible with consumer expectations, catching breaking changes before they reach production.

Overview

In a microservice architecture, independent deployments create a version compatibility problem: when Service A (consumer) depends on Service B (provider), changes to B's API may break A without either team knowing until production. Consumer-Driven Contract Testing (CDCT) addresses this by having the consumer write expectations (a "contract") about the provider's API, and the provider verifies it can satisfy those contracts in its own CI pipeline. Pact is the most popular CDCT framework. The alternative — schema evolution (Avro, Protobuf, JSON Schema Registry) — enforces compatibility rules at the message schema level for event-driven systems. Both approaches catch breaking changes before they reach production.

Consumer-Driven Contract Testing with Pact

The consumer defines a Pact contract — the minimum response shape it needs from the provider. The Pact broker stores contracts. The provider downloads contracts from the broker and verifies it can produce the expected responses. If the provider changes its response in a breaking way, the verification fails in CI before deployment.

Java — Pact consumer contract definition and mock server test
// Consumer side — define contract in a consumer test
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "inventory-service")
public class InventoryClientContractTest {

    @Pact(consumer = "order-service")
    public RequestResponsePact stockLookupPact(PactDslWithProvider builder) {
        return builder
            .given("product 101 exists with stock 50")
            .uponReceiving("a request for product stock")
                .path("/stock/101")
                .method("GET")
            .willRespondWith()
                .status(200)
                .body(new PactDslJsonBody()
                    .integerType("productId", 101)    // type-only matching (flexible)
                    .integerType("quantity", 50)
                    .stringType("unit", "units"))
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "stockLookupPact")
    public void testStockLookup(MockServer mockServer) {
        InventoryClient client = new InventoryClient(mockServer.getUrl());
        StockResponse response = client.getStock(101L);
        assertThat(response.getQuantity()).isGreaterThanOrEqualTo(0);
        // Pact generates a contract JSON file — publish to Pact Broker
    }
}

Provider verification in CI

The provider downloads contracts from the Pact Broker and runs them against a real (or started) provider. If the provider changes its response structure in a way that breaks the contract, the verification test fails. This catches breaking changes in the provider's CI pipeline before deployment — not in production.

Java — provider verification against Pact Broker contracts
// Provider side — verify contracts from Pact Broker
@Provider("inventory-service")
@PactBroker(
    url = "https://pact-broker.example.com",
    authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class InventoryProviderContractTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    // Set up test state matching "given()" in consumer contract
    @State("product 101 exists with stock 50")
    public void setupProduct101() {
        // Insert test data into database or mock the repository
        inventoryRepository.save(new Product(101L, "Widget", 50, "units"));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
    }
}

// Publish results to Pact Broker (in CI)
// pact.verifier.publishResults=true
// pact.provider.version=${GIT_COMMIT}
// pact.provider.branch=${GIT_BRANCH}

Schema evolution rules for Avro and JSON Schema

For event-driven systems using Kafka with a Schema Registry, compatibility rules enforce that schema changes remain compatible. BACKWARD compatibility (new schema can read old messages) is the default for consumer evolution. FORWARD compatibility (old schema can read new messages) is needed when producers change first. FULL compatibility enforces both.

CLI — Schema Registry compatibility modes and safe evolution rules
# Confluent Schema Registry compatibility modes
# BACKWARD (default): new schema can read messages from previous schema
#   → consumers can be upgraded before producers
#   → safe to: add optional fields, remove fields
#   → NOT safe to: remove required fields, change field types

# FORWARD: old schema can read messages from new schema
#   → producers can be upgraded before consumers
#   → safe to: add fields with defaults

# FULL: both BACKWARD + FORWARD
#   → safe additions only: new optional fields with defaults

# Set compatibility for a subject
curl -X PUT http://schema-registry:8081/config/orders-value \
  -H "Content-Type: application/json" \
  -d '{"compatibility": "FULL"}'

# Avro schema evolution example (BACKWARD compatible change)
# V1: {"type":"record","name":"Order","fields":[
#   {"name":"id","type":"int"},
#   {"name":"total","type":"double"}
# ]}

# V2: add optional field with default (BACKWARD compatible)
# {"name":"customerId","type":["null","string"],"default":null}

# NOT compatible (would break BACKWARD): removing "total" field

Key Points to Remember

  • 1Consumer-Driven Contract Testing catches breaking API changes in provider CI before they reach production
  • 2Pact contracts define minimum required response shape — type-matching (not value-matching) makes contracts resilient
  • 3Provider state ("given()" in Pact) sets up test data to match each consumer scenario — must be deterministic
  • 4Publish verification results to Pact Broker with git commit/branch — enables "can-i-deploy" checks in deployment pipelines
  • 5Avro/JSON Schema BACKWARD compatibility: add optional fields; NOT backward compatible: remove/rename required fields
  • 6FULL compatibility (add optional fields with defaults) is the safest evolution strategy for bidirectional compatibility

Interview Questions

Sign in to ask Aria
1

What problem does Consumer-Driven Contract Testing solve that integration tests cannot?

MediumThoughtworks
2

Who writes Pact contracts — the consumer or the provider?

EasyInfosys
3

What is BACKWARD schema compatibility in Avro and what changes are safe to make?

MediumConfluent
4

How does the "can-i-deploy" check in Pact Broker prevent breaking deployments?

HardNetflix
5

How would you handle a breaking change to an API that has consumer contracts written against it?

HardAmazon

Ask Aria about Backward Compatibility & Contract Testing

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…