Handbooks / Microservices / Chapter 5
API Versioning and Contracts
6 pages · ~4 min✓ Reviewed
Builds on Resilience Patterns. Next up: Securing Microservices.
Compatibility Fundamentals and Mental Model
1. Two Directions
| Reader | Data from | Upgrade first | |
|---|---|---|---|
| Backward | new | old | consumers |
| Forward | old | new | producers |
| Full | both | any mix | any order |
- Full = backward + forward at once
- Needed when producers and consumers deploy independently
- Rollout order is out of your control
2. Breaking vs Safe
| Change | Verdict |
|---|---|
| Remove or rename a field | breaking |
| Change a type | breaking |
| Optional becomes required | breaking |
| Change semantics or error codes | breaking |
| Add an optional field | safe |
| Add a new endpoint | safe |
| Add an enum value | safe only if consumers tolerate unknowns |
3. The Silent Break
- Old amount1999 = cents
- New amount19.99 = dollars
- Schema checkpasses
- Datacorrupted silently
Worst break
Same field name, new meaning: no schema check fails. Add a new field instead of changing amount.
API Versioning: URI vs Header
1. Where the Version Lives
| URI | Header | Query | |
|---|---|---|---|
| Form | /v1/orders, /v2/orders | Accept media type or X-API-Version | ?version=2 |
| Strength | Visible, cacheable, curl-friendly | Clean URLs | Simple |
| Catch | URLs proliferate | Hidden, hard in a browser | Easy to omit |
http
GET /v2/orders Accept: application/vnd.acme.v2+json X-API-Version: 2 GET /orders?version=2
URI, media type, custom header, query
2. Trade-offs
- URI: easy to route and test, but the version is not part of the resource identity
- Header: caches must Vary on that header
- Query: always define a default version when the client sends none
3. Policy
- Version only on breaking changes, not every release
- Prefer additive changes so most clients never move
Schema Evolution for Events: Avro, Protobuf, Registry
1. Registry and Modes
- Registry stores versioned schemas per subject, e.g.
orders-value - Messages carry only a schema ID
- Registry rejects a schema that breaks the subject's mode
| Mode | Reads | Upgrade first |
|---|---|---|
| BACKWARD (default) | new reads old | consumers |
| FORWARD | old reads new | producers |
| FULL | both | any order |
_TRANSITIVE | all versions, not latest | same as base |
2. Avro
- Reader and writer schemas resolved at read time
- Added field needs a default (backward)
- Only fields with defaults may be removed
- Rename needs aliases
- No field numbers: schema must be present at read time, hence the registry
3. Protobuf
Deprecation, Tolerant Reader and Expand-Contract
1. Tolerant Reader
- Postel: be liberal in what you accept
- Ignore unknown fields; read only what you need
- Do not fail on new enum values or extra JSON properties
- Main enabler of forward compatibility
2. Deprecation Workflow
- Announce it; set
deprecated: truein OpenAPI - Send
DeprecationandSunsetheaders - Monitor per-client usage
- Remove only when usage hits zero
http
Deprecation: @1767225600 Sunset: Thu, 31 Dec 2026 23:59:59 GMT
RFC 9745: unix seconds; Sunset is an HTTP date
3. Expand and Contract
Rename old_name to new_name
- 11 ExpandR1: add new_name, write both
- 22 MigrateR2: switch readers one by one
- 33 ContractR3: stop writing old_name; R4: delete it
4. Catch Breaks in CI
- Contract tests (Pact): consumer expectations
buf breakingfor Protobuf- Registry compatibility API for Avro
- Fail the build before merge
Compatibility Cheat Sheet
1. Safe vs Breaking
| Safe | Breaking |
|---|---|
| Add optional field | Remove or rename field |
| Add endpoint | Change type or meaning |
| Add required field | |
| Reuse a Protobuf number |
2. Registry and Versions
| Mode | Upgrade first |
|---|---|
| BACKWARD | Consumers |
| FORWARD | Producers |
| FULL / TRANSITIVE | Rollout order unknown |
/v2/orders: visible, cacheableAccept: application/vnd.acme.v2+json: clean URLs, addVary: Accept- Always default the version
3. Deprecate and Break Safely
Check yourself
Registry mode is BACKWARD. A producer adds currency (string) to an Avro schema with no default. Accepted?
- Rejected: the new schema must read old data, and old records have no
currency. - Fix: add a default, e.g.
"default": "USD".
Protobuf field string email = 4; was deleted, then int32 age = 4; was added. What breaks?
- Identity is the field number, so old bytes tagged 4 decode as the wrong type.
- Fix:
reserved 4;and giveagea new number.
A team renames JSON field phone to mobile in one release. Why does it break, and what is the safe path?
- Old clients still read
phone, which is gone. - Expand-contract: add
mobileand write both, move readers, stop writingphone, then delete it.