Day 15 of 100·System Design Day·541 words

API design contracts — REST done deliberately

Resource modeling, status codes, envelopes, versioning, pagination — graded against your own APIs.

Concept → your projects

An API contract is the promise between producer and consumer. The design surface:

  • Resource modeling: nouns not verbs (/access-requests, not /createAccessRequest); the hierarchy encodes ownership (/groups/{id}/members).
  • Status codes as protocol: 201 (created), 200 (ok/duplicate), 202 (accepted, async), 401 (unauthenticated), 403 (authenticated, forbidden), 404 (missing), 409 (conflict — your conditional-update races!), 422/400 (validation), 503 (dependency down).
  • Consistent envelopes: one error shape, everywhere.
  • Pagination: offset (simple, breaks under inserts) vs keyset/cursor (stable, index-friendly).
  • Versioning: path prefix (/api/v1) — simple, cache-friendly, explicit.
  • Idempotency on unsafe verbs (Day 19/20).

Grading your own APIs

Zoth — RESTful, versioned /api/v1, resources are real nouns. Strong points to cite: status-code discipline (ErrConflict → 409 on transition races; readyz → 503 with per-component latency/error when a dependency is down — that's a contract for the orchestrator); consistent nested error envelope {"error":{"code","message"}}; self-documenting OpenAPI — cmd/apidocgen walks the Chi router and generates openapi.json + a Postman collection, so the spec can't silently drift (though it has: your api.md documents /access-requests/{id}/advisory while the route is /rag-advisory — a live example of why generated specs beat hand-written ones). Weaknesses to own: some mutations are RPC-shaped actions (/approve, /deny, /revoke) — defensible ("state transitions on a resource are verbs; POST /access-requests/{id}/approve is the standard compromise"), and no pagination on audit-logs beyond a limit ≤ 100.

Traverse — the 202/201/200 trichotomy on the same endpoint is textbook async-contract design: 201 created (no AI needed), 202 accepted-for-processing (AI queued), 200 duplicate (idempotency hit, returns the existing submission). That's a contract a client can build on — the extension knows exactly what happened. The notification inbox uses keyset pagination (paged on id), not offset. Error contract: flat {error, code?, field?} with a SAFE_ERRORS allow-list — one shape, no Prisma internals. Weakness to own: validation is mostly hand-rolled in controllers (zod exists but only guards subscriptions).

The rules worth memorizing (then violating knowingly)

  1. 404 vs 403 is a security decision, not a style choice. 404-then-403 reveals resource existence to attackers. Traverse's submission endpoints return 404 ("not found") then 403 ("not yours") — an enumeration leak, deliberate per-resource today, fixable by collapsing to 404-for-both (Day 72 covers the full decision). The gold standard for sensitive resources: same body, same timing, 404.
  2. Don't leak implementation in errors. Raw Invalid prisma.user.create() invocation: Unique constraint failed… reaching a client = information disclosure. Traverse's resolveError funnels everything unknown to a flat 500.
  3. Version before you break, not after. /api/v1 costs nothing on day one; retrofitting it costs a deprecation program.
  4. Make retries safe: Idempotency-Key on POST (traverse's idempotencyKey on submissions — literally this pattern), natural idempotency elsewhere (removeOnComplete keyed jobs, ON CONFLICT inserts).

A drill

Design the contract for "approve a request" three ways: (a) POST /requests/{id}/approvals (create an approval resource — auditable, listable, most RESTful), (b) POST /requests/{id}/approve (action-style — what zoth does), (c) PATCH /requests/{id} with {status: "APPROVED"} (least good: the state machine's guardrails vanish into generic PATCH semantics). Being able to argue all three is the skill.

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Your traverse API returns 201, 202, and 200 from POST /submissions. Explain the contract.

201 = persisted, nothing async queued; 202 = persisted AND AI analysis queued (the response returns before workers run — the client doesn't wait for LLMs); 200 = duplicate detected via the (userId, idempotencyKey) unique constraint — returns the existing row, so extension retries are free. The client gets complete information about both persistence and pipeline state from one status code.

02

Is POST /access-requests/{id}/approve un-RESTful?

It's the pragmatic middle. Pure REST would POST an approval sub-resource — which zoth actually has (an approvals table with its own decision_source, decided_at). The action endpoint exists because the decision is a guarded state transition: approve runs authority checks and an atomic conditional update; exposing that as generic PATCH would hide the state machine. I'd defend it as 'REST resources + explicit transition endpoints' — the pattern UPI/status-machine APIs converge on.

03

What's wrong with offset pagination and what did you do instead?

OFFSET re-scans and re-discards rows, and it's unstable — a new insert shifts every page. Traverse's notification inbox paginates by id (keyset): WHERE id < $cursor ORDER BY id DESC LIMIT n — O(log n) seek on the PK, stable under inserts. Zoth's list endpoints use LIMIT/OFFSET-optional SQL — fine at its scale; I'd move them to keyset on created_at,id if lists grew.