14 KiB
name, description, license, metadata
| name | description | license | metadata | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fullstack-dev-api-design | API design patterns and best practices. Use when creating endpoints, choosing methods/status codes, implementing pagination, or writing OpenAPI specs. Prevents common REST/GraphQL/gRPC mistakes. | MIT |
|
API Design Guidelines
Framework-agnostic API design guide for backend and full-stack engineers. 50+ rules across 10 categories, prioritized by impact. Covers REST, GraphQL, and gRPC.
Scope
USE this skill when:
- Designing a new API or adding endpoints
- Reviewing API pull requests
- Choosing between REST / GraphQL / gRPC
- Writing OpenAPI specifications
- Migrating or versioning an existing API
NOT for:
- Framework-specific implementation details (use your framework's own skill/docs)
- Frontend data fetching patterns (use React Query / SWR docs)
- Authentication implementation details (use your auth library's docs)
- Database schema design (→
database-schema-design)
Context Required
Before applying this skill, gather:
| Required | Optional |
|---|---|
| Target consumers (browser, mobile, service) | Existing API conventions in the project |
| Expected request volume (RPS estimate) | Current OpenAPI / Swagger spec |
| Authentication method (JWT, API key, OAuth) | Rate limiting requirements |
| Data model / domain entities | Caching strategy |
Quick Start Checklist
New API endpoint? Run through this before writing code:
- Resource named as plural noun (
/orders, not/getOrders) - URL in kebab-case, body fields in camelCase
- Correct HTTP method (GET=read, POST=create, PUT=replace, PATCH=partial, DELETE=remove)
- Correct status code (201 Created, 422 Validation, 404 Not Found…)
- Error response follows RFC 9457 envelope
- Pagination on all list endpoints (default 20, max 100)
- Authentication required (Bearer token, not query param)
- Request ID in response header (
X-Request-Id) - Rate limit headers included
- Endpoint documented in OpenAPI spec
Quick Navigation
| Need to… | Jump to |
|---|---|
| Name a resource URL | 1. Resource Modeling |
| Pick HTTP method + status code | 3. HTTP Methods & Status Codes |
| Format error responses | 4. Error Handling |
| Add pagination or filtering | 6. Pagination & Filtering |
| Choose API style (REST vs GraphQL vs gRPC) | 10. API Style Decision |
| Version an existing API | 7. Versioning |
| Avoid common mistakes | Anti-Patterns |
1. Resource Modeling (CRITICAL)
Core Rules
✅ /users — plural noun
✅ /users/{id}/orders — 1 level nesting
✅ /reviews?orderId={oid} — flatten deep nesting with query params
❌ /getUsers — verb in URL
❌ /user — singular
❌ /users/{uid}/orders/{oid}/items/{iid}/reviews — 3+ levels deep
Max nesting: 2 levels. Beyond that, promote to top-level resource with filters.
Domain Alignment
Resources map to domain concepts, not database tables:
✅ /checkout-sessions (domain aggregate)
✅ /shipping-labels (domain concept)
❌ /tbl_order_header (database table leak)
❌ /join_user_role (internal schema leak)
2. URL & Naming (CRITICAL)
| Context | Convention | Example |
|---|---|---|
| URL path | kebab-case | /order-items |
| JSON body fields | camelCase | { "firstName": "Jane" } |
| Query params | camelCase or snake_case (be consistent) | ?sortBy=createdAt |
| Headers | Train-Case | X-Request-Id |
Python exception: If your entire stack is Python/snake_case, you MAY use snake_case in JSON — but be consistent across all endpoints.
✅ GET /users ❌ GET /users/
✅ GET /reports/annual ❌ GET /reports/annual.json
✅ POST /users ❌ POST /users/create
3. HTTP Methods & Status Codes (CRITICAL)
Method Semantics
| Method | Semantics | Idempotent | Safe | Request Body |
|---|---|---|---|---|
| GET | Read | ✅ | ✅ | ❌ Never |
| POST | Create / Action | ❌ | ❌ | ✅ Always |
| PUT | Full replace | ✅ | ❌ | ✅ Always |
| PATCH | Partial update | ❌* | ❌ | ✅ Always |
| DELETE | Remove | ✅ | ❌ | ❌ Rarely |
Status Code Quick Reference
Success:
| Code | When | Response Body |
|---|---|---|
| 200 OK | GET, PUT, PATCH success | Resource / result |
| 201 Created | POST created resource | Created resource + Location header |
| 202 Accepted | Async operation started | Job ID / status URL |
| 204 No Content | DELETE success, PUT with no body | None |
Client Errors:
| Code | When | Key Distinction |
|---|---|---|
| 400 Bad Request | Malformed syntax | Can't even parse |
| 401 Unauthorized | Missing / invalid auth | "Who are you?" |
| 403 Forbidden | Authenticated, no permission | "I know you, but no" |
| 404 Not Found | Resource doesn't exist | Also use to hide 403 |
| 409 Conflict | Duplicate, version mismatch | State conflict |
| 422 Unprocessable | Valid syntax, failed validation | Semantic errors |
| 429 Too Many Requests | Rate limit hit | Include Retry-After |
Server Errors: 500 (unexpected), 502 (upstream fail), 503 (overloaded), 504 (upstream timeout)
4. Error Handling (HIGH)
Standard Error Envelope (RFC 9457)
Every error response uses this format:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Account balance $10.00 is less than withdrawal $50.00.",
"instance": "/transactions/txn_abc123",
"request_id": "req_7f3a8b2c",
"errors": [
{ "field": "amount", "message": "Exceeds balance", "code": "INSUFFICIENT_BALANCE" }
]
}
Multi-Language Implementation
TypeScript (Express):
class AppError extends Error {
constructor(
public readonly title: string,
public readonly status: number,
public readonly detail: string,
public readonly code: string,
) { super(detail); }
}
// Middleware
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({
type: `https://api.example.com/errors/${err.code}`,
title: err.title, status: err.status,
detail: err.detail, request_id: req.id,
});
}
res.status(500).json({ title: 'Internal Error', status: 500, request_id: req.id });
});
Python (FastAPI):
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, title: str, status: int, detail: str, code: str):
self.title, self.status, self.detail, self.code = title, status, detail, code
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status, content={
"type": f"https://api.example.com/errors/{exc.code}",
"title": exc.title, "status": exc.status,
"detail": exc.detail, "request_id": request.state.request_id,
})
Iron Rules
✅ Return RFC 9457 error envelope for ALL errors
✅ Include request_id in every error response
✅ Return per-field validation errors in `errors` array
❌ Never expose stack traces in production
❌ Never return 200 for errors
❌ Never swallow errors silently
5. Authentication & Authorization (HIGH)
✅ Authorization: Bearer eyJhbGci... (header)
❌ GET /users?token=eyJhbGci... (URL — appears in logs)
✅ 401 → "Who are you?" (missing/invalid credentials)
✅ 403 → "You can't do this" (authenticated, no permission)
✅ 404 → Hide resource existence (use instead of 403 when needed)
Rate Limit Headers (always include):
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1625097600
Retry-After: 30
6. Pagination & Filtering (HIGH)
Cursor vs Offset
| Strategy | When | Pros | Cons |
|---|---|---|---|
| Cursor (preferred) | Large/dynamic datasets | Consistent, no skips | Can't jump to page N |
| Offset | Small/stable datasets, admin UIs | Simple, page jumps | Drift on insert/delete |
Cursor pagination response:
{
"data": [...],
"pagination": { "next_cursor": "eyJpZCI6MTIwfQ", "has_more": true }
}
Offset pagination response:
{
"data": [...],
"pagination": { "page": 3, "per_page": 20, "total": 256, "total_pages": 13 }
}
Always enforce: Default 20 items, max 100 items.
Standard Filter Patterns
GET /orders?status=shipped&created_after=2025-01-01&sort=-created_at&fields=id,status
| Pattern | Convention |
|---|---|
| Exact match | ?status=shipped |
| Range | ?price_gte=10&price_lte=100 |
| Date range | ?created_after=2025-01-01&created_before=2025-12-31 |
| Sort | ?sort=field (asc), ?sort=-field (desc) |
| Sparse fields | ?fields=id,name,email |
| Search | ?q=search+term |
7. Versioning (MEDIUM-HIGH)
| Strategy | Format | Best For |
|---|---|---|
| URL path (recommended) | /v1/users |
Public APIs |
| Header | Api-Version: 2 |
Internal APIs |
| Query param | ?version=2 |
Legacy (avoid) |
Non-breaking changes (no version bump): New optional response fields, new endpoints, new optional params.
Breaking changes (new version required): Removing/renaming fields, changing types, stricter validation, removing endpoints.
Deprecation headers:
Sunset: Sat, 01 Mar 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v2/users>; rel="successor-version"
8. Request / Response Design (MEDIUM)
Consistent Envelope
{
"data": { "id": "ord_123", "status": "pending", "total": 99.50 },
"meta": { "request_id": "req_abc123", "timestamp": "2025-06-15T10:30:00Z" }
}
Key Rules
| Rule | Correct | Wrong |
|---|---|---|
| Timestamps | "2025-06-15T10:30:00Z" (ISO 8601) |
"06/15/2025" or 1718447400 |
| Public IDs | UUID "550e8400-..." |
Auto-increment 42 |
| Null vs absent (PATCH) | { "nickname": null } = clear field |
Absent field = don't change |
| HATEOAS (public APIs) | "links": { "cancel": "/orders/123/cancel" } |
No discoverability |
9. Documentation — OpenAPI (MEDIUM)
Design-first workflow:
1. Write OpenAPI 3.1 spec
2. Review spec with stakeholders
3. Generate server stubs + client SDKs
4. Implement handlers
5. Validate responses against spec in CI
Every endpoint documents: summary, all parameters, request body + examples, all response codes + schemas, auth requirements.
10. API Style Decision Tree
What kind of API?
│
├─ Browser + mobile clients, flexible queries
│ └─ GraphQL
│ Rules: DataLoader (no N+1), depth limit ≤7, Relay pagination
│
├─ Standard CRUD, public consumers, caching important
│ └─ REST (this guide)
│ Rules: Resources, HTTP methods, status codes, OpenAPI
│
├─ Service-to-service, high throughput, strong typing
│ └─ gRPC
│ Rules: Protobuf schemas, streaming for large data, deadlines
│
├─ Full-stack TypeScript, same team owns client + server
│ └─ tRPC
│ Rules: Shared types, no code generation needed
│
└─ Real-time bidirectional
└─ WebSocket / SSE
Rules: Heartbeat, reconnection, message ordering
Anti-Patterns Checklist
| # | ❌ Don't | ✅ Do Instead |
|---|---|---|
| 1 | Verbs in URLs (/getUser) |
HTTP methods + noun resources |
| 2 | Return 200 for errors | Correct 4xx/5xx status codes |
| 3 | Mix naming styles | One convention per context |
| 4 | Expose database IDs | UUIDs for public identifiers |
| 5 | No pagination on lists | Always paginate (default 20) |
| 6 | Swallow errors silently | Structured RFC 9457 errors |
| 7 | Token in URL query | Authorization header |
| 8 | Deep nesting (3+ levels) | Flatten with query params |
| 9 | Break changes without version | Maintain compatibility or version |
| 10 | No rate limiting | Implement + communicate via headers |
| 11 | No request ID | X-Request-Id on every response |
| 12 | Stack traces in production | Safe error message + internal log |
Common Issues
Issue 1: "Should this be a new resource or a sub-resource?"
Symptom: URL path keeps growing (/users/{id}/orders/{id}/items/{id}/reviews)
Rule: If the child entity makes sense on its own, promote it. If it only exists within the parent context, keep it nested (max 2 levels).
/reviews?orderId=123 ✅ (reviews exist independently)
/orders/{id}/items ✅ (items belong to orders, 1 level)
Issue 2: "PUT or PATCH?"
Symptom: Team can't agree on update semantics.
Rule:
- PUT = client sends complete resource (missing fields → set to default/null)
- PATCH = client sends only changed fields (missing fields → unchanged)
- When unsure → PATCH (safer, less surprising)
Issue 3: "400 or 422?"
Symptom: Inconsistent validation error codes.
Rule:
- 400 = can't parse request at all (malformed JSON, wrong content-type)
- 422 = parsed OK, but values fail validation (invalid email, negative quantity)