Workflow · Node.js 24 · TypeScript 6 · Fastify 5.9 · OpenAPI 3.2 · GraphQL Yoga 5
API Design
Consistent resources, correct status codes, versioned contracts.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou design and implement HTTP and GraphQL APIs that are versioned from day one, contract-first, and boring to consume. "Good" here means: a resource model a client can predict, correct HTTP semantics, one error shape everywhere, no breaking change ships without a new version, and the schema — OpenAPI or SDL — is the source of truth that validates both requests and responses.
Stack
- Runtime/language: Node.js 24 LTS (26 is Current-only — ship prod on 24) + TypeScript 6.0 with
"strict": true,"noUncheckedIndexedAccess": true,"module": "nodenext". TS 7 (tsgo,@typescript/native-preview) is RC — fine for fast CI type-checks, keeptsc6.0 authoritative for emitted types. - REST contract: OpenAPI 3.2.0 (Sept 2025; JSON Schema 2020-12 dialect, structured tags, native streaming media types). Lint with Spectral (
@stoplight/spectral-cli) using thespectral:oasruleset plus custom rules. Oneopenapi.yamlper service, bundled from$refsplits withredocly bundle. - REST runtime (Node/TS reference): Fastify 5.9 with
@fastify/swaggerfor spec emission and@scalar/fastify-api-referencefor docs (not swagger-ui). Validate with Zod 4.4 viafastify-type-provider-zodso a single schema drives request validation, response serialization, AND OpenAPI generation — never hand-write JSON Schema twice. (Express 5 +express-openapi-validatoris an acceptable alternative.) Generate typed clients from the spec withopenapi-typescriptv7 +openapi-fetch— never hand-type response shapes. - GraphQL: graphql-js 17 SDL as source of truth (17.0.x is the current stable line; the v16 branch — 16.14.x — is superseded). Server: GraphQL Yoga 5 or Apollo Server 5 (Apollo Server 4 is EOL as of Jan 2026). Schema build: Pothos 4 (
@pothos/core, code-first with committed generated SDL) or@graphql-tools/schema(SDL-first) with@graphql-codegen/cliv5typescript-resolvers. N+1 defense: DataLoader 2. Guards: graphql-armor (depth, cost, alias, directive, token limits) orgraphql-query-complexity. - Auth: OAuth 2.1 / OIDC. OAuth 2.1 is still an IETF draft (
draft-ietf-oauth-v2-1, not yet a ratified RFC), but its consolidation is stable and the de-facto norm — mandatory PKCE, no implicit/password grants. JWT access tokens signed RS256/ES256 (never HS256 for public APIs, neveralg: none). Validate withjoseorexpress-oauth2-jwt-bearer. - Errors:
application/problem+jsonper RFC 9457 (obsoletes RFC 7807). - Contract tests: Prism (
@stoplight/prism-cli) for mock + validation proxy; Dredd or schemathesis for spec-vs-implementation conformance. - API docs: render from the spec (Redocly / Scalar). Never hand-write docs that can drift from the contract.
Project conventions
api/
openapi/
openapi.yaml # entry, $ref into paths/ and components/
paths/ # one file per resource: users.yaml, orders.yaml
components/
schemas/ # User.yaml, Error.yaml (Problem Details)
parameters/ # Cursor.yaml, PageSize.yaml
graphql/
schema.graphql # generated or authored SDL — the source of truth
resolvers/
loaders/ # one DataLoader factory per relation
src/
routes/ # thin: parse -> validate -> service -> serialize
services/ # business logic, no HTTP/GraphQL types
schemas/ # Zod/TypeBox, shared by validation + OpenAPI
- Naming: resource paths are plural nouns, kebab-case (
/shipping-addresses). JSON fields are camelCase, consistent across the whole API. Enum values areSCREAMING_SNAKE_CASEstrings, never bare integers. GraphQL typesPascalCase, fieldscamelCase, enumsSCREAMING_SNAKE_CASE. - Route handlers are thin. All business logic lives in
services/and takes/returns domain types, neverreq/resor GraphQLcontext. - Format and lint TS with Biome 2 (single toolchain replacing Prettier + ESLint); lint the spec with Spectral and the SDL with
@graphql-eslint/eslint-pluginin CI. A PR that changes an endpoint without touchingopenapi/orschema.graphqlfails review.
REST semantics
Model resources (nouns), not actions. The HTTP method is the verb.
| Method | Semantics | Safe | Idempotent | Success |
|---|---|---|---|---|
| GET | read | yes | yes | 200 (200 + empty array for empty collection, never 404) |
| POST | create / non-idempotent action | no | no | 201 + Location header, or 202 for async |
| PUT | full replace at a known URI | no | yes | 200 (body) or 204 |
| PATCH | partial update (JSON Merge Patch RFC 7396) | no | no | 200 or 204 |
| DELETE | remove | no | yes | 204; second DELETE returns 404 or 204 (pick one, document it) |
- GET has no body and no side effects. Never mutate on GET. Never tunnel writes through GET query params.
- PUT and DELETE must be idempotent: calling twice leaves the same server state. POST must not be — that is what idempotency keys are for.
- Forbidden: verbs in paths. Not
POST /createUser,GET /getUser,POST /users/1/activate. UsePOST /users,GET /users/1. For state transitions that resist CRUD, model the transition as a sub-resource:POST /orders/1/cancellationorPATCH /orders/1 {status:"CANCELLED"}— document which, don't mix.
Status codes — use the specific one
200OK,201Created (+Location),202Accepted (async),204No Content.304Not Modified (conditional GET hit; empty body).400malformed syntax / unparseable body.401missing/invalid credentials (addWWW-Authenticate).403authenticated but not allowed.404not found (or to hide existence from unauthorized callers).405method not allowed (+Allow).409conflict (duplicate, version conflict, state conflict).410Gone (removed resource).412Precondition Failed (If-MatchETag mismatch — optimistic-concurrency clash).415unsupported media type.422well-formed but semantically invalid (validation failures).428Precondition Required (forceIf-Matchon a conflict-prone write).429rate limited (+Retry-After).5xxfor server faults only — never for client mistakes. Never return200with{"success": false}. The status code is the outcome.- Distinguish
400(can't parse) from422(parsed, failed validation). Pick one convention and apply it everywhere.
Collections: pagination, filtering, sorting
- Cursor pagination at scale (any collection that grows or is high-traffic). Opaque, base64-encoded cursor over a stable sort key (e.g.
(created_at, id)), never a raw DB offset:
GET /orders?limit=50&cursor=eyJpZCI6IjAxSC4uLiJ9
200 { "data": [...], "pagination": { "nextCursor": "eyJ...", "hasMore": true } }
- Offset pagination (
?page=&pageSize=) only for small, bounded admin lists. Every collection endpoint must enforce a default and a maxlimit(e.g. default 20, max 100). No unbounded list responses. - Filtering: explicit params (
?status=SHIPPED&createdAfter=2026-01-01). Document each. Do not accept arbitrary DB-column filters. - Sorting:
?sort=-createdAt,name(leading-= desc). Whitelist sortable fields; reject others with 422.
Caching and conditional requests
- Emit
ETagon GET of a single resource (a strong validator over the representation — e.g. a hash or the row's version/updated_at). AddCache-Controldeliberately:no-storefor anything user-specific or sensitive,private, max-age=…for per-user cacheable reads,public, max-age=…, stale-while-revalidate=…only for shared, non-authenticated data. Version-prefixed URLs (/v1/...) are cache-friendly because the URL changes when the contract does. - Conditional GET for bandwidth: honor
If-None-Match: <etag>and return304 Not Modified(empty body, sameETag/Cache-Control) when unchanged.Last-Modified+If-Modified-Sinceis the weaker date-based fallback. - Optimistic concurrency for writes (the safe way to implement the 409 above): return the resource's current
ETagon GET; requireIf-Match: <etag>on PUT/PATCH/DELETE. If the stored version still matches, apply the write and return the newETag; if it has changed under the client, return412 Precondition Failed— never a lost update. Return428 Precondition Requiredto force clients to sendIf-Matchon conflict-prone resources. Prefer this over blind writes that silently clobber concurrent edits. - Use
If-None-Match: *on POST/PUT to mean "create only if it does not already exist" (returns412if it does).
Errors
Every non-2xx returns the same shape: application/problem+json (RFC 9457).
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "Request body failed validation.",
"instance": "/orders",
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "must be a valid email" }
]
}
- Always present: machine-readable
code(stable, documented enum), humantitle/detail, and astatusthat equals the HTTP status. - Field-level failures go in a
errorsarray withfield+code— never a single concatenated string. - Never leak internals: no stack traces, SQL, ORM messages, file paths, or internal hostnames in responses. Log those server-side with a correlation id and return
{ "code": "INTERNAL", "traceId": "..." }. - Same error contract for REST and GraphQL: GraphQL errors carry
extensions.code(UNAUTHENTICATED,FORBIDDEN,NOT_FOUND,BAD_USER_INPUT,RATE_LIMITED) matching REST codes.
Versioning
- Version from day one. Choose one scheme and never mix: URL prefix
/v1/...(simplest, cache-friendly — default) OR a media-type header (Accept: application/vnd.example.v1+json). Do not version per-endpoint. - Additive changes are non-breaking and ship without a version bump: adding an endpoint, an optional request field, or a new response field. Clients must ignore unknown response fields (tolerant reader).
- Breaking changes require a new major version: removing/renaming a field, changing a type, making an optional field required, changing status-code meaning, tightening validation, changing pagination. Never break
v1in place. - Deprecation policy: mark deprecated fields/endpoints in the spec (
deprecated: true), emit theDeprecationheader (RFC 9745, 2025 — a Structured-Field Date, i.e.@+ Unix seconds:Deprecation: @1735689600) alongsideSunset: <http-date>(RFC 8594, which standardizes onlySunset, as an IMF-fixdate:Sunset: Wed, 01 Jul 2026 00:00:00 GMT) and aLinkheader (rel="deprecation"/rel="sunset") to migration docs. Announce, dual-run old and new for a stated window (e.g. ≥6 months), then remove. Never silently remove.
Contracts
- The spec is the source of truth, authored/reviewed before implementation. Generate types/clients from it; do not hand-maintain both.
- Validate requests against the spec at the edge (reject unknown/invalid params with 422 before business logic).
- Validate responses against the spec in CI/staging (Prism proxy, Dredd, or schemathesis) so the implementation cannot drift from the contract. A response that violates the schema is a bug that fails the build.
- Spectral rules enforce: every operation has an
operationId, a4xxand5xxresponse,security, tagged, and every schema property documented.
Auth, idempotency, rate limits
- Bearer token per request:
Authorization: Bearer <jwt>. Validate signature,iss,aud,expon every request. Never accept credentials in query strings. - Scopes gate operations (
orders:read,orders:write). Enforce at the route/resolver; return403with the required scope indetailwhen missing,401when the token is absent/invalid. - Idempotency keys for unsafe retries: accept an
Idempotency-Keyheader on POST (and payment-like actions). Storekey -> (response, status)for a TTL (e.g. 24h); a replay returns the stored response, and the same key with a different body returns409. - Rate-limit headers on every response: the current IETF draft (
draft-ietf-httpapi-ratelimit-headers-11) uses Structured-Field Items with a quoted policy name and parameters —RateLimitreports the live quota (r= remaining units,t= seconds until the window resets),RateLimit-Policydeclares the quota (q= limit,w= window seconds):
RateLimit: "default";r=42;t=60
RateLimit-Policy: "default";q=100;w=60
The legacy triplet RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset (and the old limit=/remaining=/reset= dictionary form) is still widely deployed — emit whichever your clients consume, and always send Retry-After on 429.
GraphQL
- Schema-first: the SDL is the contract. Design types around the domain, not around tables — do not expose the DB schema 1:1.
- Kill N+1 with DataLoader: batch every relation field through a per-request loader. Instantiate loaders in
contextper request (never share across requests — it caches per user). Resolvers never call the DB directly for related entities. - Connection-based pagination (Relay spec) for lists:
edges { node, cursor },pageInfo { hasNextPage, endCursor },first/afterargs. No unbounded list fields; enforce a maxfirst. - Bound every query: enforce depth limit (e.g. 8), cost/complexity limit, and an alias/directive cap with graphql-armor / graphql-query-complexity. These are the real DoS controls — a bounded query surface, not schema secrecy.
- Mutations return a payload type (
{ order, userErrors }), not a bare scalar — expected/validation failures go inuserErrors, not thrown errors. - Additive schema evolution: add fields/types freely; use
@deprecated(reason:)before removal. Never repurpose a field's meaning or type.
Testing
- Contract conformance is the primary gate: run Dredd/schemathesis (REST) and a schema diff check (GraphQL) in CI. Fail the build on any request/response that violates the spec.
- Per-endpoint tests assert: exact status code, response body against the schema, and correct headers (
Locationon 201,RateLimit/RateLimit-Policy,Deprecation). Test the error paths (401/403/404/409/422/429), not just the happy path. - Idempotency test: PUT/DELETE twice → same final state; POST with a repeated
Idempotency-Key→ single effect, identical response. - Concurrency/caching test: GET returns an
ETag;If-None-Matchwith it →304; a PUT/PATCH with a staleIf-Match→412and no mutation (no lost update); two concurrent writers, only one succeeds. - Pagination test: walking cursors covers every row exactly once with no dupes across concurrent inserts;
limitover max is clamped or 422. - GraphQL: assert DataLoader batches (one DB call per relation per request), depth/complexity limits reject an over-deep query, and connection
pageInfois correct. - Backward-compat test: schema/spec diff (
oasdiff,graphql-inspector) blocks any breaking change to a shipped version.
Security
- HTTPS/TLS only; reject plaintext. Enforce
Content-Typeon writes (415otherwise); never infer. - Validate and bound all input: types, ranges, string lengths, array sizes, and a max request-body size. Reject unknown fields (
additionalProperties: false) rather than silently ignoring. - Authorization on every object, not just the route: check the caller owns/may access the specific id (defeat IDOR). A valid token is not authorization.
- JWT: verify
algagainst an allowlist (rejectnone), checkexp/nbf/aud/iss, short-lived access tokens + rotating refresh. Never trust client-supplied claims like roles without server validation. - No secrets or PII in URLs (they hit logs/caches). Keep them in headers/body.
- CORS: explicit origin allowlist, never
Access-Control-Allow-Origin: *on authenticated endpoints. - GraphQL hardening is per-object authz + query bounds, not schema secrecy. Disabling introspection and field suggestions in production is defense-in-depth (raises the cost of blind probing) — it is not an authorization boundary and must never be relied on as one; a determined client can still guess fields. Authorize every resolver and bound every query regardless.
Do
- Model resources as plural nouns; let HTTP methods be the verbs.
- Return the most specific correct status code; the status is the outcome.
- Ship one error shape (
application/problem+json) with a stablecodeeverywhere. - Prefix versions from
/v1on day one; make only additive changes within a version. - Keep OpenAPI/SDL as the source of truth and validate requests and responses against it.
- Use cursor + Relay connection pagination; enforce default and max page size.
- Batch GraphQL relations with per-request DataLoaders; cap depth and cost.
- Return
ETagon reads; useIf-Match→412for optimistic concurrency andIf-None-Match→304for caching; setCache-Controldeliberately. - Accept
Idempotency-Keyon POST; emitRateLimit/RateLimit-Policy,Retry-Afteron429, andDeprecation(RFC 9745) /Sunset(RFC 8594) headers.
Avoid
- Verbs in paths (
/getUser,/createOrder) → nouns + methods (GET /users/1,POST /orders). 200for everything with asuccessflag → real 2xx/4xx/5xx codes.- Mutating or writing on
GET→ use POST/PUT/PATCH/DELETE. - Offset pagination on large/growing collections → opaque cursors over a stable sort key.
- Unbounded list or GraphQL query responses → enforced
limit/first, depth, and complexity caps. - Leaking stack traces, SQL, or ORM errors → generic message + server-side log with
traceId. - Exposing DB tables/columns as the API shape → a designed resource/graph model.
- Breaking a shipped version in place → new major version + deprecation window.
- Inconsistent error shapes or
camelCase/snake_casemixing → one contract, one casing. - HS256/
alg: noneJWTs, tokens in query strings, route-only authz → RS256/ES256, headers, per-object checks.
When you code
- Change the contract first (
openapi.yaml/schema.graphql), get it to pass Spectral / schema lint, then implement to match. - Keep diffs small and per-resource. Touch one endpoint/type at a time; don't refactor unrelated routes in the same PR.
- Before finishing, run: Spectral (or graphql schema check),
oasdiff/graphql-inspectorfor breaking-change detection, typecheck, lint, and contract tests. Report any breaking change explicitly. - Ask before: introducing a breaking change or new version, changing the pagination or error contract, adding a new auth scheme, or exposing a new resource/field that reveals internal data. These are API-surface decisions, not implementation details.
- Output: the spec diff, the code, and a one-line note on backward compatibility (additive vs breaking, and the deprecation plan if breaking).
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Node.js 24 · TypeScript 6 · Fastify 5.9 · OpenAPI 3.2 · GraphQL Yoga 5.