The contract
Written before the code · shaped by what callers need · versioned
A contract is the specification of what an owner offers: the operations, their inputs and outputs, and the guarantees attached to them. It is a file in git. It is not the code, and it is not generated from the code.
Three properties, and only the third is optional to argue about
It is written before the code
The spec comes first and the code is generated from it — server stubs and client packages both. The reverse direction, generating a spec from an implementation, produces a document that describes whatever the code happens to do, including its accidents. Callers then depend on the accidents.
It is shaped by what callers need, not by what the tables look like
This is the one that decides whether item 30 is worth doing.
A CRUD API mirroring the tables one-to-one — GET /orders, GET /order_items, one endpoint per
table, columns as fields — keeps every bit of the coupling and adds a network hop. It is
worse than direct access, because a schema change now has to move the API and its callers in
step instead of just the callers.
A contract that decouples is written from the caller's question. The caller does not want
order_items; it wants "what did this customer buy, with prices". Answer that, and the tables
behind it are free to change.
| Table-shaped | Caller-shaped | |
|---|---|---|
| Endpoint | GET /order_items?order_id= | GET /orders/{id} returning lines inline |
| Caller must know | that lines live in a second table | nothing about storage |
| Split the table in two | breaks every caller | invisible |
It is versioned
Additive change happens in place. Breaking change gets a new version, and the old one keeps working until its callers are gone — which is knowable, because the owner sees who calls it.
What counts as a breaking change
The distinction that CI enforces. If it is on the left, it needs a new version; if it is on the right, it ships.
| Breaking — needs a new version | Not breaking — ships in place |
|---|---|
| Removing a field from a response | Adding an optional field to a response |
| Renaming a field | Adding a new endpoint |
Narrowing a type (string → enum, wider → narrower range) | Widening a type |
| Making an optional request field required | Adding an optional request field |
| Adding a new value to an enum a caller switches on | Adding a value to an enum documented as open |
| Changing pagination semantics — page size, cursor meaning, ordering | Adding a new filter parameter |
| Changing an error code for an existing condition | Adding a new error code for a new condition |
| Tightening a rate limit or timeout callers rely on | Relaxing a validation rule |
Two of these are easy to get wrong:
- Enums. Adding a value is breaking unless the contract said from the start that the list is open and callers were required to handle the unknown case. Decide this per field, in the first version.
- Ordering. An unspecified sort order is not a licence to change it. If callers can observe it, it is part of the contract whether it was written down or not — so write it down.
Caller identity
A contract names its caller. "The owner knows the caller, the operation and the row" holds only if every request carries an identity the owner can verify — so identity is part of the contract, not plumbing underneath it.
The caller is a Zitadel service user
Each calling service is a Zitadel service user. It exchanges its credentials for a
short-lived JWT whose audience is the owning service, and the owner validates that token
locally against cached JWKS — signature, iss, aud, exp — then checks a role the owner
defines. No round-trip per request. Authorization moves from "this credential may read the
table" to "this service may perform this operation".
The flow — the token request, the verifying middleware — is worked through on the audit-log
authentication page. Two things differ here: the
audience is the owning service's own, and the role is one the owner names. audit.writer is
that page's role, not a convention.
That identity is the service, not the person
A service-user token says which application is calling. It says nothing about which end user
the call is made on behalf of. Where an owner must authorize or audit per user, the contract
has to carry that user context explicitly; how it does so is decided per contract, at
contract-design time on milestone 30.2. It is not prescribed here.
Identity on the API does not close the direct path
A service holding a valid JWT and a database password has two routes, and all the coupling
of the second. The grant is still revoked on milestone 30.4, and new credentials still come
only from the secrets engine — the completion criterion on migration is
unchanged.
What CI checks
A contract nothing enforces is documentation, and documentation does not stop a caller from breaking. The pipeline compares the spec on the branch against the spec on the main line and fails on anything in the left column that is not accompanied by a version bump.
The specs are published in the Backstage catalog (roadmap item 22), so a caller finds the contract without asking anyone.