Software Architecture · API Design

Where an API Contract Ends and the Implementation Begins

Which parts of an HTTP exchange callers may depend on, which are incidental, and why undocumented behaviour still turns into a maintenance obligation.

A service returns a timestamp with microsecond precision. A later release truncates it to milliseconds, because the storage layer it moved to keeps millisecond resolution. The field is still a string, still parses as ISO 8601, still appears in every response. Nothing in a schema comparison flags the change. Yet any caller that deduplicated records by the full timestamp, or that used it as part of a cache key, now sees collisions where it previously saw distinct values.

That example marks the boundary in question. An API contract is not the shape of the JSON. It is the set of observations a caller is entitled to rely on, such that a future release which preserves those observations will not break that caller. Schemas describe types; contracts describe behaviour. The gap between them is where much compatibility damage happens.

The full set of observables

Everything a caller can measure is a candidate for the contract, not just the documented body fields:

  • the method, path shape, and query parameters accepted, including which combinations are legal;
  • the media types accepted on the request and produced on the response;
  • the status codes emitted for each documented condition, and the headers attached to them;
  • the ordering, cardinality, and stability of collections across repeated calls;
  • the representation of absent or empty values;
  • the error taxonomy, including whether an error is retryable;
  • where documented, timing properties such as whether a write is visible to a subsequent read.

Most of these are invisible to a type checker. A JSON schema validator will happily accept a response where items is unordered that used to be sorted by creation time, because ordering lives in the prose of a specification, not in its grammar. That is precisely why API review has to be a review of invariants rather than of types.

The canonical statement of HTTP’s own semantics lives in RFC 9110. It assigns meaning to methods, defines which are safe and which are idempotent, and structures status codes into classes with defined handling rules. Those definitions are part of every HTTP API’s contract whether or not the API’s documentation repeats them.

Three tiers of commitment

In practice a contract has three layers, and only the first is intentional.

Guaranteed. Documented, tested, and supported. Removing or changing one of these is a breaking change and requires the migration machinery described in versioning strategies and compatibility promises.

Tolerated. Not documented, but stable enough that callers have noticed. A field that has always been present, an array that has always been sorted, a status code that has only ever appeared for one condition. Nobody promised it. Everybody relies on it.

Incidental. Genuinely accidental: internal identifiers, the ordering produced by whatever query planner ran that day, the exact wording of a validation message, the number of decimal places on a computed float.

The tier a behaviour occupies is not decided by the provider. It is decided by observation. Hyrum’s law, as stated in Software Engineering at Google, is the compact form of this: with a sufficient number of consumers, every observable behaviour of a system will be depended on by somebody. The law is not a complaint about consumers. It is a design constraint, because it means the only reliable way to keep a behaviour out of the contract is to keep it unobservable, or to change it early and often enough that no dependency can form.

The practical consequence is a bias toward narrow responses. Every field shipped is a field that must be carried, populated, and eventually deprecated. An endpoint that returns forty fields because the underlying row has forty columns has accepted forty obligations, most of them unpaid for by any caller.

Status codes are semantic, not decorative

RFC 9110 tells clients that unrecognised status codes are to be handled by class: any 2xx means success, any 4xx means the request was at fault, any 5xx means the server was. That rule gives APIs room to add codes over time without breaking generic clients, but it also means a change of class is always breaking, even if the payload is unchanged.

Returning 200 OK with a body that says {"error": "not found"} is a contract decision with wide consequences. Clients cannot distinguish the failure without parsing the body, caches may store a failure as a successful representation, and generic retry policies will not retry it. Returning 404 instead moves the same information into a channel that every HTTP intermediary already understands.

Specific codes carry more than class. 409 Conflict and 422 Unprocessable Content both describe client-side faults, but they point at different remedies: a state conflict versus a syntactically valid but semantically rejected payload. 429 Too Many Requests, defined in RFC 6585, pairs with Retry-After to make throttling machine-actionable. 202 Accepted indicates that processing is deferred, which implies the caller needs a way to poll, which is a second contract obligation that follows from the first.

