API Design Skill
An API is a contract with people you can't refactor. Design it spec-first, keep it boringly consistent, and treat every breaking change as a versioned event. REST-centric; the contract rules apply equally to gRPC/RPC. Go + TypeScript used as reference stacks.
When to use
Designing new endpoints, defining or evolving request/response schemas, choosing a versioning approach, reviewing an API for consistency, or writing the OpenAPI document.
Resource modeling (normative)
- URLs are nouns for resources, plural, kebab-case:
/purchase-orders/{id}, never /getPurchaseOrder.
- Nest only for true ownership, max one level:
/orders/{id}/items; relate everything else by ID or query param.
- Actions that don't map to CRUD become sub-resources or controller verbs at the end:
POST /orders/{id}/cancellation (preferred) or POST /orders/{id}:cancel — never verbs in the middle of paths.
- Responses expose a representation DTO, never the internal/DB model — fields are deliberately chosen, internal IDs/flags stay internal.
- One JSON field-naming convention API-wide (pick
camelCase or snake_case, then never mix).
Methods and status codes — decision table
| Operation |
Method |
Success |
Notes |
| Read one / list |
GET |
200 |
Safe, cacheable, never mutates |
| Create |
POST |
201 + Location |
Support an idempotency key for retries |
| Full replace |
PUT |
200 / 204 |
Idempotent by definition |
| Partial update |
PATCH |
200 |
JSON Merge Patch unless you need JSON Patch |
| Delete |
DELETE |
204 |
Idempotent: repeat delete → 204 or 404, never 500 |
| Validation failure |
— |
422 (or 400) |
Field-level details, see error format |
| Auth missing / wrong |
— |
401 / 403 |
401 = who are you; 403 = you can't do that |
| Conflict / stale write |
— |
409 |
Pair with ETag/If-Match for optimistic locking |
| Rate limited |
— |
429 + Retry-After |
|
Error format (one shape, everywhere)
Use RFC 9457 Problem Details (application/problem+json): type, title, status, detail, instance, plus an errors[] array with {field, code, message} for validation. Machine-readable codes are part of the contract — clients branch on them, so renaming one is a breaking change. Never leak stack traces, SQL, or internal class names.
Collections (normative)
- Every list endpoint paginates from day one — retrofitting pagination is a breaking change. Default cursor-based (
?cursor=...&limit=50, response carries nextCursor); offset only for small, stable datasets.
- Filtering via query params (
?status=open), sorting via ?sort=-createdAt (leading - = descending). Document the allowed fields; reject unknown ones with 400.
- Cap
limit server-side; an uncapped list endpoint is a self-inflicted DoS.
Versioning and evolution
- Version from day one: URL path
/v1/ (visible, cache-friendly) is the pragmatic default; media-type/header versioning if the org already does it.
- Non-breaking (no new version): adding optional request fields, adding response fields, new endpoints. Clients must be told to ignore unknown response fields.
- Breaking (new version or deprecation cycle): removing/renaming fields, type changes, semantics changes, new required params, tightening validation, changing error
codes.
- Deprecate with
Deprecation/Sunset headers + a dated migration note; keep N-1 alive for the published window. Detect accidental breaks mechanically with oasdiff — see Verification.
Anti-patterns
- Exposing the database row as the response schema — couples every consumer to your storage.
- Verbs in URLs (
/createUser), GET that mutates, POST used for everything.
- 200 with
{"error": ...} in the body — breaks every HTTP-aware client and cache.
- List endpoints without pagination or limit caps.
- Per-endpoint bespoke error shapes; clients end up with a parser per route.
- Designing in code first and "generating the spec later" — the contract becomes whatever the implementation accidentally does (review this in [[code-review]]).
Verification (executable)
The contract is linted and diffed, not eyeballed: lint the OpenAPI doc with spectral lint openapi.yaml (custom ruleset = your conventions codified) or vacuum lint; detect breaking changes in CI with oasdiff breaking base.yaml revision.yaml; for gRPC, buf lint + buf breaking. Keep server and spec from drifting by generating one from the other (oapi-codegen in Go, openapi-typescript/zod-openapi in TS) and add a contract test per endpoint ([[test-strategy]]). Full tooling table in reference.md.
Going deeper
- reference.md — spec-first tooling per ecosystem, extended status-code and header guidance, pagination patterns, design-review checklist.
- examples.md — one resource end-to-end: OpenAPI fragment, Problem-Details error, cursor pagination, Go and TypeScript handlers.
1---2name: api-design3description: API Design Skill4---5# API Design Skill67> An API is a **contract with people you can't refactor**. Design it spec-first, keep it boringly consistent, and treat every breaking change as a versioned event. REST-centric; the contract rules apply equally to gRPC/RPC. Go + TypeScript used as reference stacks.89## When to use1011Designing new endpoints, defining or evolving request/response schemas, choosing a versioning approach, reviewing an API for consistency, or writing the OpenAPI document.1213## Resource modeling (normative)1415- URLs are **nouns for resources**, plural, kebab-case: `/purchase-orders/{id}`, never `/getPurchaseOrder`.16- Nest only for true ownership, max one level: `/orders/{id}/items`; relate everything else by ID or query param.17- Actions that don't map to CRUD become **sub-resources or controller verbs at the end**: `POST /orders/{id}/cancellation` (preferred) or `POST /orders/{id}:cancel` — never verbs in the middle of paths.18- Responses expose a **representation DTO**, never the internal/DB model — fields are deliberately chosen, internal IDs/flags stay internal.19- One JSON field-naming convention API-wide (pick `camelCase` or `snake_case`, then never mix).2021## Methods and status codes — decision table2223| Operation | Method | Success | Notes |24| ---------------------- | ------ | ------------------- | -------------------------------------------------- |25| Read one / list | GET | 200 | Safe, cacheable, never mutates |26| Create | POST | 201 + `Location` | Support an idempotency key for retries |27| Full replace | PUT | 200 / 204 | Idempotent by definition |28| Partial update | PATCH | 200 | JSON Merge Patch unless you need JSON Patch |29| Delete | DELETE | 204 | Idempotent: repeat delete → 204 or 404, never 500 |30| Validation failure | — | 422 (or 400) | Field-level details, see error format |31| Auth missing / wrong | — | 401 / 403 | 401 = who are you; 403 = you can't do that |32| Conflict / stale write | — | 409 | Pair with `ETag`/`If-Match` for optimistic locking |33| Rate limited | — | 429 + `Retry-After` | |3435## Error format (one shape, everywhere)3637Use **RFC 9457 Problem Details** (`application/problem+json`): `type`, `title`, `status`, `detail`, `instance`, plus an `errors[]` array with `{field, code, message}` for validation. Machine-readable `code`s are part of the contract — clients branch on them, so renaming one is a breaking change. Never leak stack traces, SQL, or internal class names.3839## Collections (normative)4041- **Every list endpoint paginates from day one** — retrofitting pagination is a breaking change. Default cursor-based (`?cursor=...&limit=50`, response carries `nextCursor`); offset only for small, stable datasets.42- Filtering via query params (`?status=open`), sorting via `?sort=-createdAt` (leading `-` = descending). Document the allowed fields; reject unknown ones with 400.43- Cap `limit` server-side; an uncapped list endpoint is a self-inflicted DoS.4445## Versioning and evolution4647- Version from day one: URL path `/v1/` (visible, cache-friendly) is the pragmatic default; media-type/header versioning if the org already does it.48- **Non-breaking** (no new version): adding optional request fields, adding response fields, new endpoints. Clients must be told to ignore unknown response fields.49- **Breaking** (new version or deprecation cycle): removing/renaming fields, type changes, semantics changes, new required params, tightening validation, changing error `code`s.50- Deprecate with `Deprecation`/`Sunset` headers + a dated migration note; keep N-1 alive for the published window. Detect accidental breaks mechanically with `oasdiff` — see Verification.5152## Anti-patterns5354- Exposing the database row as the response schema — couples every consumer to your storage.55- Verbs in URLs (`/createUser`), GET that mutates, POST used for everything.56- 200 with `{"error": ...}` in the body — breaks every HTTP-aware client and cache.57- List endpoints without pagination or limit caps.58- Per-endpoint bespoke error shapes; clients end up with a parser per route.59- Designing in code first and "generating the spec later" — the contract becomes whatever the implementation accidentally does (review this in [[code-review]]).6061## Verification (executable)6263The contract is linted and diffed, not eyeballed: lint the OpenAPI doc with `spectral lint openapi.yaml` (custom ruleset = your conventions codified) or `vacuum lint`; detect breaking changes in CI with `oasdiff breaking base.yaml revision.yaml`; for gRPC, `buf lint` + `buf breaking`. Keep server and spec from drifting by generating one from the other (`oapi-codegen` in Go, `openapi-typescript`/`zod-openapi` in TS) and add a contract test per endpoint ([[test-strategy]]). Full tooling table in **[reference.md](reference.md)**.6465## Going deeper6667- **[reference.md](reference.md)** — spec-first tooling per ecosystem, extended status-code and header guidance, pagination patterns, design-review checklist.68- **[examples.md](examples.md)** — one resource end-to-end: OpenAPI fragment, Problem-Details error, cursor pagination, Go and TypeScript handlers.