API Contract Design
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/api-design.md |
| Description | Not specified |
Source Content
API Contract Design
Use this reference when designing a new API, adding endpoints to an existing service, choosing between protocols, deciding when to cut a /v2, or generating openapi.yaml from Zod schemas. APIs are the longest-lived contracts in the system — lock in the shape, versioning story, error envelope, and a Prism mock before a single handler ships.
This reference covers REST/OpenAPI 3.1 (the default for polyglot or public APIs), tRPC (TS-only internal monorepos), GraphQL (graph-shaped reads), and gRPC (perf-critical internal services). See the parent SKILL.md for the design workflow and elicitation points.
Quick reference: Five rules for any API
- One error envelope. RFC 9457 Problem Details, no ad-hoc shapes.
- Cursor pagination, not OFFSET, for any list endpoint that may exceed 10k rows.
- Idempotency keys on every state-changing POST (RFC 7231).
- Versioning story stated — additive forever, or
/v2with a 90-day deprecation window. - Mock command emitted so frontend isn’t blocked on handler implementation.
Protocol choice matrix
| Protocol | Best for | Trade-offs |
|---|---|---|
| REST / OpenAPI 3.1 | Polyglot services, public APIs, heterogeneous clients | Slightly verbose; well-understood; HTTP semantics matter; tooling mature |
| tRPC | TS-only internal monorepos, shared schema, speed-to-ship | TypeScript-only; smaller ecosystem; assumes shared runtime |
| GraphQL | Graph-shaped reads (many small joins per client), federation across teams | Complex validation; N+1 query trap; requires resolver discipline; slower mutation design |
| gRPC + buf | Perf-critical internal services, binary protocol, streaming | Steep learning curve; less human-friendly; requires proxy for browser clients |
One question to ask: “Who consumes this — internal TS only, polyglot, or public third-parties?” The answer almost always picks the protocol.
REST / OpenAPI 3.1 (the default)
Resource modeling
- Use plural nouns for collections:
/invoices,/users,/subscriptions. - Use nested resources for relationships:
/invoices/{id}/line_items(not/line_items?invoice_id={id}). - Map to correct HTTP verbs:
GET /resource— list (paginated)GET /resource/{id}— fetch onePOST /resource— create (always idempotent)PUT /resource/{id}— replace (all fields required)PATCH /resource/{id}— partial update (sparse fields)DELETE /resource/{id}— delete
Error envelope: RFC 9457 Problem Details
Every error response uses this shape (never bare strings, never ad-hoc):
{ "type": "https://api.example.com/errors#payment-failed", "title": "Payment Processing Failed", "status": 402, "detail": "Card declined: insufficient funds", "instance": "/invoices/inv-123"}Map all errors to HTTP status codes:
| HTTP Status | Problem Type | When |
|---|---|---|
| 400 | bad-request | Validation failed (malformed JSON, missing required field) |
| 401 | unauthorized | Authentication missing or invalid |
| 403 | forbidden | Authenticated but not authorized for this resource |
| 404 | not-found | Resource does not exist |
| 409 | conflict | State conflict (duplicate, precondition failed) |
| 422 | validation-error | Semantic validation (e.g., past date for a future deadline) |
| 429 | rate-limit-exceeded | Rate limit hit; include Retry-After header |
| 500 | internal-error | Server error; never echo internal details to client |
Example OpenAPI schema for Problem Details:
components: schemas: ProblemDetails: type: object required: - title - status properties: type: type: string format: uri title: type: string status: type: integer minimum: 400 maximum: 599 detail: type: string instance: type: string format: uriPagination: cursor-based, never OFFSET
Cursor pagination (correct):
GET /invoices?cursor=next_token_from_previous_response&limit=50Response:{ "items": [...50 invoices...], "next_cursor": "eyJpZCI6ICJpbnYtMTIzIn0="}OFFSET pagination (wrong for >10k rows):
GET /invoices?offset=1000&limit=50Why? OFFSET scans and discards 1000 rows for every request — O(n) per page. A cursor with an index does not.
For small lists (<1k rows), OFFSET is tolerable; use cursor everywhere else.
Cursor implementation:
// Encode: base64(json of last-row's sort key)next := base64.StdEncoding.EncodeToString([]byte(`{"id":"inv-999"}`))
// Decode: extract sort key and fetch the next pagevar cursor Cursorjson.Unmarshal(base64.StdEncoding.DecodeString(req.Cursor), &cursor)rows := db.Query("SELECT ... WHERE id > ? ORDER BY id LIMIT ?", cursor.ID, limit)Idempotency keys (RFC 7231 § 4.2.2)
Every POST that mutates state must accept an Idempotency-Key header. The server uses it to deduplicate retries:
POST /invoicesIdempotency-Key: 8e03978c-51f4-49d3-9a8f-24b6c4d70f98
{ "amount": 1000, "customer_id": "cust-123"}Implementation:
- On first request, generate a response ID
- Store
(idempotency_key, response_id)in a dedup table - On retry with same key, fetch and return the cached response (same status code, same body)
- Expire cache entries after 24–48 hours
This makes retries safe — the client can retry forever without fear of double-charging, double-creating, etc.
Versioning strategy
Option A: Additive forever (recommended for most services)
- New fields are always optional
- Old fields are never removed (mark deprecated with
x-deprecated: true) - New endpoints are added under the same
/api/root - No
/v2endpoint ever created - Example: add a
metadatafield to an invoice without breaking v1 clients
Option B: Major versions (only when breaking change tolerance is zero)
- Breaking changes trigger a new
/v2endpoint - The
/v1endpoint is supported for 90 days (window announced in advance) - After 90 days,
/v1is removed or returns 410 Gone - Example: invoice schema fundamentally changes; a
/v2endpoint with incompatible types is created
State your versioning decision in the spec (info.version in OpenAPI):
openapi: 3.1.0info: version: 1.0.0 title: Invoice API description: | **Versioning strategy:** Additive forever. We never break old clients. All new fields are optional; old fields are never removed.tRPC (TypeScript-only monorepos)
For TS-only internal services, tRPC is faster than OpenAPI:
import { z } from 'zod';import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const invoiceRouter = t.router({ list: t.procedure .input(z.object({ cursor: z.string().optional() })) .output(z.object({ items: z.array(invoiceSchema), next_cursor: z.string().optional(), })) .query(async ({ input }) => { // implementation }), create: t.procedure .input(invoiceCreateSchema) .output(invoiceSchema) .mutation(async ({ input }) => { // implementation }),});
export const appRouter = t.router({ invoices: invoiceRouter });Why tRPC?
- Schema inferred from Zod → full type safety on client and server
- One source of truth (the router, not a separate OpenAPI spec)
- No code generation step
When to use: Internal monorepo, frontend and backend in the same repo, TS/Node stack.
When NOT to use: Public APIs, polyglot teams, or when clients need a human-readable contract (OpenAPI spec).
GraphQL (graph-shaped reads)
Use GraphQL when clients need flexible shape control and many small joins per request:
type Query { invoice(id: ID!): Invoice invoices( after: String first: Int = 50 ): InvoiceConnection!}
type InvoiceConnection { edges: [InvoiceEdge!]! pageInfo: PageInfo!}
type Invoice { id: ID! amount: Int! customer: Customer! lineItems: [LineItem!]!}
type Customer { id: ID! name: String! # client can fetch this without a separate call}Why GraphQL?
- Clients fetch exactly the fields they need (no over-fetching)
- One roundtrip for related entities (customer + line items together)
- Schema is self-documenting and introspectable
Gotchas:
- N+1 query trap: naive resolvers fetch related entities one-at-a-time → slow. Use batch loaders or dataloader-style batching.
- Mutations are second-class: design the mutation input shape carefully, validate at the field level.
- No built-in pagination: use cursor-based pagination (Relay style) with
ConnectionandEdgetypes.
When to use: Many small joins per client, heterogeneous data shapes, web-first focus.
When NOT to use: Simple CRUD APIs, public APIs that benefit from OpenAPI docs, or teams unfamiliar with resolver patterns.
gRPC + buf (perf-critical internal services)
Use gRPC for binary protocol, streaming, and microsecond-scale latency:
service InvoiceService { rpc GetInvoice(GetInvoiceRequest) returns (Invoice); rpc ListInvoices(ListInvoicesRequest) returns (stream Invoice); rpc CreateInvoice(CreateInvoiceRequest) returns (Invoice);}
message Invoice { string id = 1; int64 amount_cents = 2; string customer_id = 3; repeated LineItem line_items = 4;}
message GetInvoiceRequest { string id = 1;}
message ListInvoicesRequest { string cursor = 1; int32 limit = 2 [(buf.validate.field).int32.gte = 1, (buf.validate.field).int32.lte = 100];}Why gRPC?
- Binary protocol → smaller messages, faster parsing
- HTTP/2 multiplexing → many parallel streams over one connection
- Streaming → real-time data push
Gotchas:
- Requires a proxy (grpc-web or Envoy) for browser clients
- Protobuf learning curve
- Debugging is harder (not human-readable like JSON)
When to use: Backend-to-backend services, real-time data pipelines, latency-sensitive internal APIs.
When NOT to use: Public APIs, browser clients, or teams unfamiliar with Protobuf.
Linting and validation
Spectral (OpenAPI only)
Spectral is an OpenAPI linter that catches common shape mistakes:
npm i -g @stoplight/spectral-clispectral lint api/openapi.yamlIt checks for:
- Missing
descriptionfields - Inconsistent error responses across endpoints
- Missing security schemes
- Unused schemas
Create an .spectralrc.yaml if you want org-specific rules:
extends: spectral:oasrules: operation-operationId-unique: error path-keys-no-trailing-slash: warnHouse rules (this skill’s scripts/lint_openapi.sh)
Automated checks for this skill’s own requirements:
- RFC 9457 Problem Details shape — all error responses have
type,title,status,detailfields - Cursor pagination — all list endpoints have a
cursorparameter (notoffset) - Idempotency-Key — all POST operations reference the
Idempotency-Keyheader
Run scripts/lint_openapi.sh api/openapi.yaml to validate.
Prism mocks (frontend can build in parallel)
Once the contract is locked, emit a Prism mock server so frontend doesn’t wait for handlers:
npm i -g @stoplight/prism-cliprism mock api/openapi.yaml --dynamic # starts a mock server on :4010Frontend can:
fetch('http://localhost:4010/invoices') .then(r => r.json()) .then(data => console.log(data))Prism generates realistic example data from the spec. When handlers are ready, swap localhost:4010 for the real API.
Bruno / Hurl tests (commit next to the spec)
Write portable API tests in Bruno or Hurl, committed alongside the spec:
Bruno (GUI + CLI):
// request GET /invoicesGET http://{{host}}/invoices?cursor={{cursor}}&limit=50Authorization: Bearer {{token}}
@name get_invoicesHurl (plain text, git-friendly):
GET http://localhost:8080/invoices?limit=50Authorization: Bearer token123
HTTP 200[Captures]next_cursor: body.next_cursorBoth formats are human-readable, versionable, and runnable in CI.
How to use this reference
- Follow the workflow in the parent SKILL.md’s “How I work (API contracts)” section
- Choose your protocol using the matrix above
- If REST/OpenAPI: follow the RFC 9457, cursor pagination, and Idempotency-Key rules
- Write the spec first (even if you generate it from code schemas)
- Emit a Prism mock and commit Bruno/Hurl tests
- Run
scripts/lint_openapi.shbefore submission - Hand the spec to frontend so they can mock and build in parallel