Loading learning content...
In a microservices architecture, services are like independent companies doing business with each other. Just as companies rely on legal contracts to define expectations, services rely on API contracts to define how they communicate. These contracts specify what requests look like, what responses to expect, and what error conditions may occur.
Without explicit, well-maintained contracts, microservices devolve into a fragile web of implicit dependencies. A seemingly innocent change in one service's response format cascades into production failures across dozens of dependent services. Teams become afraid to change anything, and the promised independence of microservices evaporates.
API contracts are not optional—they are the foundation upon which microservices independence is built.
By the end of this page, you will understand how to design robust API contracts, the different schema languages available, how to validate compatibility between contract versions, and how contract testing enables truly independent service deployment. You'll learn to treat APIs as products and contracts as the specification that enables ecosystem growth.
An API contract is a formal specification that describes the interface between two services. It defines:
The contract serves as the single source of truth for how services interact. It's both documentation and enforcement mechanism.
Think of your API contract as the user interface for developers. Just as a well-designed UI makes software easy to use, a well-designed contract makes services easy to integrate. The contract is often the first thing consumers see—it shapes their impression of your service and determines how quickly they can integrate.
Implicit contracts emerge naturally when services communicate:
These implicit contracts are dangerous because:
Explicit contracts are formal specifications:
| Aspect | Implicit Contract | Explicit Contract |
|---|---|---|
| Documentation | Tribal knowledge or outdated docs | Schema is the documentation (always current) |
| Discoverability | Read source code or ask the team | Published schema, auto-generated docs |
| Validation | Runtime errors in production | Compile-time errors, CI failures |
| Client Generation | Manual HTTP calls | Auto-generated type-safe clients |
| Breaking Changes | Discovered by angry consumers | Detected by compatibility checks |
| Evolution | Fear of changing anything | Confident evolution with versioning |
Several schema languages have emerged for defining API contracts. Each has strengths suited to different contexts.
The dominant standard for REST APIs. OpenAPI specifications are YAML or JSON documents describing HTTP endpoints, request/response schemas, authentication, and more.
Strengths:
Weaknesses:
Google's language-neutral, platform-neutral serialization format. Used primarily with gRPC but applicable beyond.
Strengths:
Weaknesses:
A vocabulary for annotating and validating JSON documents. Often used within OpenAPI but can stand alone.
Strengths:
Weaknesses:
OpenAPI's equivalent for asynchronous, event-driven APIs. Describes message brokers, topics, and event schemas.
Strengths:
Weaknesses:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
# OpenAPI 3.1 Specification Exampleopenapi: 3.1.0info: title: Order Service API version: 2.3.0 description: | API for managing customer orders. Supports order creation, retrieval, and status updates. paths: /orders: post: operationId: createOrder summary: Create a new order tags: [Orders] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateOrderRequest' responses: '201': description: Order created successfully content: application/json: schema: $ref: '#/components/schemas/Order' '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/ValidationError' '409': description: Duplicate order (idempotency key conflict) content: application/json: schema: $ref: '#/components/schemas/ConflictError' /orders/{orderId}: get: operationId: getOrder summary: Retrieve order by ID tags: [Orders] parameters: - name: orderId in: path required: true schema: type: string format: uuid responses: '200': description: Order found content: application/json: schema: $ref: '#/components/schemas/Order' '404': description: Order not found components: schemas: CreateOrderRequest: type: object required: [customerId, items, idempotencyKey] properties: customerId: type: string format: uuid description: The customer placing the order items: type: array minItems: 1 items: $ref: '#/components/schemas/OrderItem' idempotencyKey: type: string description: Client-generated key for deduplication x-example: "order-2024-01-08-abc123" OrderItem: type: object required: [productId, quantity] properties: productId: type: string format: uuid quantity: type: integer minimum: 1 maximum: 100 notes: type: string maxLength: 500 Order: type: object properties: id: type: string format: uuid customerId: type: string format: uuid items: type: array items: $ref: '#/components/schemas/OrderItem' status: type: string enum: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED] totalAmount: type: number format: decimal createdAt: type: string format: date-time updatedAt: type: string format: date-timeWell-designed contracts share common principles that ensure usability, maintainability, and evolvability.
"Be conservative in what you send, be liberal in what you accept" sounds wise but leads to problems. Liberal acceptance creates implicit contracts—consumers depend on lenient parsing. When you later enforce stricter parsing, you break those consumers. Instead: be strict in both sending AND receiving, but use explicit versioning to evolve without breaking.
Consistent naming dramatically improves contract usability:
Field Names:
customerEmailAddress not emailisActive, hasShipped, canCancelorderId, customerId (not just id)Endpoint Patterns:
/orders, /customers/orders/{orderId}/orders/{orderId}/cancel/customers/{customerId}/ordersEnum Values:
ORDER_STATUS_PENDING| Mistake | Problem | Better Approach |
|---|---|---|
| Stringly-typed fields | "2024-01-08" vs "01/08/2024" vs "Jan 8, 2024" | Explicit formats: format: date-time (ISO 8601) |
| Ambiguous nullability | Does [] mean empty list or unknown? | Separate null (unknown) from [] (known empty) |
| Generic error responses | {"error": "Something went wrong"} | Structured errors with codes, fields affected |
| Exposing internal IDs | Database autoincrement IDs leak info | UUIDs or opaque identifiers |
| Inconsistent casing | userId vs user_id vs UserID | Pick one convention, enforce via linting |
| No pagination | Return all 10,000 results | Cursor-based or offset pagination from start |
The ability to evolve contracts without breaking consumers is fundamental to microservices independence. This requires understanding compatibility rules.
A change is backward compatible if existing consumers continue to work without modification. The provider can be upgraded without requiring consumer updates.
Backward-Compatible Changes:
Breaking Changes (NOT Backward Compatible):
A change is forward compatible if new consumers can work with old providers. This is less common but important for gradual rollouts.
Forward-Compatible Changes:
Always add new things as optional. Never remove things without a deprecation period. Never change the meaning of existing things. When in doubt, create a new version of the API.
1234567891011121314151617181920212223
# Using buf for protobuf compatibility checking# buf.yaml configurationversion: v1breaking: use: - FILE # Check breaking changes at file levelrules: # Common breaking change rules - FIELD_NO_DELETE - FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED - FIELD_SAME_TYPE - FIELD_SAME_NAME - REQUIRED_FIELD_NO_DELETE - ENUM_VALUE_NO_DELETE - RPC_NO_DELETE - SERVICE_NO_DELETE # Run compatibility check against previous versionbuf breaking --against git#branch=main # Output example when breaking change detected:# order_service.proto:45:3: Field "15" on message "Order" changed type # from "string" to "int64".Contract testing verifies that services correctly implement their contracts without requiring end-to-end integration tests. It's the mechanism that enables independent deployment with confidence.
In a microservices system:
Contract testing solves this by:
Provider-Side Testing (Schema Validation) The provider tests that its actual responses match its published contract.
Contract Schema → Generate Mock Requests → Hit Real Provider → Validate Against Schema
Consumer-Side Testing (Pact-style) Consumers declare their expectations (what they need from providers). Providers verify those expectations.
1. Consumer writes tests recording expectations
2. Expectations published to "Pact Broker"
3. Provider downloads consumer expectations
4. Provider verifies it can fulfill all expectations
Bi-Directional Contract Testing Combining both: provider publishes schema, consumer publishes expectations, broker verifies compatibility between them.
| Approach | Who Writes Tests | What's Validated | Tooling Examples |
|---|---|---|---|
| Provider Schema Testing | Provider team | Responses match published schema | OpenAPI validators, Prism, Dredd |
| Consumer Contract Testing | Consumer team | Provider fulfills consumer expectations | Pact, Spring Cloud Contract |
| Bi-Directional | Both teams | Schema ↔ expectations compatibility | Pact + schema comparison |
| Schema Registry | Both teams | Message schemas compatible | Confluent Schema Registry, AWS Glue |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Consumer-side contract testimport { PactV3, MatchersV3 } from '@pact-foundation/pact'; const { like, eachLike, string, integer, uuid } = MatchersV3; describe('Order Service Contract', () => { const provider = new PactV3({ consumer: 'PaymentService', provider: 'OrderService', logLevel: 'warn', }); describe('when a valid order exists', () => { it('should return the order details', async () => { // Define the expected interaction await provider .given('an order with ID abc-123 exists') .uponReceiving('a request to get order abc-123') .withRequest({ method: 'GET', path: '/orders/abc-123', headers: { Accept: 'application/json', }, }) .willRespondWith({ status: 200, headers: { 'Content-Type': 'application/json', }, body: { // Define what we (the consumer) actually need // Not everything the provider returns - just what we use id: uuid('abc-123'), customerId: uuid(), totalAmount: integer(9999), status: string('CONFIRMED'), items: eachLike({ productId: uuid(), quantity: integer(1), }), }, }); // Execute the test await provider.executeTest(async (mockServer) => { const orderClient = new OrderClient(mockServer.url); const order = await orderClient.getOrder('abc-123'); // Verify our code handles the response correctly expect(order.id).toBe('abc-123'); expect(order.totalAmount).toBeGreaterThan(0); expect(order.items.length).toBeGreaterThan(0); }); }); }); describe('when order does not exist', () => { it('should return 404', async () => { await provider .given('no order with ID nonexistent exists') .uponReceiving('a request for nonexistent order') .withRequest({ method: 'GET', path: '/orders/nonexistent', }) .willRespondWith({ status: 404, body: { error: string('ORDER_NOT_FOUND'), message: string('Order not found'), }, }); await provider.executeTest(async (mockServer) => { const orderClient = new OrderClient(mockServer.url); await expect(orderClient.getOrder('nonexistent')) .rejects.toThrow('Order not found'); }); }); });});Pact pioneered 'consumer-driven contracts'—consumers declare what they need, and providers must satisfy those needs. This inverts the typical relationship: instead of providers dictating what's available, consumers drive the contract to include only what's actually used. This reveals unused fields and enables confident deprecation.
Managing contracts is an ongoing discipline, not a one-time design activity. Mature organizations treat contracts as first-class artifacts with defined lifecycle stages.
1. Design Phase
2. Publication Phase
3. Active Phase
4. Deprecation Phase
5. Retirement Phase
12345678910111213141516171819202122232425262728293031323334
# Deprecation in OpenAPIpaths: /orders/{orderId}/status: put: operationId: updateOrderStatus deprecated: true # Marks endpoint as deprecated x-deprecated-since: "2024-01-08" x-sunset-date: "2024-07-08" x-replacement: updateOrder summary: Update order status (DEPRECATED) description: | **DEPRECATED**: This endpoint will be removed on 2024-07-08. Use PATCH /orders/{orderId} instead with status field in body. Migration guide: https://docs.company.com/migrations/order-status components: schemas: Order: type: object properties: # Deprecated field - still returned but will be removed legacyCustomerId: type: string deprecated: true description: | DEPRECATED: Use 'customerId' (UUID format) instead. Will be removed after 2024-06-01. # New preferred field customerId: type: string format: uuid description: Unique customer identifierAPI contracts are the invisible infrastructure that enables microservices to operate as truly independent units. Without explicit contracts, you have a distributed monolith—tightly coupled services that must be deployed together and tested together.
Let's consolidate the key insights:
What's next:
Now that we understand how to define contracts, we'll explore the protocols that carry those contracts across the wire. Protocol selection—REST vs gRPC vs GraphQL vs message queues—determines performance characteristics, streaming capabilities, and operational complexity.
You now understand how to design, validate, test, and evolve API contracts. You can choose appropriate schema languages, implement contract testing, and manage the contract lifecycle. Next, we'll dive into protocol selection—choosing the right transport mechanism for different communication needs.