API Design Patterns
Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.
Additional References
- references/error-responses.md - Detailed error handling examples
Resource Naming
Use consistent, predictable URL patterns:
Collection resources (plural nouns)
GET /api/v1/users # List users POST /api/v1/users # Create user GET /api/v1/users/:id # Get user PUT /api/v1/users/:id # Update user (full) PATCH /api/v1/users/:id # Update user (partial) DELETE /api/v1/users/:id # Delete user
Nested resources
GET /api/v1/users/:userId/posts # List user's posts POST /api/v1/users/:userId/posts # Create post for user GET /api/v1/users/:userId/posts/:postId # Get specific post
Actions (use verbs sparingly)
POST /api/v1/users/:id/activate # Activate user POST /api/v1/posts/:id/publish # Publish post POST /api/v1/invoices/:id/send # Send invoice
Guidelines
-
Use plural nouns for collections (/users , not /user )
-
Use lowercase with hyphens for multi-word resources (/ledger-accounts )
-
Avoid deep nesting (max 2 levels: /users/:id/posts/:id )
-
Use query parameters for filtering, sorting, pagination
-
Use verbs only for actions that don't fit CRUD (activate, publish, send)
API Versioning
Version APIs in the URL path:
/api/v1/users /api/v2/users
Not in headers (harder to test/debug)
Not in query params (breaks caching)
Version Strategy
// v1/routes.ts export async function v1Routes(app: FastifyInstance) { app.get('/users', getUsersV1) app.post('/users', createUserV1) }
// v2/routes.ts export async function v2Routes(app: FastifyInstance) { app.get('/users', getUsersV2) // Breaking change in response structure app.post('/users', createUserV2) }
// server.ts app.register(v1Routes, { prefix: '/api/v1' }) app.register(v2Routes, { prefix: '/api/v2' })
RFC 7807 Problem Details
Standardized error response format:
interface ProblemDetail { type: string // Error type identifier status: number // HTTP status code title: string // Short, human-readable summary detail: string // Specific explanation for this occurrence instance: string // URI reference to specific occurrence traceId: string // Request trace ID for debugging }
// Example error response { "type": "NOT_FOUND", "status": 404, "title": "Not Found", "detail": "User with ID usr_01h455vb4pex5vsknk084sn02q not found", "instance": "/api/v1/users/usr_01h455vb4pex5vsknk084sn02q", "traceId": "req_abc123xyz" }
Error Types
// Domain error base class abstract class AppError extends Error { abstract readonly status: number abstract readonly type: string
constructor(message: string, public readonly context?: ErrorContext) { super(message) }
toResponse(instance: string, traceId: string): ProblemDetail { return { type: this.type, status: this.status, title: this.name, detail: this.message, instance, traceId, ...this.context, } } }
// Specific error types class NotFoundError extends AppError { readonly status = 404 readonly type = 'NOT_FOUND' }
class ConflictError extends AppError { readonly status = 409 readonly type = 'CONFLICT'
constructor( message: string, public readonly retryable: boolean = false, context?: ErrorContext ) { super(message, context) } }
class ServiceUnavailableError extends AppError { readonly status = 503 readonly type = 'SERVICE_UNAVAILABLE'
constructor( message: string, public readonly retryable: boolean = true, context?: ErrorContext ) { super(message, context) } }
See references/error-responses.md for complete examples.
Pagination (Cursor-Based)
Use cursor-based pagination for large datasets:
// Request GET /api/v1/posts?limit=20&cursor=pst_01h455vb4pex5vsknk084sn02q
// Response { "items": [ { "id": "pst_01h455w3x8k5z9y7q1m0n2b3c4", ... }, { "id": "pst_01h455x2y9l6a0z8r2n1o3c5d6", ... } ], "nextCursor": "pst_01h455z1a0m7b8y9s3o2p4d6e7", "hasMore": true }
Implementation
interface PaginatedRequest { limit?: number // Max items to return (default 20, max 100) cursor?: string // Cursor for next page (opaque to client) }
interface PaginatedResponse<T> { items: T[] nextCursor?: string hasMore: boolean }
async function listPosts(req: PaginatedRequest): Promise<PaginatedResponse<Post>> { const limit = Math.min(req.limit ?? 20, 100) const queryLimit = limit + 1 // Fetch one extra to check hasMore
const posts = await db.query.posts.findMany({ where: req.cursor ? gt(posts.id, req.cursor) : undefined, orderBy: desc(posts.createdAt), limit: queryLimit, })
const hasMore = posts.length > limit const items = posts.slice(0, limit) const nextCursor = hasMore ? items[items.length - 1].id : undefined
return { items, nextCursor, hasMore } }
Why Cursor Over Offset
❌ Offset-based (/posts?offset=40&limit=20)
- Unstable: Items can shift if new records inserted
- Performance: DB must scan all previous rows
- Inaccurate: Can miss or duplicate items
✅ Cursor-based (/posts?cursor=pst_xyz&limit=20)
- Stable: Cursor points to specific item
- Performant: DB uses index seek
- Accurate: No gaps or duplicates
Filtering & Sorting
Use query parameters for filtering and sorting:
Filtering
GET /api/v1/users?status=active&role=admin GET /api/v1/posts?author=usr_abc&published=true
Sorting
GET /api/v1/posts?sort=-createdAt # Descending (- prefix) GET /api/v1/users?sort=name # Ascending
Combined
GET /api/v1/posts?author=usr_abc&status=published&sort=-createdAt&limit=20
Implementation
interface ListPostsQuery { author?: string status?: 'draft' | 'published' sort?: 'createdAt' | '-createdAt' | 'title' | '-title' limit?: number cursor?: string }
async function listPosts(query: ListPostsQuery): Promise<PaginatedResponse<Post>> { const conditions = []
if (query.author) { conditions.push(eq(posts.authorId, query.author)) } if (query.status) { conditions.push(eq(posts.status, query.status)) }
const orderByColumn = query.sort?.startsWith('-') ? query.sort.slice(1) : query.sort ?? 'createdAt' const orderByDirection = query.sort?.startsWith('-') ? desc : asc
return await db.query.posts.findMany({ where: conditions.length > 0 ? and(...conditions) : undefined, orderBy: orderByDirection(posts[orderByColumn]), limit: query.limit ?? 20, }) }
HTTP Status Codes
Use status codes consistently:
Success
200 OK # Successful GET, PUT, PATCH 201 Created # Successful POST (include Location header) 204 No Content # Successful DELETE, PUT with no response body
Client Errors
400 Bad Request # Invalid request body/parameters 401 Unauthorized # Missing or invalid authentication 403 Forbidden # Valid auth, but lacks permission 404 Not Found # Resource doesn't exist 409 Conflict # Resource already exists, optimistic lock failure 422 Unprocessable # Validation error (semantic) 429 Too Many Requests # Rate limit exceeded
Server Errors
500 Internal Server Error # Unexpected error 503 Service Unavailable # Temporary unavailability, retry later
Response Envelope (When to Use)
Don't use envelopes for simple CRUD:
// ❌ Unnecessary wrapping GET /api/v1/users/123 { "success": true, "data": { "id": "123", "name": "Alice" } }
// ✅ Return resource directly GET /api/v1/users/123 { "id": "123", "name": "Alice" }
Use envelopes for pagination:
// ✅ Envelope needed for metadata GET /api/v1/users?limit=20 { "items": [...], "nextCursor": "usr_xyz", "hasMore": true }
Timestamps
Use ISO 8601 format for all timestamps:
{ "createdAt": "2024-01-15T14:30:00.000Z", // ISO 8601 UTC "updatedAt": "2024-01-16T09:15:30.123Z" }
// In entities toResponse(): UserResponse { return { ... createdAt: this.createdAt.toISOString(), // Date → ISO string updatedAt: this.updatedAt.toISOString(), } }
Idempotency
Use idempotency keys for safe retries:
// Request POST /api/v1/transactions Headers: Idempotency-Key: txn_abc123xyz Body: { "amount": 100, "from": "usr_123", "to": "usr_456" }
// Implementation async function createTransaction(rq: CreateTransactionRequest, idempotencyKey: string) { // Check if transaction with this key already exists const existing = await db.query.transactions.findFirst({ where: eq(transactions.idempotencyKey, idempotencyKey), })
if (existing) { return TransactionEntity.fromRecord(existing) // Return existing }
// Create new transaction const transaction = TransactionEntity.fromRequest(rq, idempotencyKey) return await transactionRepo.create(transaction) }
Guidelines
-
Plural nouns - Collections use plural resource names
-
Lowercase with hyphens - Multi-word resources like ledger-accounts
-
Version in URL - /api/v1/ , /api/v2/ for breaking changes
-
RFC 7807 errors - Standardized error response format
-
Cursor pagination - For large datasets (more stable than offset)
-
Query params - For filtering, sorting, pagination (not in path)
-
HTTP status codes - Use correct codes (200, 201, 204, 400, 404, 409, 500, 503)
-
ISO 8601 timestamps - Always use .toISOString() for dates
-
Idempotency keys - For non-idempotent operations (POST, PATCH)
-
No unnecessary envelopes - Return resources directly unless pagination needed