Service Contracts: Sync APIs, Async Events, Tolerant Readers
What This Concept Is
Every cross-service interaction is a contract. You cannot avoid having contracts; you can only decide whether they are explicit and versioned or implicit and fragile.
Three contract shapes cover almost all needs:
- Synchronous APIs. REST or gRPC, described by an OpenAPI or
.protoschema. Request-response, producer in charge of the shape. - Asynchronous events. Messages on a bus, described by an event schema (Avro/Protobuf/JSON Schema). Fire-and-forget, consumer in charge of interpretation.
- Tolerant readers. A style discipline applied on top of either. A consumer reads what it needs and ignores what it does not, so the producer can add fields without breaking the consumer.
Getting the contract right matters more than any code inside the service. Services die at their edges.
Why It Matters Here
Independent deployability (concept 01) is only real if you can change a service's internals without breaking its consumers. The contract is the interface at which that promise lives or dies.
Example: Sync API Contract (OpenAPI Fragment)
From the Orders service, an endpoint Inventory might call:
openapi: 3.0.3
info:
title: Orders API
version: "2.3.0"
paths:
/orders/{id}:
get:
summary: Get order by id
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Order found
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"404": { description: Not found }
components:
schemas:
Order:
type: object
required: [id, customer_id, status, items]
properties:
id: { type: string, format: uuid }
customer_id:{ type: string, format: uuid }
status: { type: string, enum: [pending, confirmed, cancelled, shipped] }
total_cents:{ type: integer, minimum: 0 }
items:
type: array
items: { $ref: "#/components/schemas/OrderItem" }
OrderItem:
type: object
required: [sku, quantity]
properties:
sku: { type: string }
quantity: { type: integer, minimum: 1 }
Compatibility rules for this contract:
- Adding a new optional field is backward compatible.
- Adding a new enum value to
statusis not backward compatible for strict readers -- only safe with tolerant readers. - Removing or renaming a required field is breaking.
Example: Event Schema
The OrderConfirmed event that Orders publishes for Inventory, Fulfillment, and Analytics to consume:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OrderConfirmed",
"type": "object",
"required": ["event_id", "event_version", "occurred_at", "order_id", "customer_id", "items"],
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_version": { "type": "integer", "const": 1 },
"occurred_at": { "type": "string", "format": "date-time" },
"order_id": { "type": "string", "format": "uuid" },
"customer_id": { "type": "string", "format": "uuid" },
"total_cents": { "type": "integer", "minimum": 0 },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["sku", "quantity"],
"properties": {
"sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
}
}
}
}
}
Note the event_version field. If the shape changes in a breaking way, publish a new event type (OrderConfirmedV2) alongside the old one for a deprecation window.
Tolerant Readers
A tolerant reader is a consumer that:
- reads only the fields it needs
- ignores unknown fields instead of failing
- treats enum values it does not recognize as a safe default (usually: "unknown" or "skip")
With tolerant readers on both sides, producers can add fields, add enum values, and evolve their payloads without a coordinated deploy. Without them, every field addition is a breaking change in practice.
A small test: "does our JSON deserializer fail on unknown fields?" If yes, you are not tolerant; fix that before you talk about contract evolution.
Common Confusion / Misconception
"Sync APIs are safer than events because they are typed." The typing is identical either way; only the transport differs. The safety comes from versioning discipline and tolerant readers, not from the protocol.
Second confusion: "events are fire-and-forget, so we do not need a schema." Events have contracts too, and the contract is more important for events because there are usually more consumers and no immediate feedback when a consumer breaks.
How To Use It
- For each cross-service interaction, name the contract type: sync API, event, or both.
- Write the schema (OpenAPI for sync, JSON Schema or Avro for events). Keep it in the producer's repo, published as an artifact.
- State the compatibility rules: what additions are safe, which changes are breaking, and what the deprecation window is.
- Enforce tolerant readers: deserializers must not fail on unknown fields.
- Version events with a
versionfield; version sync APIs in URL (/v1,/v2) or header.
Check Yourself
- Why is the tolerant-reader discipline independent of whether the transport is sync or async?
- What makes adding a required field to an existing event a breaking change, even if the producer tolerates missing fields?
- Why version events with a
versionfield rather than just "send the new shape"?
Mini Drill or Application
Take one cross-service interaction from the decomposition table in concept 07. In 15 minutes:
- Write an OpenAPI fragment for the sync version (at least one endpoint, one schema).
- Write a JSON Schema for the matching event.
- List three additions that are backward compatible and one that is breaking.
How This Sits In The Module
Concept 09 takes these contracts and shows how consumer-driven contract tests turn them into CI gates. Cluster 4 uses the contract as the substrate for resilience (timeouts apply per contract, retries apply per contract, etc.).
Schema Evolution Modes (Confluent-Style Taxonomy)
Confluent Schema Registry's taxonomy of compatibility modes is the most widely cited set of names for evolution rules, and it applies beyond Kafka:
| Mode | What the producer may do | What the consumer must be able to do |
|---|---|---|
| Backward | Add optional, remove optional | Read messages written with the previous schema |
| Forward | Add optional, remove optional (mirror) | New schema readable by old consumers |
| Full | Backward + Forward | Both |
| None | Anything | Everything breaks |
The practical target for most service contracts is Full compatibility -- achievable if you stick to additive, optional-only changes and your consumers are tolerant readers. Breaking changes force a new named version (OrderConfirmedV2).
This mapping means the tolerant-reader discipline above is not a style preference; it is the necessary condition for Full compatibility.
Read This Only If Stuck
Local chunks
- Primer: RPC and REST -- sync contract fundamentals.
- Primer: Asynchronism -- async contract fundamentals.
- Primer: HTTP -- the protocol-level vocabulary (idempotent verbs, status codes).
- Primer: TCP and UDP -- background for streaming gRPC vs HTTP.
- FoSA: Communication (event-driven) -- the event-driven side of contracts.
- FoSA: Request-Reply -- pseudo-synchronous pattern on top of messaging.
- FoSA: Asynchronous Capabilities -- why async contracts demand more discipline.
- FoSA: Architecture Decision Records -- every contract change of any significance deserves an ADR.
External canonical references
- Martin Fowler, TolerantReader -- the canonical short description.
- Chris Richardson, Messaging pattern.
- Chris Richardson, Remote procedure invocation -- the sync counterpart.
- Confluent, Schema Evolution and Compatibility -- the Backward/Forward/Full taxonomy referenced above.
- Google, API Design Guide -- Versioning -- Google's rules for sync API evolution; widely imitated.
- Microsoft, REST API versioning guidelines -- Microsoft's rules; also widely imitated.
- CloudEvents specification, cloudevents.io -- a cross-vendor event envelope standard that pairs well with a domain schema.
- OpenAPI Specification, spec.openapis.org -- the spec itself; bookmark.
Depth Path
- Apache Avro and Confluent Schema Registry documentation if your stack uses Kafka. The "compatibility modes" section maps directly onto the compatibility rules above.
- Sam Newman, Building Microservices (2nd ed.), chapter 5 ("Implementing microservice communication") -- the fullest treatment of contract choices with trade-offs.
- Pact Foundation, Non-HTTP testing (message pacts) -- contract testing extended to events, preview for concept 09.