API Contracts
A contract is the only part of a service that is genuinely hard to change, because you do not control who depends on it or when they will redeploy. Everything behind it is refactorable; everything in it is a promise. Design it deliberately, and evolve it additively.
The contract is written before the implementation, and the specification is the source of truth — not a document generated afterwards from whatever the code happened to return.
Config Resolution
- Read
.msskills/config.yamlin the repo root; look uppaths.api. - A custom document exists at that path → read its frontmatter
mode:override→ use it alone as the API standard; ignore the defaults below.overlay(default, or nomodekey) → read defaults first, then apply the custom document's sections on top, matched by exact heading; new sections append.
- A path is configured but no file exists there → say which path is missing, then use the defaults.
- No config file or no
paths.apikey → use defaults. .msskills/stack.mdexists → use its protocol and tooling: REST, gRPC, or GraphQL; its serialisation format; its schema registry and compatibility mode; its spec linter.- An existing specification is in the repository → its conventions outrank these defaults. Match the API that exists rather than introducing a second style beside it.
Self-Validation Checklist
STOP before presenting any new or changed contract. Verify every check. Fix failures first.
- SPEC FIRST: Does a specification fragment exist — OpenAPI, AsyncAPI, protobuf, JSON Schema — before the implementation? If the code came first → write the spec now and reconcile.
- CONSUMER-SHAPED: Does each operation serve a real consumer use case, or does it expose the internal model? An endpoint that mirrors a table row by row → reshape it around what the caller is trying to do.
- BACKWARD COMPATIBLE: For a change to an existing contract, is it purely additive — new
optional field, new endpoint, new enum value the consumer may ignore? If it removes, renames,
narrows a type, tightens validation, or changes a default → it is breaking. STOP and route
through
collaborative-judgment. - ERRORS SPECIFIED: Is every failure enumerated with a status code, a stable machine-readable code, and guidance on whether retrying helps? A contract with only the success case is half written.
- IDEMPOTENCY: Can every non-
GEToperation be safely retried? If not → define the idempotency key, its scope, and how long it is honoured (seeresilience-patterns). - NO UNBOUNDED COLLECTION: Does any response return a list with no limit? Every collection is paginated with an explicit default and maximum page size.
- TYPES EXPLICIT: Are money, time, and identifiers unambiguous — currency alongside amount, timestamps in UTC with offset, IDs typed rather than bare strings? Ambiguity here becomes a production incident.
- NO INTERNAL LEAKAGE: Does the payload expose database identifiers, internal enum names,
stack traces, or fields no consumer needs? Strip them (see
secure-service). - EVENTS ARE FACTS: Is each published event named in the past tense, describing something that
happened, and does it carry what consumers need without requiring a call back? Events named as
commands (
SendEmail) belong on a queue, not an event stream. - COMPATIBILITY MODE: For an event schema, is the registry's compatibility mode stated, and does the change satisfy it?
- VERIFIABLE: Is it clear which contract test will hold this promise (see
contract-testing)?
All checks pass → state "Contract holds: , , errors specified."
Active Anti-Pattern Scan
Any box you can check is a defect. Fix it before presenting.
- Code-First Spec: the specification is generated from handlers after the fact, so the contract is whatever the code does → author the spec deliberately.
- CRUD Leakage: endpoints that mirror tables, forcing consumers to orchestrate business operations → model the operation the consumer actually performs.
- Chatty Contract: the consumer must call three endpoints to render one screen → give it one purpose-built representation.
- Silent Breaking Change: a field removed, renamed, or narrowed without a version or a migration window → additive change, or an explicit deprecation cycle.
- Envelope Errors: HTTP 200 with
{"success": false}→ use the status code; it is the part every proxy, client, and dashboard already understands. - Stringly-Typed Payload: dates, money, enums, and booleans passed as free text → use typed fields with stated formats.
- Unbounded List: a collection endpoint with no pagination → it will time out the day the data grows.
- Chatty Event: an event carrying only an ID, forcing every consumer to call back → include the facts consumers need, and say why anything is deliberately omitted.
- Command as Event:
SendConfirmationEmailpublished to a topic → that is a command with one owner, not a fact. - Version in Every Path:
/v2/minted for an additive change → version only on a genuine break. - Undocumented Nullability: optional and required unstated, so consumers guess → mark every field.
- Leaked Internals: database IDs, internal status names, or exception text in responses → map to the public vocabulary.
Ambiguity Signals
Route these through collaborative-judgment. Each has two defensible answers.
- Break now or carry the field forever. A clean break costs a coordinated migration; carrying a deprecated field costs permanent complexity. Which is cheaper depends on how many consumers exist and whether you can see their traffic.
- Versioning strategy. URL path, header, or media type — each trades discoverability against cache and routing behaviour, and the right answer is usually "whatever the rest of the estate already does".
- Event granularity. Fine-grained facts are flexible and chatty; coarse aggregate events are efficient and couple consumers to a bigger shape.
- Thin versus fat events. Carrying state removes callbacks and duplicates data that can go stale; carrying only an ID keeps one source of truth and adds a synchronous dependency.
- Whether a change is really breaking. Tightening validation, changing a default, or adding a required field to a request breaks some consumers. Deciding whether those consumers exist is a judgment call, not a lookup.