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:
- Expand. Add the new field, endpoint, or column alongside the old one. Providers support both.
- Migrate. Update consumers to use the new form, one at a time.
- 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:
- The consumer's tests run against a mock provider and record the requests it makes and the responses it expects into a pact file.
- The pact is published to a broker (the Pact Broker).
- The provider's build fetches the pacts of its consumers and replays each request against the real provider, verifying the responses match the expectations.
- Verification results are stored. A
can-i-deploycheck 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 / noA 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:
{
"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.
