| name | api-design-patterns |
| description | Language-agnostic API design patterns covering REST and GraphQL, including resource naming, HTTP methods, status codes, versioning, pagination, filtering, authentication, error handling, and schema design. Activate when working with APIs, REST endpoints, GraphQL schemas, API documentation, OpenAPI/Swagger, JWT, OAuth2, endpoint design, API versioning, rate limiting, or GraphQL resolvers. |
API Design Patterns
Language-agnostic patterns for designing robust, scalable REST and GraphQL APIs. Focus on solving real problems with simple, maintainable solutions.
Auto-activate when: Working with API routes, endpoints, REST design, GraphQL schemas, OpenAPI/Swagger specs, authentication tokens, API documentation, or discussing endpoint design, versioning strategies, or API architecture.
Philosophy
- Simple over clever - Choose straightforward patterns that solve the problem
- Consistency - Apply patterns consistently across endpoints
- Pragmatism - Pick approaches based on actual use cases, not theoretical purity
- No over-engineering - Don't add features or complexity "just in case"
REST API Design
Resource Naming Conventions
Principles:
- Use nouns for resource names, not verbs
- Use lowercase with hyphens for multi-word resources
- Represent relationships hierarchically
- Use plural for collections
✅ Good patterns:
GET /users
GET /users/{id}
GET /users/{id}/posts
GET /users/{id}/posts/{post_id}/comments
POST /users
PUT /users/{id}
DELETE /users/{id}
❌ Avoid verbs:
GET /getUsers
GET /fetchUserById
POST /createUser
GET /getUserPosts
Special cases:
- Singular for singleton resources:
/profile,/settings(user-specific, not collections) - Actions as sub-resources:
/users/{id}/activate(when GET/POST semantics don't fit) - Search/filter: Use query parameters, not new endpoints
GET /users?role=admin&status=active- NOT
GET /users/adminsorGET /active-users
HTTP Methods
| Method | Purpose | Idempotent | Safe | Has Body |
|---|---|---|---|---|
| GET | Retrieve resource | Yes | Yes | No |
| POST | Create new resource | No | No | Yes |
| PUT | Replace entire resource | Yes | No | Yes |
| PATCH | Partial update | No | No | Yes |
| DELETE | Remove resource | Yes | No | No |
| HEAD | Like GET, no body | Yes | Yes | No |
| OPTIONS | Describe communication | Yes | Yes | No |
Best practices:
- GET - Never for mutations; safe to retry
- POST - Create new or trigger actions; use 201 Created
- PUT - Full replacement; include all fields
- PATCH - Partial update; only changed fields
- DELETE - Use 204 No Content or 200 with body
Avoid: PATCH if API is simple; use PUT instead. Don't mix PUT/PATCH semantics.
HTTP Status Codes
2xx Success:
200 OK- General success (GET, PUT with response body)201 Created- Resource created (POST)204 No Content- Success, no body (DELETE, PATCH with no response)202 Accepted- Request queued, will process asynchronously
3xx Redirection:
301 Moved Permanently- Resource moved (deprecated endpoints)304 Not Modified- Client cache valid (use ETag/If-None-Match)
4xx Client Error:
400 Bad Request- Invalid input (malformed JSON, missing fields)401 Unauthorized- Missing or invalid auth403 Forbidden- Authenticated but no permission404 Not Found- Resource doesn't exist409 Conflict- Concurrent update or constraint violation422 Unprocessable Entity- Semantically invalid (validation errors)429 Too Many Requests- Rate limit exceeded
5xx Server Error:
500 Internal Server Error- Unexpected error503 Service Unavailable- Temporary downtime
Versioning Strategies
Option 1: URL Path (Explicit, Straightforward)
/api/v1/users
/api/v2/users
Pros: Clear, cacheable, explicit breaking changes Cons: Multiple code paths, redundancy
Option 2: Header-based (Clean URLs)
GET /api/users
Accept-Version: 1.0
Pros: Clean URLs, version handling logic centralized Cons: Less obvious in browser/logs
Option 3: Media Type (Accept header)
GET /api/users
Accept: application/vnd.myapi.v2+json
Pros: RESTful, content negotiation Cons: Complex, less common
Recommendation: Use URL versioning for major changes. Avoid if possible - design for forward compatibility:
- Add fields without removing old ones
- Make new features optional
- Deprecated endpoints return 410 Gone with migration info
Pagination Patterns
Offset/Limit (Simple, works for small datasets):
GET /users?offset=0&limit=20
Response:
{
"data": [...],
"pagination": {
"offset": 0,
"limit": 20,
"total": 1500
}
}
Cursor-based (Scalable, efficient for large datasets):
GET /users?cursor=abc123&limit=20
Response:
{
"data": [...],
"pagination": {
"cursor": "next_cursor_xyz",
"limit": 20
}
}
Pros: Efficient queries, works with distributed systems Cons: Cursor generation logic needed
Keyset pagination (Efficient, uses natural ordering):
GET /users?after_id=123&limit=20
Use natural sort fields (ID, timestamp) instead of arbitrary cursors.
Recommendation:
- Small fixed datasets: offset/limit
- Large or growing datasets: cursor-based
- Simple endpoints: keyset pagination
Filtering, Sorting, Searching
Filtering:
GET /users?role=admin&status=active&department=sales
GET /posts?created_after=2024-01-01&created_before=2024-12-31
Sorting:
GET /users?sort=name,-created_at
(hyphen = descending)
Or explicit:
GET /users?sort_by=name&sort_order=asc
Searching:
GET /users?search=john
GET /posts?q=api+design
(Full-text search, implementation-specific)
Validation:
- Whitelist allowed filter/sort fields
- Escape search queries (SQL injection prevention)
- Limit result count with pagination
Rate Limiting
Headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1629801600
When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Strategies:
- Token bucket - Smooth bursts, standard
- Leaky bucket - Even distribution
- Fixed window - Simple, vulnerable to boundary abuse
- Sliding window - Balanced complexity/accuracy
Recommendation: Token bucket per user/API key with reasonable defaults (e.g., 1000 req/hour).
Request/Response Patterns
Request Validation
Validate early:
1. Schema validation (required fields, types)
2. Format validation (email, UUID, dates)
3. Business logic validation (duplicate check, range)
4. Return appropriate error
Request validation example:
POST /users
{
"email": "user@example.com",
"name": "John Doe",
"age": 30
}
Validation error response (400/422):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "INVALID_EMAIL",
"message": "Invalid email format"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be >= 18"
}
]
}
}
Error Response Format
Consistent error structure:
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User with id 123 does not exist",
"status": 404,
"timestamp": "2024-01-15T10:30:00Z",
"request_id": "req_abc123xyz"
}
}
Or simplified for simple APIs:
{
"code": "INVALID_REQUEST",
"message": "Missing required field: email"
}
Error codes (use consistently):
INVALID_REQUEST- Malformed requestVALIDATION_ERROR- Field validation failedAUTHENTICATION_FAILED- Invalid credentialsINSUFFICIENT_PERMISSIONS- Authorized but lacks permissionRESOURCE_NOT_FOUND- 404RESOURCE_ALREADY_EXISTS- 409 on duplicateINTERNAL_SERVER_ERROR- 500
Success Response Format
Envelope pattern (good for APIs with metadata):
{
"data": {
"id": "123",
"name": "John Doe",
"email": "john@example.com"
},
"meta": {
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0"
}
}
Direct pattern (simpler, common in modern APIs):
{
"id": "123",
"name": "John Doe",
"email": "john@example.com"
}
Collection response:
{
"data": [
{ "id": "1", "name": "User 1" },
{ "id": "2", "name": "User 2" }
],
"pagination": {
"cursor": "next_page",
"limit": 20
}
}
Recommendation: Keep responses consistent. Use envelopes if you need pagination/meta at root level. For collections, include pagination separately.
Partial Responses (Optional)
Allow clients to request specific fields:
GET /users/123?fields=id,name,email
Reduces bandwidth for large objects. Implement via field selection in queries (GraphQL does this naturally).
Authentication & Authorization
API Key Pattern
Simple, good for service-to-service:
GET /api/data
Authorization: Bearer api_key_xyz
or
GET /api/data?api_key=xyz123
Pros: Simple, easy to debug Cons: Less secure than OAuth2, no scoping
Storage: Use secure vaults, never log keys, rotate regularly.
JWT (JSON Web Token)
Flow:
1. Client authenticates (POST /auth/login)
2. Server returns JWT
3. Client includes in Authorization header
4. Server validates signature
GET /api/protected
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
JWT structure: header.payload.signature
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "user123", "exp": 1629801600, "scope": "read write" }
Signature: HMACSHA256(header.payload, secret)
Best practices:
- Store secret securely (environment variable, vault)
- Include expiration (
exp) - Use HTTPS only
- Validate signature on every request
- Refresh tokens for long-lived sessions
- Include scopes for fine-grained permissions
OAuth2 (Delegated Authorization)
Flow (Authorization Code):
1. User clicks "Login with Google"
2. Redirect to OAuth provider
3. User authenticates with provider
4. Provider redirects back with auth code
5. Server exchanges code for access token
6. Server gets user info, creates session
When to use: Third-party integrations, user account delegation
Scopes:
scope=read write user:email profile
Permission Models
Role-based (RBAC):
User → Role(s) → Permission(s)
admin: can do everything
moderator: can delete comments, ban users
user: can create posts, read public data
Attribute-based (ABAC):
Can user perform action on resource?
Policy: user can delete post if:
- user.role == "admin" OR
- resource.owner_id == user.id OR
- user.created_at < resource.created_at - 24hours
Recommendation: Start with RBAC (simpler). Move to ABAC only if needed.
Implementation:
Middleware approach:
1. Extract user/token from request
2. Load user permissions
3. Check against required permission
4. Allow/deny
GraphQL Patterns
Schema Design
Build around data needs, not database structure:
# ✅ Good: Organized by domain
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
followers(first: Int): UserConnection!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments(first: Int): CommentConnection!
publishedAt: DateTime!
}
# ❌ Avoid: Exposing raw database structure
type UserRow {
user_id: Int!
user_name: String!
created_timestamp: String!
}
Nullability:
# Sensible defaults
type User {
id: ID! # Always required
email: String! # Required
bio: String # Optional, may be null
posts: [Post!]! # Required array, posts required
}
Query vs Mutation
Queries: Read operations, always safe to execute multiple times
query {
user(id: "123") {
name
email
posts { title }
}
}
Mutations: Write operations, may have side effects
mutation {
createPost(input: {title: "...", body: "..."}) {
id
createdAt
}
}
Batch operations:
mutation {
updateUsers(updates: [{id: "1", name: "Alice"}, {id: "2", name: "Bob"}]) {
id
name
}
}
Resolvers
Resolver anatomy:
function resolve(parent, args, context, info) {
// parent: object containing this field
// args: arguments passed to field
// context: shared data (user, db, etc)
// info: field metadata
return data
}
Example:
const resolvers = {
Query: {
user: (parent, { id }, context) => {
return context.userDB.findById(id);
}
},
User: {
posts: (user, { first }, context) => {
return context.postDB.findByAuthorId(user.id).limit(first);
}
}
};
Key principle: Resolvers should be simple, push logic to services/repositories.
N+1 Query Problem
Problem:
User query returns 100 users
For each user, resolve posts (100 queries!)
Total: 1 + 100 = 101 queries
Solution 1: DataLoader (Batching)
const userLoader = new DataLoader(async (userIds) => {
// Load all users at once instead of individually
return database.users.findByIds(userIds);
});
// In resolver:
User: {
posts: (user, args, context) => {
// Uses batched loader
return context.postLoader.loadByAuthorId(user.id);
}
}
Solution 2: Proactive Loading
Query: {
users: async (parent, args, context) => {
const users = await context.userDB.find();
// Batch load all posts for users
const postMap = await context.postDB.findByAuthorIds(
users.map(u => u.id)
);
users.forEach(u => u._postsMap = postMap[u.id]);
return users;
}
}
Recommendation: Use DataLoader for most cases. Simple and effective.
Error Handling
GraphQL errors:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"status": 404
}
}
]
}
Pattern: Partial data + errors in extensions. Allows graceful degradation.
API Documentation
OpenAPI/Swagger
Minimal example:
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: User list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
summary: Create user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
required: [id, name, email]
Tools:
- Swagger UI - Interactive exploration
- ReDoc - Clean documentation
- Postman - API client and testing
GraphQL Schema Documentation
Introspection (built-in):
{
__schema {
types {
name
description
fields { name, description, type }
}
}
}
Tools:
- GraphQL Playground - Interactive IDE
- GraphQL Explorer (Apollo) - Documented explorer
- Voyager - Schema visualization
Write descriptive type/field definitions:
"""
User account in the system.
Each user has a unique email and can create multiple posts.
"""
type User {
"""Unique identifier (UUID)"""
id: ID!
"""User's full name"""
name: String!
"""Email address (must be unique)"""
email: String!
}
Common Pitfalls & Solutions
Pitfall: Endpoint Explosion
Problem: Creating endpoints for every slight variation
GET /users
GET /users/admins
GET /users/active
GET /users/verified
Solution: Use filtering
GET /users?role=admin&status=active&verified=true
Pitfall: Inconsistent Error Handling
Problem: Different endpoints return different error formats
// Endpoint 1
{ "error": "Not found" }
// Endpoint 2
{ "code": 404, "message": "Resource not found" }
Solution: Standardize error format across all endpoints
Pitfall: God Endpoints
Problem: Single endpoint doing too much based on parameters
GET /data?type=users&action=delete&id=123
Solution: Use proper REST structure
DELETE /users/123
Pitfall: Ignoring Caching
Problem: No cache headers, identical queries repeated
GET /users/123
(No Cache-Control or ETag headers)
Solution: Add cache headers
GET /users/123
Cache-Control: public, max-age=300
ETag: "abc123xyz"
Clients respect caching, reduce server load.
Pitfall: Breaking Changes Without Versioning
Problem: Removing fields or changing response structure
// v1: { "user": { "name": "John" } }
// Now: { "name": "John" }
// Breaks all clients
Solution:
- Use versioning (URL or header)
- Design for forward compatibility (add fields, don't remove)
- Support deprecated fields for reasonable period
Pitfall: Poor Pagination
Problem: No limits, full result set in every request
GET /posts
Returns all 1 million posts (crashes clients)
Solution: Always paginate
GET /posts?limit=20&offset=0
Returns 20 items with pagination metadata
Pitfall: Exposed Internal Details
Problem: Error messages revealing system internals
ERROR: Unique constraint violation on users_email_idx
Solution: Generic error codes with details in logs
{ "code": "VALIDATION_ERROR", "message": "Email already in use" }
(Log full details server-side)
Pitfall: Inadequate Authentication
Problem: No authentication or sending credentials in URL
GET /api/data?api_key=secret123
GET /api/data?password=mypassword
Solution: Use Authorization header with HTTPS
GET /api/data
Authorization: Bearer <token>
(HTTPS only)
Pitfall: Missing Request Validation
Problem: Accepting any input, failing later in business logic
POST /users
{ "name": 123, "email": "not-an-email" }
(No validation, crashes in processing)
Solution: Validate request schema immediately
1. Type check (name: string)
2. Format check (email: valid format)
3. Business rules (email unique, age >= 18)
4. Return 400 if invalid
Pitfall: GraphQL Over/Under Fetching Issues
Problem (Over-fetching with REST):
GET /users/123
Returns: { id, name, email, phone, address, ... }
Client only needs: id, name
Solution: Use GraphQL's precise field selection
query {
user(id: "123") {
id
name
}
}
Problem (Under-fetching with GraphQL):
query {
user(id: "123") { posts { id } }
user(id: "456") { posts { id } }
# Separate queries for each user
}
Solution: Batch queries
query {
user1: user(id: "123") { posts { id } }
user2: user(id: "456") { posts { id } }
# Single request, clear
}
Quick Reference
REST Status Codes:
2xx: Success (200, 201, 204)4xx: Client error (400, 401, 403, 404, 422, 429)5xx: Server error (500, 503)
Authentication:
- API Key: Simple, good for internal/service APIs
- JWT: Good for public APIs, includes scopes
- OAuth2: Third-party integrations
Pagination:
- Offset/limit: Simple, small datasets
- Cursor-based: Large datasets, efficient
- Keyset: Natural sort fields
GraphQL N+1:
- Use DataLoader for batching
- Implement in resolver layer
- Transparent to schema
Error Format:
{
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": {}
}
Always include:
- Consistent endpoint structure
- Clear error responses
- Proper status codes
- Pagination on collections
- Authentication/authorization
- Request validation
- Documentation
- Caching headers
Note: For project-specific API patterns, check .claude/CLAUDE.md in the project directory.