Skip to main content

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 .proto schema. 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 status is 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

  1. For each cross-service interaction, name the contract type: sync API, event, or both.
  2. Write the schema (OpenAPI for sync, JSON Schema or Avro for events). Keep it in the producer's repo, published as an artifact.
  3. State the compatibility rules: what additions are safe, which changes are breaking, and what the deprecation window is.
  4. Enforce tolerant readers: deserializers must not fail on unknown fields.
  5. Version events with a version field; version sync APIs in URL (/v1, /v2) or header.

Check Yourself

  1. Why is the tolerant-reader discipline independent of whether the transport is sync or async?
  2. What makes adding a required field to an existing event a breaking change, even if the producer tolerates missing fields?
  3. Why version events with a version field 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:

ModeWhat the producer may doWhat the consumer must be able to do
BackwardAdd optional, remove optionalRead messages written with the previous schema
ForwardAdd optional, remove optional (mirror)New schema readable by old consumers
FullBackward + ForwardBoth
NoneAnythingEverything 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

External canonical references

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.