Designs RESTful APIs, GraphQL schemas, and RPC interfaces for consistency, usability, and scalability.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add cosmix/loom --skill loom-api-design --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Loom Api Design?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-api-design)More formats (shields.io, HTML) on the badges page.
---
name: loom-api-design
description: Designs RESTful APIs, GraphQL schemas, and RPC interfaces for consistency, usability, and scalability.
allowed-tools:
- Read
- Grep
- Glob
- Edit
- Write
triggers:
- api design
- REST
- RESTful
- REST API
- GraphQL
- GraphQL schema
- gRPC
- OpenAPI
- Swagger
- endpoint
- route
- resource
- CRUD
- HTTP method
- GET
- POST
- PUT
- PATCH
- DELETE
- status code
- pagination
- filtering
- sorting
- versioning
- HATEOAS
- API versioning
- schema design
- RPC
- service design
- idempotency key
- problem+json
- RFC 9457
---
# API Design
## Overview
Design intuitive, consistent, evolvable API contracts across REST, GraphQL, and gRPC. Get the resource model, HTTP semantics, error envelope, pagination, and versioning right up front — they are the hardest things to change once clients depend on them.
## Design Workflow
1. Model resources and relationships before URLs; name the nouns, then map operations onto them.
2. Pick the paradigm: REST (resource CRUD, caching, broad tooling), GraphQL (client-shaped reads, aggregation, mobile), gRPC (internal, low-latency, streaming, strong contracts).
3. Lock the **error envelope**, **pagination shape**, and **versioning policy** once — reuse everywhere.
4. Design for change: additive evolution, tolerant readers, deprecation windows.
5. Write the spec (OpenAPI/SDL/proto) as the source of truth; generate clients/servers from it.
## REST
### Resource & URL conventions
- Nouns, plural collections: `/users`, `/users/{id}`, `/users/{id}/orders`. Never verbs in paths (`/getUsers` is wrong — the method is the verb).
- Pick one case (`snake_case` or `camelCase`) and keep it identical across paths, query params, and bodies.
- Keep nesting shallow (≤2 levels); deep hierarchies couple resources. Prefer `/orders?user_id=` over `/users/{id}/orders/{oid}/items/...`.
- Sub-resource actions that aren't CRUD: model as a resource (`POST /orders/{id}/refunds`) rather than an RPC verb (`POST /orders/{id}/refund`) when the action produces a trackable entity.
### HTTP methods, safety, idempotency
| Method | Purpose | Safe | Idempotent | Body |
| ------ | ------- | ---- | ---------- | ---- |
| GET | Read | yes | yes | no |
| HEAD | Read headers | yes | yes | no |
| POST | Create / non-idempotent action | no | **no** | yes |
| PUT | Full replace (client sets full state) | no | yes | yes |
| PATCH | Partial update | no | not inherently | yes |
| DELETE | Remove | no | yes | maybe |
- **Idempotent** = same request repeated yields same server state (not same response — a repeated DELETE may return 404 the second time; that's fine).
- PUT replaces the entire resource — omitted fields are cleared. If a client can't send full state, use PATCH.
### Status codes — get the ambiguous ones right
| Code | Use for | Not for |
| ---- | ------- | ------- |
| 200 | OK with body | creates (use 201) |
| 201 | Created; return `Location` + body | |
| 202 | Accepted, async processing pending | sync completion |
| 204 | Success, no body (DELETE, some PUT) | |
| 400 | Malformed **syntax** (bad JSON, wrong type) | valid-but-rejected semantics |
| 401 | Missing/invalid **authentication** | authorization failures |
| 403 | Authenticated but not **authorized** | missing auth (401) |
| 404 | Not found (or hide existence from 403) | |
| 409 | **State conflict**: duplicate, optimistic-concurrency/version clash | field validation |
| 422 | Well-formed but **semantically invalid** (business-rule / field validation) | syntax errors (400) |
| 428 | Precondition Required — force `If-Match` to prevent lost updates | |
| 412 | Precondition Failed — `If-Match`/ETag mismatch | |
| 429 | Rate limited; include `Retry-After` | |
| 503 | Temporarily down; include `Retry-After` | |
⚠ 400 vs 422: 400 = "I can't parse this." 422 = "I parsed it, but `age: -5` violates a rule." Pick one convention for field-validation errors (422 is the modern default) and apply it everywhere. ⚠ 409 vs 422: 409 is about **resource state** (email already taken, stale version); 422 is about the **payload's** semantics.
### PATCH: Merge Patch vs JSON Patch
Two incompatible standards — declare which via `Content-Type`:
- **JSON Merge Patch** (RFC 7386, `application/merge-patch+json`): send a partial object; present keys overwrite, `null` **deletes** the member. Simple, but you **cannot set a value to null** and **cannot edit array elements** (arrays are replaced wholesale).
- **JSON Patch** (RFC 6902, `application/json-patch+json`): an ordered array of ops (`add`/`remove`/`replace`/`move`/`copy`/`test`) with JSON Pointer paths. Handles arrays, nulls, and conditional updates; verbose. `test` enables optimistic concurrency inside the patch.
```json
// merge-patch: clears "nickname", sets name
{ "name": "Ada", "nickname": null }
// json-patch: equivalent + array edit
[ { "op": "replace", "path": "/name", "value": "Ada" },
{ "op": "remove", "path": "/nickname" },
{ "op": "add", "path": "/tags/-", "value": "vip" } ]
```
### Idempotency keys (safe POST retries)
For non-idempotent creates/payments, accept a client-generated `Idempotency-Key` header. Store `key → (request fingerprint, response)` for a window (e.g., 24h). On replay: same key + same body ⇒ return the stored response; same key + **different** body ⇒ 422/409 (key reuse). Scope keys per endpoint + authenticated principal. This is the correct fix for "user double-clicked / client retried on timeout."
### Pagination
| Style | Pros | Cons / gotchas |
| ----- | ---- | -------------- |
| Offset/limit (`?page=&per_page=`) | random access, total counts, trivial | **drifts under concurrent writes** (inserts/deletes shift the window → skipped or duplicated rows); deep offsets are slow (DB scans+discards N rows) |
| Cursor/keyset (`?after=<opaque>`) | stable under writes, O(1) via indexed `WHERE key > cursor` | no jump-to-page; needs a stable total-order sort key |
⚠ Cursor correctness: the sort key **must be unique and total-ordering**, or rows sharing a value get skipped/duplicated at page boundaries. Tie-break on a unique column: `ORDER BY created_at, id` and encode both in the cursor. ⚠ Make cursors **opaque** (base64 the keyset) so clients can't depend on internals and you can evolve the scheme. Default and cap `limit` (e.g., default 20, max 100) to protect the backend. Prefer cursor for infinite scroll / large or write-heavy datasets; offset only for small, admin-style, jump-to-page needs.
### Filtering, sorting, field selection
- Filter via query params: `?status=active&min_price=10`. Sort with a signed key list: `?sort=-created_at,name` (`-` = desc).
- Sparse fieldsets / expansion to control payload size: `?fields=id,email` and `?expand=customer`.
- Keep filter grammar simple; a full query DSL in query strings is a maintenance trap — reach for GraphQL if clients truly need arbitrary queries.
### Error envelope — standardize on RFC 9457 (problem+json)
RFC 9457 (obsoletes 7807), media type `application/problem+json`. Members: `type` (URI identifying the problem class), `title`, `status`, `detail`, `instance`, plus domain extensions.
```json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Request validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"instance": "/users",
"errors": [
{ "field": "email", "code": "invalid_format", "message": "Not a valid email" },
{ "field": "age", "code": "out_of_range", "message": "Must be 18–120" }
],
"request_id": "req_abc123"
}
```
Rules: machine-readable stable `type`/`code` (clients switch on these, never on `title`/`message` prose); a `request_id` for support/tracing; a `errors` array for per-field validation. Never leak stack traces, SQL, or internal hostnames.
### Versioning
| Strategy | Pros | Cons |
| -------- | ---- | ---- |
| URI path `/v2/users` | explicit, cache-friendly, easy to route/test | version pinned to URL; coarse-grained |
| Media type `Accept: application/vnd.api.v2+json` | clean URLs, granular | harder to test/curl; caches need `Vary: Accept` |
| Custom header `API-Version: 2` | clean URLs | invisible in logs/URLs; `Vary` caution |
| Query `?version=2` | trivial | pollutes caching/URLs; least recommended |
- **Version only for breaking changes.** Additive changes (new endpoints, new optional fields, new enum values *if clients tolerate unknowns*) need no bump — bake "tolerant reader" into client guidance.
- Breaking = removing/renaming fields, changing types/meaning, changing auth or error format, tightening validation.
- Deprecate with `Deprecation` and `Sunset` response headers (RFC 8594) + `Link rel="successor-version"`; support N-1 for a published window; publish a migration guide.
## GraphQL
- **Don't version** — evolve additively; mark removals with `@deprecated(reason:)`. Avoid nullable-to-non-null tightening (breaking).
- Relay-style connections for lists: `edges { node cursor }`, `pageInfo { hasNextPage endCursor }`. Global `Node` interface + opaque IDs for refetch/caching.
- Separate input types per mutation (`CreateUserInput`, `UpdateUserInput`); mutations return a **payload** carrying both `data` and typed `errors`, not just top-level `errors`.
- Nullability is a contract: a non-null field that errors **nulls its nearest nullable parent** — model expected-failure fields as nullable so one error doesn't blank a whole response.
- ⚠ **N+1 by construction**: nested resolvers fan out into per-row fetches. Batch with DataLoader. ⚠ Untrusted clients can craft deep/expensive queries — enforce **depth and complexity limits** and persisted queries.
## gRPC / Protobuf
- Methods are verbs: `GetUser`, `ListUsers`, `CreateUser`. Package-version namespaces: `myapi.v1`.
- **Field numbers are the wire contract**: never change or reuse a number; `reserved` deleted numbers/names. Adding fields is backward-compatible; changing a field's type or number is not.
- Cursor pagination via `page_size` + `page_token` / `next_page_token` (AIP-158). Rich errors via `google.rpc.Status` + status codes; use canonical codes (`NOT_FOUND`, `ALREADY_EXISTS`, `FAILED_PRECONDITION`, `INVALID_ARGUMENT`).
- Use `optional` (proto3) to distinguish "unset" from zero-value where it matters. Leverage streaming (server/client/bidi) instead of polling.
## Examples
### REST: paginated collection (OpenAPI 3.1)
```yaml
paths:
/products:
get:
summary: List products
parameters:
- { name: category, in: query, schema: { type: string } }
- { name: after, in: query, schema: { type: string }, description: Opaque cursor }
- { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
responses:
"200":
description: Cursor-paginated products
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/Product" } }
page_info:
type: object
properties:
end_cursor: { type: [string, "null"] } # 3.1 nullable via type array
has_next_page: { type: boolean }
post:
summary: Create product
parameters:
- { name: Idempotency-Key, in: header, schema: { type: string } }
responses:
"201": { description: Created }
"422": { description: Validation error }
```
### GraphQL: mutation payload + connection
```graphql
type Query {
users(filter: UserFilter, first: Int = 20, after: String): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
type CreateUserPayload {
user: User
errors: [UserError!]! # typed, in-payload errors
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge { node: User! cursor: String! }
```
### gRPC: list with pagination + reserved fields
```protobuf
syntax = "proto3";
package ecommerce.v1;
service ProductService {
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
}
message Product {
string id = 1;
string name = 2;
int64 price_cents = 4;
reserved 3; // never reuse a retired field number
reserved "legacy_sku";
}
message ListProductsRequest {
string category = 1;
int32 page_size = 2; // caps server-side
string page_token = 3; // opaque cursor
}
message ListProductsResponse {
repeated Product products = 1;
string next_page_token = 2;
}
```
## Checklists
**Contract design — verify before publishing:**
- [ ] Resources are nouns; casing consistent across path/query/body; nesting ≤2 levels
- [ ] Every operation's success **and** error status codes are chosen with 400/401/403/404/409/422/429 used per their semantics
- [ ] One error envelope (problem+json) with stable machine-readable `code`/`type` + `request_id`, reused everywhere
- [ ] Pagination shape fixed (cursor for large/write-heavy); cursors opaque; sort key unique/total-ordering with tie-break; `limit` capped
- [ ] PATCH semantics declared (merge-patch vs json-patch) via `Content-Type`
- [ ] Non-idempotent creates accept `Idempotency-Key`; optimistic concurrency via ETag/`If-Match` where lost-update matters
- [ ] Versioning policy chosen; only breaking changes bump; `Deprecation`/`Sunset` headers + migration guide planned
**Evolution safety — before shipping a change:**
- [ ] Change classified breaking vs additive; breaking ⇒ new version, not silent
- [ ] New fields optional; unknown-field tolerance documented for clients
- [ ] GraphQL: removals `@deprecated`, not deleted; gRPC: field numbers untouched, retired numbers `reserved`
- [ ] No secrets, stack traces, or internal identifiers leak in error bodies
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!