Guide contents

Lesson 67 of 129

API Versioning and Contract Testing

Services deploy independently, so a provider and its consumers run different versions at the same time. A provider change that breaks a consumer causes an outage that no single service's tests reveal. Managing this requires compatibility rules for changes, a migration procedure, and automated checks of the contracts between services. For general REST versioning strategies see API pagination, idempotency and versioning.

Compatibility

  • Backward compatible change: new provider version works with old consumers. Example: adding an optional response field.
  • Forward compatible change: old provider version works with new consumers, or a consumer tolerates data from a newer producer. This requires consumers to ignore unknown fields.

For messages on a broker, both matter: messages already stored in a topic may be written by older producers and read by newer consumers (backward), and new messages may be read by older consumers not yet upgraded (forward).

Change Compatible?
Add optional field Yes, if consumers ignore unknown fields
Add required request field No
Remove a field No, consumers may read it
Rename a field No (equals remove plus add)
Change a field type No
Add an enum value Risky: consumers with strict enums fail
Tighten validation No

Semantic changes are breaking even when the shape is unchanged. Examples: a field that changes unit (seconds to milliseconds), a status value that changes meaning, or a call that becomes asynchronous. Schema comparison tools cannot detect these, so they require contract tests or explicit review.

Consumer rule (the tolerant reader): read only the fields you need, ignore unknown fields, and do not fail on unknown enum values.

Expand and contract

Breaking changes are made in several compatible steps, also called parallel change:

  1. Expand. Add the new field, endpoint, or column alongside the old one. Providers support both.
  2. Migrate. Update consumers to use the new form, one at a time.
  3. Contract. After all consumers have moved (verified by usage metrics or contract tests), remove the old form.

Example: renaming fullName to name. The provider returns both fields, consumers switch to name, and the provider then drops fullName.

flowchart LR
    A["v1: fullName"] --> B["Expand: fullName + name"]
    B --> C["Migrate consumers to name"]
    C --> D["Contract: name only"]

The same pattern applies to database schemas (see CI/CD and deployment strategies) and to event schemas, where old and new event types are published in parallel during migration.

Versioning strategies

When a breaking change cannot be avoided, publish a new version alongside the old one: a version in the URL path, a header, or a media type, or a new topic or event type for messages. Running multiple versions costs maintenance, so set a deprecation policy and monitor which consumers still call old versions. Prefer additive evolution and reserve new versions for real breaks.

Consumer-driven contract testing

End-to-end tests across all services are slow and brittle. Contract testing verifies the interaction between one consumer and one provider without deploying both together.

In consumer-driven contracts, each consumer states what it needs from the provider. Pact is the common tool:

  1. The consumer's tests run against a mock provider and record the requests it makes and the responses it expects into a pact file.
  2. The pact is published to a broker (the Pact Broker).
  3. The provider's build fetches the pacts of its consumers and replays each request against the real provider, verifying the responses match the expectations.
  4. Verification results are stored. A can-i-deploy check queries the broker to decide whether a version is compatible with the versions currently deployed in an environment.
sequenceDiagram
    participant C as Consumer CI
    participant B as Pact Broker
    participant P as Provider CI
    C->>B: publish pact
    P->>B: fetch pacts of consumers
    P->>P: replay requests against provider
    P->>B: publish verification result
    C->>B: can-i-deploy?
    B-->>C: yes / no

A simplified pact interaction, recorded from a consumer test, contains the request, the expected response, and an optional provider state that the provider verification sets up before replaying it:

JSON
{
  "consumer": { "name": "order-service" },
  "provider": { "name": "customer-service" },
  "interactions": [
    {
      "description": "a request for customer 42",
      "providerStates": [{ "name": "customer 42 exists" }],
      "request": { "method": "GET", "path": "/customers/42" },
      "response": {
        "status": 200,
        "body": { "id": 42, "name": "Ada" }
      }
    }
  ]
}

The consumer only lists the fields it reads (id, name). The provider may add other fields freely, but removing or renaming name fails verification of this pact.

Properties:

  • The pact contains only the fields and behaviors consumers actually use, so the provider learns which fields can be changed or removed safely.
  • Tests run quickly in each service's own pipeline.
  • Contract tests check the shape and semantics of interactions, not the provider's full business logic.

Contract testing supports messaging as well: the pact describes the message content a consumer expects.

Alternative: provider-driven approaches such as OpenAPI specifications with breaking-change detection tools compare the new spec against the previous one in CI.

Schema registries

For messaging, a schema registry stores versioned schemas (Avro, Protobuf, JSON Schema) for topics. Producers register schemas, messages carry a schema ID, and the registry enforces a configured compatibility mode when a new schema version is registered:

  • Backward: consumers using the new schema can read data written with the previous one.
  • Forward: consumers using the previous schema can read data written with the new one.
  • Full: both.
  • Transitive variants check against all earlier versions, not only the latest.

The mode determines the upgrade order. With backward compatibility, consumers are upgraded first, since the new consumer schema can read both old and new data. With forward compatibility, producers are upgraded first, since old consumers can still read the new data. Full compatibility allows any order. Non-transitive modes compare a new schema only against the latest registered version, so a chain of individually compatible changes can still be incompatible with an older version whose data remains in the topic.

Registries reject incompatible schemas at publish time, before bad messages enter the system. Protobuf field numbers and Avro defaults are what make additive evolution safe: fields are identified by number (Protobuf) or by name with default values (Avro), so adding or removing optional fields does not break readers. See gRPC and Protobuf.

Format Identifies fields by Safe changes Unsafe changes
Protobuf Field number Add a field with a new number, remove a field and reserve its number Reuse a number, change a field's type incompatibly, renumber
Avro Field name, resolved between writer and reader schemas Add or remove a field that has a default value Add a field without a default (breaks backward), rename without an alias
JSON Schema Property name Add optional properties (if additional properties are allowed) Add required properties, change types

Interview questions

  • What is a tolerant reader? A consumer that ignores unknown fields and unexpected enum values and reads only what it needs, allowing providers to add fields without breaking it.
  • How do you rename a field without downtime? Expand by returning both names, migrate consumers to the new one, verify no one uses the old one, then remove it.
  • How does consumer-driven contract testing differ from end-to-end tests? Each consumer-provider pair is verified in isolation through recorded expectations, so tests are fast, do not need a full environment, and pinpoint the broken interaction.
  • Why do event schemas need stricter compatibility than REST APIs? Events persist in the log and are read later by consumers of different versions, so both old data with new consumers and new data with old consumers must work.