API Contracts: Design, Versioning, and Contract Testing
Master API contract design for microservices including OpenAPI specs, semantic versioning strategies, and automated contract testing.
API Contracts: Design, Versioning, and Contract Testing
Microservices create a problem that monoliths never have: keeping distributed services in sync. Your user service expects a certain payload from the order service. Your billing service assumes a particular response shape from the inventory service. Someone changes the order service on a Friday afternoon, deploys, and Monday morning you are tracking down failures that started somewhere unexpected.
I have watched this play out more times than I care to count. The fix is not more meetings or better Slack messages. It is treating API contracts as concrete artifacts that get tested automatically, not just documentation that everyone promises to read.
Introduction
An API contract spells out the agreement between a service provider and its consumers. It describes what requests the provider accepts, what responses it returns, and what those responses look like. Done well, it lets providers and consumers change independently, as long as neither violates the agreed interface.
A monolith skips this because everything lives in one codebase. One deploy, one team, no problem. Microservices break that simplicity. Multiple teams ship on different schedules, with different failure tolerances. Without a contract that can actually be enforced and tested, you are relying on documentation and hope. Hope does not hold up past a handful of services.
The contract becomes a buffer. The order service team can ship on Tuesday without a sync meeting with the user service team, as long as the new version still honors the existing contract. That is what independent deployability is really about.
Core Concepts
The most widely adopted standard for describing REST APIs is the OpenAPI Specification (OAS). Originally known as Swagger, it provides a machine-readable format for defining your API endpoints, request/response schemas, authentication methods, and more.
A basic OpenAPI spec for a user endpoint looks like this:
openapi: 3.1.0
info:
title: User Service API
version: 1.0.0
paths:
/users/{userId}:
get:
summary: Get user by ID
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
properties:
id:
type: string
email:
type: string
format: email
createdAt:
type: string
format: date-time
The spec gives you several things for free. You can generate client SDKs, spin up mock servers, validate requests against the schema, and produce interactive documentation. Most teams start with OpenAPI when they want to bring discipline to their API design process.
|You can read more about REST API design principles in my post on RESTful API Design.
API Versioning Strategies
At some point, you will need to change your API. Perhaps you need to add new fields, rename something, or change the structure of a response. This is where versioning becomes critical.
Three approaches exist for versioning APIs.
URL Path Versioning
You embed the version number directly in the URL path. For example, /api/v1/users or /api/v2/orders. This is the most explicit approach and the easiest to route. You can see exactly which version you are calling, and load balancers can route accordingly.
The downside is that it violates the idea that a URL should identify a resource uniquely. /api/v1/users and /api/v2/users are technically the same resource according to REST purists, but they have different URLs. It can also get messy if you have many versions floating around.
Header Versioning
With header versioning, the URL stays the same, but clients include a header like API-Version: 2024-01-01 or Accept: application/vnd.mycompany.v2+json. This keeps URLs clean but makes testing and debugging harder since the version is invisible in the URL.
Query Parameter Versioning
You pass the version as a query parameter: /api/users?version=2. It is simple but easy to forget and harder to route at the infrastructure level.
My recommendation is URL path versioning for most teams. The explicitness pays off in operational simplicity. You always know which version is running, and you can route traffic based on path alone.
For a deeper dive into the tradeoffs between these approaches, see my post on API Versioning Strategies.
Semantic Versioning for APIs
Semantic versioning (SemVer) gives you a formal system for communicating the nature of changes. The format is MAJOR.MINOR.PATCH.
- MAJOR version increments when you make breaking changes
- MINOR version increments when you add functionality in a backward-compatible way
- PATCH version increments when you make backward-compatible bug fixes
For APIs, the rules are fairly strict. A breaking change includes removing fields, changing field types, renaming endpoints, or changing the semantics of a response. Adding optional request fields or adding new response fields are backward-compatible changes that warrant a MINOR bump.
The key insight is that you should never deploy a MAJOR version bump without a migration path. If your user service is on version 3, the old version 2 should continue working until all consumers have migrated. This is where API gateways become invaluable. You can route traffic between versions, giving consumers time to adapt.
Understanding Contract Testing
This is where contract testing comes in. The question it answers: how do you verify that your service provider satisfies every consumer expectations, without standing up your entire microservices stack for every test run?
Traditional integration testing requires all services running together. With fifty microservices, you either run all fifty locally (unworkable) or in a shared environment (slow, flaky, requiring constant coordination).
Contract testing flips this. Instead of testing the whole system, you test the agreement between one consumer and one provider in a controlled, isolated setting.
Two approaches exist.
Provider-Driven Contracts
The provider defines its promises. The provider team writes tests validating their own behavior against the contract. Consumers then trust the provider assertions.
Simple to set up, but it places the burden on the provider team to track what every consumer actually needs.
Consumer-Driven Contracts
Each consumer describes what it requires from the provider. Those requirements get encoded as tests. The provider then verifies it satisfies all consumer contracts before shipping.
This gives every consumer a voice. If the billing service needs the totalAmount field, that expectation lives in a contract test that the provider must pass. The billing team does not have to hope the order service team remembered their requirements. They can verify it automatically.
Consumer-driven contracts shine in multi-team environments. Dependencies become visible and testable, and provider teams gain real confidence that their changes are safe.
Breaking vs Non-Breaking Changes
Understanding which changes break contracts and which do not is essential for safe API evolution.
Non-Breaking Changes
These changes do not require a MAJOR version bump and should not break existing consumers.
- Adding new optional fields to request bodies
- Adding new fields to response bodies
- Adding new endpoints
- Making previously required fields optional
- Adding new optional query parameters
Breaking Changes
These changes require a MAJOR version bump and a migration strategy.
- Removing fields from requests or responses
- Changing the type of a field (string to number, for example)
- Renaming fields or endpoints
- Making optional fields required
- Changing the structure of a response (nested objects where there were none)
- Changing authentication requirements
The safest approach is to think of your API as a public promise. Once you publish version 1.0, you need to support it until all consumers have migrated. This is why adding required fields to responses is dangerous, even though it seems like a minor change. A consumer that was written before that field existed might not handle it correctly.
Contract Testing with Pact
Pact is the most popular consumer-driven contract testing framework. It works by having consumers define their expectations, generating a “pact” file, and then having providers verify against that pact.
Here is how it works in practice with a consumer test.
@ExtendWith(PactConsumerTestExt.class)
class UserServiceConsumerPactTest {
@Pact(consumer = "UserService", provider = "OrderService")
V4Pact getOrdersPact(PactDslWithProvider builder) {
return builder
.uponReceiving("a request for user orders")
.path("/orders")
.query("userId", "123")
.method("GET")
.willRespondWith()
.status(200)
.body(newJsonBody(body -> {
body.stringValue("orderId", "ord-456");
body.stringValue("status", "shipped");
body.stringValue("totalAmount", "29.99");
}).build())
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "getOrdersPact", port = "8080")
void testOrders(MockServer mockServer) {
OrderClient client = new OrderClient(mockServer.getUrl());
Order order = client.getOrdersForUser("123");
assertEquals("ord-456", order.getOrderId());
assertEquals("shipped", order.getStatus());
}
}
This test defines exactly what the consumer expects from the order service. When this test runs, Pact records the interaction and generates a pact file. The order service team can then download this pact file and verify their implementation satisfies it.
For Spring Boot applications, Spring Cloud Contract is another excellent option. It follows a similar philosophy but integrates more tightly with the Spring ecosystem. You define your contracts using Groovy or YAML, and it generates both provider and consumer tests automatically.
Contract Flow Diagram
Understanding the flow of contract testing helps visualize how everything connects. Here is a diagram showing the typical contract testing lifecycle.
graph TD
A[Consumer writes CDC test] --> B[Pact file generated]
B --> C[Pact file published to Pact Broker]
D[Provider pulls latest pact] --> E[Provider runs contract verification]
E --> F{All contracts satisfied?}
F -->|Yes| G[Safe to deploy provider]
F -->|No| H[Provider must fix failures]
H --> E
C --> D
I[New version of Provider deployed] --> J[Consumer notified of changes]
J --> K{Consumer contract still valid?}
K -->|No| L[Consumer updates their CDC test]
L --> A
The Pact Broker acts as a central hub. Providers pull contracts from the broker and verify against them. When a provider deploys successfully, it publishes its verification results. If a provider breaks a contract, the broker can notify affected consumers.
Service Orchestration vs Choreography
When you have multiple services that need to coordinate, there are two broad patterns: orchestration and choreography.
In orchestration, you have a central coordinator that directs the flow. It calls each service in sequence, handles responses, and decides what to do next. This is straightforward to understand and implement, but the coordinator becomes a bottleneck and a single point of failure.
In choreography, services react to events and publish their own events. There is no central coordinator. Each service only knows about its own responsibilities. This is more resilient and allows services to evolve independently, but it can be harder to trace end-to-end flows.
Both approaches benefit from contract testing. In an orchestrated system, the orchestrator is a consumer of all the downstream services, so it should have contract tests for each one. In a choreographed system, every service that publishes events should have contracts that describe those events, and every service that consumes events should have contracts that describe what it expects.
You can read more about these patterns in my posts on Service Orchestration and Service Choreography.
Common Pitfalls
If you are starting from scratch, here is what I would suggest based on what actually works.
Start with OpenAPI. Define your contracts upfront. Even if you skip contract testing for now, a machine-readable spec forces you to reason about your API design before you write code. Retrofitting contracts onto an existing system is painful.
Pick one service to pilot consumer-driven contracts. Not your whole organization at once. Pick a service with multiple consumers, write the contracts, show the value. Small wins build momentum.
Get a Pact Broker or something similar. The broker is what makes this manageable at scale. Without it, you are tracking pact files by hand, which becomes a mess fast.
Take breaking changes seriously. Major version bumps should be rare and announced with plenty of lead time. Run old versions in production alongside new ones until consumers have migrated. An API gateway helps here.
|Automate contract verification in your CI pipeline. It should run on every pull request. If a provider breaks a consumer contract, the build fails before the code merges. That is the safety net.
Production Failure Scenarios
Understanding what goes wrong is as important as knowing the right patterns.
Pact Broker goes down. Provider builds still pass (cached pacts) but you lose canary detection of breaking changes. Run broker health checks in your CI dashboard and consider redundant broker deployments for production-grade setups.
Consumer contract changes but the provider is not notified in time. This is the classic cascade failure scenario. Use webhook notifications from your Pact Broker to alert consumers immediately when a provider publishes verification results. Automate consumer contract re-verification on provider deploy events.
Breaking change sneaks through because the contract was not enforced in CI. If contract tests are not blocking merges, they become optional hygiene. Make them required status checks. A provider should not be able to merge a PR that breaks a consumer contract.
New field added to response breaks an older consumer. Adding fields is backward-compatible in theory, but a consumer with strict JSON parsing or a code generator that builds strongly-typed models from the schema may still fail. Version your schemas and run consumer contract tests across all supported major versions.
Provider assumes a consumer only needs minimal fields (over-fetching). This is not a breaking change but a performance issue. Consumer-driven contracts explicitly define what each consumer needs, preventing providers from making assumptions that become hidden performance bottlenecks.
Trade-off Analysis
| Concern | Provider-Driven | Consumer-Driven |
|---|---|---|
| Setup complexity | Lower — provider writes tests alone | Higher — requires coordination and broker infra |
| Consumer confidence | Medium — trusts provider assertions | High — each consumer verifies their own needs |
| Visibility of dependencies | Low — provider tracks manually | High — all dependencies visible in broker |
| Maintenance burden | Provider bears it | Shared between provider and consumers |
| Evolution speed | Faster — provider controls changes | Slower — requires consumer buy-in for changes |
| Failure detection | Consumer discovers breakages in production | Provider discovers breakages before deploy |
When to choose provider-driven: Small team, single consumer, or early-stage prototyping where setup speed matters more than dependency visibility.
When to choose consumer-driven: Multi-team environment, multiple consumers, or any system where production outages from breaking changes are costly.
Interview Questions
Expected answer points:
- Defines the explicit agreement between a service provider and its consumers
- Specifies request formats, response structures, and behavior
- Enables independent deployability so teams can ship without coordination meetings
- Acts as a buffer allowing provider and consumer to evolve independently without breaking each other
Expected answer points:
- URL Path Versioning (e.g.,
/api/v1/users) - Main advantage: operational simplicity — the version is explicit and visible in every request
- Routing, debugging, and load balancer configuration become straightforward
- Easy to route at the infrastructure level without additional configuration
Expected answer points:
- Provider-driven contracts: the provider team writes tests validating their own behavior
- Consumer-driven contracts (CDC): each consumer describes what it needs from the provider, encoded as tests that the provider must pass before shipping
- CDC gives every consumer a voice and automatic verification
- CDC shifts failure detection earlier — provider discovers breakages before deploy, not in production
Expected answer points:
- Any breaking change requires a MAJOR version bump
- Breaking changes include: removing fields, changing field types, renaming endpoints or fields
- Making optional fields required, changing response structures, altering authentication requirements
- Non-breaking changes (adding optional fields, new endpoints) warrant only a MINOR bump
Expected answer points:
openapiversion number (e.g., 3.1.0)infoobject with title, version, and descriptionpathsdefining all API endpoints with their operationscomponentscontaining reusable schemas, parameters, and security schemes- Machine-readable format enabling SDK generation, mock servers, and interactive documentation
Expected answer points:
- Non-breaking changes: adding optional fields, adding new response fields, adding new endpoints, making required fields optional
- Breaking changes: removing fields, changing field types, renaming fields/endpoints, making optional fields required, changing response structures
- Backward-compatible additions warrant MINOR/PATCH bumps; breaking changes require MAJOR bumps with migration paths
- Adding required fields to responses is dangerous even though it seems minor — older consumers may fail
Expected answer points:
- Acts as a central hub storing pact files from all consumers
- Providers pull contracts from the broker and verify against them
- When a provider deploys successfully, it publishes verification results to the broker
- If a provider breaks a contract, the broker notifies affected consumers via webhooks
- Without a broker, tracking pact files by hand becomes unmanageable as the system scales
Expected answer points:
- The Pact file is a machine-readable artifact generated from consumer tests
- It records the exact expectations a consumer has for a provider's API
- Provider teams download the pact file and verify their implementation satisfies it
- Enables isolated, automated verification without running the entire microservices stack
- Published to a Pact Broker for centralized management and version tracking
Expected answer points:
- Orchestration: central coordinator directs the flow, calling each service in sequence, handling responses, deciding next steps; simpler to implement but creates a single point of failure
- Choreography: services react to events and publish their own events; no central coordinator, more resilient, allows independent evolution
- Both benefit from contract testing — orchestrator is a consumer of all downstream services; in choreography, every event publisher and consumer should have contracts
- Choreography can be harder to trace end-to-end; orchestration can become a bottleneck
Expected answer points:
- Contract tests become blocking status checks — a provider cannot merge a PR that breaks a consumer contract
- Detects breaking changes before production, not after deployment
- Without automation, contract tests become optional hygiene rather than enforced safety nets
- Enables confident independent deployability — teams can ship without sync meetings
- Fails the build on every pull request if provider violates consumer expectations
Expected answer points:
- URL Path Versioning (
/api/v1/users): choose for operational simplicity, explicit routing, load balancer compatibility - Header Versioning (e.g.,
API-Version: 2024-01-01): choose when keeping URLs clean is priority, less visible in debugging - Query Parameter Versioning (
/api/users?version=2): choose for simplicity but easy to forget, hard to route at infrastructure level - Recommendation: URL path versioning for most teams due to explicitness and operational simplicity
Expected answer points:
- API contracts enable independent deployability by making implicit agreements between services explicit and testable
- Without contracts, teams must coordinate deployments to avoid breaking changes — defeating the purpose of microservices
- With contracts, the order service team can ship on Tuesday without syncing with the user service team, as long as the new version honors the existing contract
- The contract becomes the buffer that allows teams to evolve independently on different schedules
Expected answer points:
- Pact: language-agnostic, works with any HTTP client, consumer-driven philosophy
- Spring Cloud Contract: tightly integrated with Spring Boot ecosystem, uses Groovy or YAML to define contracts
- Spring Cloud Contract generates both provider and consumer tests automatically from the contract definition
- Pact is broker-centric with a Pact Broker for managing and distributing pact files
- Both follow the same core philosophy but differ in ecosystem fit and broker requirements
Expected answer points:
- Adding fields is backward-compatible in theory — consumer should ignore unknown fields
- In practice, consumers may fail due to: strict JSON parsers, code generators building strongly-typed models from schemas, older consumer implementations that serialize/deserialize strictly
- Over-fetching: provider assumes consumer only needs minimal fields, adding new fields causes performance issues
- Mitigation: version schemas, run consumer contract tests across all supported major versions, use API gateways for gradual rollout
Expected answer points:
- Machine-readable format that enables automated tooling — SDK generation, mock servers, validation
- Interactive documentation generation out of the box
- Standardized format adopted by the industry (originally Swagger)
- Enables contract testing and schema validation before code is written
- Supports REST API endpoints, request/response schemas, authentication, and more
Expected answer points:
- Consumer writes CDC test defining expectations for the provider API
- Pact test runs and generates a pact file — a machine-readable artifact of the interaction
- Pact file is published to a Pact Broker (central hub)
- Provider pulls latest pact from broker and runs contract verification against their implementation
- If verification passes, provider can safely deploy; if it fails, provider must fix before deploy
- Provider publishes verification results; broker notifies affected consumers of any changes
Expected answer points:
- Version is invisible in the URL — makes testing and debugging harder since version is not immediately visible
- Requires additional tooling or inspection to determine which version is being called
- Load balancer and infrastructure routing become more complex without version in the path
- Consumers cannot easily specify or swap versions without code changes
- Harder to track and monitor version distribution in logs and analytics
Expected answer points:
- Breaking changes affect existing consumers who built against the old contract
- Without a migration path, consumers break immediately upon provider deploy
- Old version should continue running in production until all consumers have migrated
- API gateway enables routing traffic between versions during transition period
- Migration time varies by consumer count and coordination complexity — can take weeks or months
- Without this buffer, you lose the independent deployability that microservices promise
Expected answer points:
- Traditional integration testing requires all 50 microservices running together — slow, flaky, coordination-heavy
- Contract testing isolates the consumer-provider interaction — no need to run the full stack
- Provider discovers breakages before deploy (in CI), not after deployment to production
- Consumer teams get immediate feedback when a provider changes something they depend on
- Eliminates the Monday morning cascade failure scenario where one Friday deploy breaks everything
- Tests run fast in isolation, enabling fast iteration and confident releases
Expected answer points:
- Choose provider-driven when: small team, single consumer, early-stage prototyping where setup speed matters
- Choose consumer-driven when: multi-team environment, multiple consumers, production outages are costly
- CDC provides higher confidence and dependency visibility — every consumer verifies their own needs
- Provider-driven is lower complexity but gives less visibility into actual consumer requirements
- Consider Pact Broker infrastructure requirements — CDC needs a broker, provider-driven does not
- For critical services with multiple consumers, CDC is almost always the better choice despite higher setup cost
Further Reading
- RESTful API Design — REST principles and best practices
- API Versioning Strategies — Deep dive into URL, header, and query parameter versioning
- Service Orchestration — Centralized coordination patterns
- Service Choreography — Event-driven coordination patterns
- Pact Broker Documentation — Official Pact Broker setup and operations
- OpenAPI Initiative — Official OAS specifications and tooling
Conclusion
Use this checklist when designing or evolving an API contract:
- OpenAPI spec defined — Machine-readable contract exists for all endpoints
- Versioning strategy chosen — URL path, header, or query parameter consistently applied
- Breaking vs non-breaking changes documented — Team agrees on what constitutes a breaking change
- Semantic versioning rules established — MAJOR/MINOR/PATCH bump criteria agreed upon
- Contract testing framework in place — Pact Broker or equivalent deployed
- Consumer-driven contracts for multi-consumer services — Each consumer has explicit contract tests
- CI pipeline enforces contract verification — Contract tests are blocking, not optional
- Migration path for MAJOR bumps — Old version runs in parallel until all consumers migrate
- API gateway configured for version routing — Traffic split between versions during transitions
- Breaking change notifications automated — Webhooks alert consumers of provider verification results
API contracts are not documentation or an API design exercise. They are a safety net. They let teams move fast without accidentally breaking each other, and they make implicit agreements between services explicit and testable.
Contract testing is one of those practices that feels like pure overhead until you have worked without it. Then you miss it. The confidence of knowing your changes do not break downstream consumers — before production, every time — is worth the investment.
Pick one API. Write the contract. Add a consumer-driven test. Find out what breaks before your users do. That is the point.
Category
Related Posts
Data Contracts: Establishing Reliable Data Agreements
Learn how to implement data contracts between data producers and consumers to ensure quality, availability, and accountability.
Amazon Architecture: Lessons from the Pioneer of Microservices
Learn how Amazon pioneered service-oriented architecture, the famous 'two-pizza team' rule, and how they built the foundation for AWS.
Asynchronous Communication in Microservices: Events and Patterns
Deep dive into asynchronous communication patterns for microservices including event-driven architecture, message queues, and choreography vs orchestration.