Whether a client may retry a failed write at all depends on method semantics rather than on status codes, and for POST that question needs an explicit answer described in idempotency keys for retried writes.

Media types carry the parsing rules

application/json specifies syntax. It says nothing about which members exist, what they mean, or which of them are required. A caller handed only that media type has a parser and no contract.

Three details from RFC 8259 matter for evolution. Object member names are unordered, so no caller may depend on the serialisation order the server chose, and a server that reorders members has broken nothing. Member names within an object should be unique, and behaviour for duplicates is unpredictable. And the grammar has one numeric type: there is no integer and no float at the syntax level. Consumers that map numbers onto IEEE 754 doubles lose precision above 2^53, which is why 64-bit identifiers are usually serialised as strings.

Beyond syntax, two mechanisms attach meaning to a payload. RFC 6906 defines the profile link relation type and recommends that media types define a parameter of the same name; that parameter names the semantics being applied without changing the media type. A distinct vendor media type such as application/vnd.example.v2+json goes further and makes the representation itself the negotiation unit, which is the mechanism behind media-type negotiation as described in versioning strategies and compatibility promises.

The response below makes the difference visible: the status code, the media type, and the ETag are all contractual, while the body’s member order is not, and the prose inside detail is not either.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
ETag: "3f9a1c"
Cache-Control: no-store

{
  "type": "https://example.com/problems/version-conflict",
  "title": "Version conflict",
  "status": 409,
  "detail": "The submitted revision is older than the stored revision."
}

The type field is a stable identifier that callers branch on; detail is human-facing text that must be free to change. Mixing the two, for instance by making callers match on the message, produces a contract nobody can maintain. The error envelope itself is treated in error contracts and problem details.

Fields that leak implementation

Certain field choices export internal structure whether or not that was intended.

Database primary keys tie the API to one storage engine and often leak volume: a monotonically increasing integer tells every caller how many rows exist and when the service launched. Surrogate public identifiers decouple the two at the cost of one extra column and one extra lookup.

Enum ordinals leak ordering. A status enum stored as an integer and serialised as a number makes inserting a new value between two existing ones a breaking change; serialising the name costs bytes and buys reorderable values.

Null versus absent is a distinction with real semantics in patch formats. JSON Merge Patch, defined in RFC 7396, uses null to mean “remove this member”, so a response that omits empty fields and a response that sets them to null mean different things to a merge-patch client. The safest rule is to pick one convention per API and state it.

Floating-point money, local timestamps without an offset, and integers beyond 2^53 round out the common cases. Each is a small decision at design time and an expensive one after a thousand callers have built on it.

The cost of documenting

Documentation is not free commentary on the API; it is the act that promotes a behaviour into the guaranteed tier. Three questions are worth asking before a field is described: can a caller make a decision with it, could the caller derive it, and would it survive a plausible reimplementation of the service behind the same interface. A field that fails the third question is a liability, because the interface has promised to keep answering it correctly after the machinery that produces it has been replaced.

The same reasoning applies to status codes and headers. An internal diagnostic header that leaks a hostname is generally harmless until a caller builds failover logic on it, at which point it has become a routing contract.

What contract discipline cannot repair

Careful boundary work does not rescue a wrong abstraction. A resource model that forces callers to make four requests to answer one question remains wrong even if every field is named and documented impeccably, and the compatibility cost of fixing it later is far higher than the cost of fixing it before the first caller arrives. Nor does the absence of documentation provide protection: once a partner integrates against an undocumented response, the behaviour is in the tolerated tier and the removal will be experienced as an outage regardless of what the specification said.

The discipline is therefore mostly subtractive. Fewer fields, fewer status codes, fewer accidental orderings, and an explicit statement of which behaviours are promised. Everything else is left free to change, which is the only condition under which a service can be reimplemented without coordinating with its callers.

References

  1. RFC 9110: HTTP Semantics — IETF
  2. RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — IETF
  3. RFC 6906: The profile Link Relation Type — IETF
  4. Software Engineering at Google — Winters, Manshreck and Wright, O'Reilly 2020