API Design Guide - REST & GraphQL Best
Practices
Table of Contents
1. RESTful API Design Principles
2. HTTP Methods and Status Codes
3. Versioning Strategies
4. Authentication and Authorization
5. Error Handling
6. Pagination and Filtering
7. Rate Limiting
8. GraphQL API Design
9. API Documentation
10. Testing APIs
1. RESTful API Design Principles
Resource-Oriented Design
REST APIs center around resources, which are entities or collections exposed
through URLs. Resources represent business objects: users, products, orders.
Each resource has a unique identifier and supports standard operations.
Resource URLs should be nouns, not verbs. URLs represent things, HTTP
methods represent actions on those things.
Good:
GET /api/users
GET /api/users/123
POST /api/users
PUT /api/users/123
DELETE /api/users/123
Bad:
GET /api/getUsers
POST /api/createUser
POST /api/updateUser
POST /api/deleteUser
URL Structure and Naming
URLs follow hierarchical structure reflecting resource relationships. Collections
use plural nouns. Resources nest under parents when relationship is strong.
/users - user collection
1
/users/123 - specific user
/users/123/orders - orders belonging to user 123
/users/123/orders/456 - specific order of user 123
Use hyphens for multi-word resources, not underscores or camelCase:
/user-profiles, not /user_profiles or /userProfiles.
Keep URLs short and readable. Avoid deeply nested URLs (max 3 levels). Use
query parameters for filtering and pagination instead of encoding in paths.
Statelessness
Each request contains all information needed to process it. Server doesn’t store
client state between requests. This enables horizontal scaling and simplifies
server implementation.
Authentication tokens or session identifiers can be sent with each request, but
the server doesn’t maintain session state. All necessary context comes from the
request.
Idempotency
Idempotent operations produce the same result regardless of how many times
they’re called. GET, PUT, DELETE are idempotent. POST is not idempotent.
PUT updates resources idempotently: calling PUT with same data multiple
times has the same effect as calling it once. This makes retries safe.
DELETE is idempotent: deleting a non-existent resource returns 404, but
doesn’t change system state differently than the first delete.
POST is not idempotent: creating a resource multiple times creates multiple
resources (unless server implements deduplication).
HATEOAS
Hypermedia As The Engine Of Application State means responses include links
to related resources and possible actions. Clients discover available operations
through hypermedia rather than out-of-band knowledge.
{
"id": 123,
"name": "Alice",
"email": "alice@[Link]",
"links": {
"self": "/api/users/123",
"orders": "/api/users/123/orders",
"edit": {
"href": "/api/users/123",
"method": "PUT"
2
},
"delete": {
"href": "/api/users/123",
"method": "DELETE"
}
}
}
HATEOAS enables API evolution without breaking clients. Clients follow links
instead of constructing URLs.
Content Negotiation
Clients specify desired response format via Accept header. Servers can support
multiple formats: JSON, XML, CSV.
GET /api/users/123
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{"id": 123, "name": "Alice"}
JSON is the de facto standard for modern APIs. XML is legacy. Support the
formats your clients need.
2. HTTP Methods and Status Codes
HTTP Methods
GET retrieves resources. It’s safe (doesn’t modify state) and idempotent. GET
requests should never have side effects.
GET /api/users/123
POST creates resources or executes operations. It’s not idempotent. POST to
collections creates resources; POST to resources executes actions.
POST /api/users
Content-Type: application/json
{"name": "Alice", "email": "alice@[Link]"}
PUT updates entire resources idempotently. Client sends complete resource
representation. Repeated identical PUTs have the same effect.
PUT /api/users/123
Content-Type: application/json
3
{"id": 123, "name": "Alice Updated", "email": "alice@[Link]"}
PATCH partially updates resources. Client sends only changed fields. PATCH
is not necessarily idempotent depending on patch format.
PATCH /api/users/123
Content-Type: application/json
{"name": "Alice Updated"}
DELETE removes resources idempotently. Deleting the same resource multiple
times has the same result (resource is gone).
DELETE /api/users/123
Success Status Codes
200 OK indicates successful request with response body. Used for successful
GET, PUT, PATCH, or DELETE with response.
201 Created indicates successful resource creation. Response includes Location
header pointing to new resource.
HTTP/1.1 201 Created
Location: /api/users/123
Content-Type: application/json
{"id": 123, "name": "Alice", "email": "alice@[Link]"}
204 No Content indicates successful request with no response body. Common
for DELETE operations or updates that don’t return updated resource.
Client Error Status Codes
400 Bad Request indicates malformed request syntax or invalid parameters. Re-
sponse includes error details.
{
"error": "Validation failed",
"details": [
{"field": "email", "message": "Invalid email format"}
]
}
401 Unauthorized indicates missing or invalid authentication. Client must au-
thenticate.
403 Forbidden indicates authenticated user lacks permission. Authentication is
valid but insufficient.
4
404 Not Found indicates resource doesn’t exist. Could also mean user lacks
permission to know resource exists (security through obscurity).
409 Conflict indicates request conflicts with current resource state. Common
for concurrent modifications or business rule violations.
422 Unprocessable Entity indicates syntactically correct but semantically invalid
request. Used for validation errors.
Server Error Status Codes
500 Internal Server Error indicates unexpected server failure. Response includes
correlation ID for debugging.
502 Bad Gateway indicates upstream server returned invalid response. Common
in proxied architectures.
503 Service Unavailable indicates temporary unavailability. Response includes
Retry-After header when possible.
3. Versioning Strategies
Why Version APIs
APIs evolve: adding features, fixing bugs, changing behavior. Versioning enables
changes without breaking existing clients. Different clients can use different API
versions.
Breaking changes require new versions: removing fields, changing field types,
changing behavior. Non-breaking changes don’t require versions: adding op-
tional fields, adding endpoints.
URL Versioning
Version in URL path is simple and explicit. Widely used and well-understood.
/api/v1/users
/api/v2/users
Pros: highly visible, easy to route, works with all HTTP clients. Cons: URLs
change across versions, version proliferation.
Header Versioning
Version specified in custom header. URLs remain stable across versions.
GET /api/users
API-Version: 2
5
Pros: clean URLs, supports content negotiation. Cons: less visible, harder to
test in browsers.
Query Parameter Versioning
Version passed as query parameter. Simple but unconventional.
/api/users?version=2
Pros: easy to implement, works with caching. Cons: query parameters seman-
tically meant for filtering, not versioning.
Content Negotiation Versioning
Version embedded in Accept header media type.
GET /api/users
Accept: application/[Link].v2+json
Pros: follows HTTP standards, supports multiple versions per resource. Cons:
complex, harder to discover and test.
Versioning Best Practices
Support old versions for reasonable periods. Announce deprecation early. Pro-
vide migration guides. Automate version testing.
Use semantic versioning: [Link]. Major version changes
break compatibility. Minor versions add features backward-compatibly.
Patches fix bugs.
Default to latest stable version when version not specified. Document all version
differences clearly.
4. Authentication and Authorization
API Keys
API keys are simple tokens identifying clients. Passed in header or query pa-
rameter.
GET /api/users
X-API-Key: sk_live_abc123def456
Pros: simple to implement and use. Cons: no expiration, no user context,
difficult to rotate.
Use API keys for: service-to-service communication, public rate-limited APIs,
simple internal tools.
6
OAuth 2.0
OAuth 2.0 is industry-standard authorization framework. Enables delegated
access: users authorize apps to access resources on their behalf without sharing
passwords.
Authorization Code Flow (for web apps): 1. Client redirects user to authoriza-
tion server 2. User authenticates and approves access 3. Authorization server
redirects back with authorization code 4. Client exchanges code for access token
5. Client uses access token to access resources
Client Credentials Flow (for service-to-service): 1. Client authenticates with
client ID and secret 2. Authorization server returns access token 3. Client uses
access token to access resources
JWT Tokens
JSON Web Tokens encode claims as JSON, sign them cryptographically. Self-
contained: contain all needed information without database lookups.
JWT structure: [Link]
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF
Decoded payload:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
Verify JWT signature to ensure authenticity. Check expiration. Extract user
identity and permissions from claims.
Role-Based Access Control
Users assigned roles bundling permissions. Roles like admin, manager, user
define what actions users can perform.
{
"user_id": 123,
"roles": ["admin", "user"],
"permissions": [
"users:read",
"users:write",
"users:delete",
"products:read"
]
}
7
Check permissions at API endpoints:
@[Link]('/api/users', methods=['DELETE'])
@require_permission('users:delete')
def delete_user(user_id):
...
Attribute-Based Access Control
ABAC evaluates policies based on attributes of user, resource, and context.
More flexible than RBAC.
Policy example: “Allow if [Link] == [Link] OR
[Link] == ‘admin’ ”
ABAC enables complex policies: time-based access, geographic restrictions, dy-
namic permissions.
5. Error Handling
Consistent Error Format
All errors follow consistent format making them easy to parse and handle.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be at least 18",
"code": "MIN_VALUE"
}
],
"trace_id": "abc123",
"timestamp": "2023-12-01T[Link]Z"
}
}
Include: error code (machine-readable), message (human-readable), details
(field-specific errors), trace ID (for debugging), timestamp.
8
Error Codes
Error codes enable programmatic error handling. Clients can handle specific
errors differently.
Common error codes: - VALIDATION_ERROR: request validation failed -
UNAUTHORIZED: authentication required or failed - FORBIDDEN: insuffi-
cient permissions - NOT_FOUND: resource doesn’t exist - CONFLICT: request
conflicts with current state - RATE_LIMIT_EXCEEDED: too many requests
- INTERNAL_ERROR: unexpected server error
Document error codes so clients know what errors to expect and how to handle
them.
Field-Level Errors
Validation errors include field-level details. Clients can display errors next to
relevant form fields.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "email",
"message": "Email is already taken",
"code": "UNIQUE_VIOLATION"
}
]
}
}
Logging and Monitoring
Log all errors server-side with full context: request details, stack traces, user
information. Include correlation IDs in responses so users can reference specific
errors when reporting issues.
Monitor error rates and alert on spikes. Track error types to identify systemic
issues.
Security Considerations
Don’t leak implementation details in error messages. Avoid exposing: stack
traces, database errors, file paths, internal IDs.
// Bad - leaks database details
{
9
"error": "duplicate key value violates unique constraint \"users_email_key\""
}
// Good - generic message
{
"error": {
"code": "CONFLICT",
"message": "Email is already registered"
}
}
Balance helpful errors for developers with security concerns. Detailed errors in
development, generic errors in production.
6. Pagination and Filtering
Offset-Based Pagination
Traditional pagination using offset and limit. Simple but has performance issues
with large offsets.
GET /api/users?offset=100&limit=20
Response includes pagination metadata:
{
"data": [...],
"pagination": {
"offset": 100,
"limit": 20,
"total": 1523
}
}
Pros: simple, allows jumping to arbitrary pages. Cons: performance degrades
with large offsets, inconsistent under concurrent modifications.
Cursor-Based Pagination
Pagination using opaque cursors. More efficient and consistent than offset-
based.
GET /api/users?cursor=eyJpZCI6MTIzfQ==&limit=20
Cursor encodes position (like last seen ID). Server decodes cursor and fetches
next page.
{
"data": [...],
10
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ==",
"has_more": true
}
}
Pros: consistent performance, handles concurrent modifications. Cons: can’t
jump to arbitrary pages.
Filtering
Filters allow clients to specify criteria for returned resources. Use query param-
eters for filters.
GET /api/users?status=active&role=admin
Support common filter operations: - Equality: status=active - Comparison:
age_gt=18, created_at_lt=2023-01-01 - In: status=active,pending - Pat-
tern matching: name_like=John%
Document supported filters and their syntax clearly.
Sorting
Allow sorting on relevant fields. Use query parameter specifying field and direc-
tion.
GET /api/users?sort=-created_at,name
Minus prefix indicates descending order. Comma-separated list supports multi-
field sorting.
Always provide default sort order for consistent results. Document sortable
fields.
Combining Pagination, Filtering, and Sorting
All features work together:
GET /api/users?status=active&role=admin&sort=-created_at&offset=0&limit=20
Process in order: filter, sort, paginate. This ensures consistent and expected
results.
7. Rate Limiting
Why Rate Limit
Rate limiting protects APIs from abuse and overload. Prevents: denial-of-
service attacks, scraping, accidental runaway clients, unequal resource distri-
11
bution.
Rate limits ensure fair usage across all clients and maintain system stability
under high load.
Rate Limit Algorithms
Fixed Window: count requests in fixed time windows (per minute, per hour).
Simple but allows bursts at window boundaries.
Sliding Window: count requests in rolling time window. Smoother than fixed
window but more complex.
Token Bucket: bucket holds tokens replenished at constant rate. Each request
consumes token. Allows controlled bursts.
Leaky Bucket: requests enter bucket, processed at constant rate. Smooths traffic
perfectly but may add latency.
Rate Limit Headers
Communicate rate limits via response headers. Clients can adjust behavior
proactively.
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1640995200
When limit exceeded, return 429 Too Many Requests:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Retry-After: 60
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Retry after 60 seconds."
}
}
Per-User vs Per-IP Limits
Per-user limits ensure authenticated users don’t abuse API. Track by user ID
from authentication token.
Per-IP limits protect unauthenticated endpoints. Track by IP address. Handle
proxies and NAT carefully.
12
Combine both: per-IP limits for unauthenticated requests, per-user limits for
authenticated requests.
Tiered Rate Limits
Different limits for different user tiers. Free users: 100 requests/hour. Paid
users: 10,000 requests/hour. Enterprise: unlimited or very high limits.
This incentivizes paid plans while still allowing free tier evaluation.
8. GraphQL API Design
GraphQL vs REST
GraphQL provides query language for APIs. Clients specify exactly what data
they need. Single endpoint serves all queries.
Advantages: no over-fetching, no under-fetching, strong typing, introspection,
single request for complex data.
Disadvantages: complexity, caching challenges, potential for expensive queries.
Schema Design
GraphQL schemas define types and operations. Types represent data models.
Operations are queries (read), mutations (write), subscriptions (real-time).
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
user: User!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
}
type Mutation {
13
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
Use exclamation marks for non-nullable fields. Design types matching domain
model.
Resolvers
Resolvers fetch data for fields. Each field can have a resolver function.
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
return await [Link](id);
},
},
User: {
orders: async (user, args, context) => {
return await [Link]([Link]);
},
},
};
Resolvers receive: parent object, field arguments, context (auth, db, etc.), info
(query details).
N+1 Query Problem
Naive resolvers cause N+1 queries: one query for list, N queries for nested fields.
query {
users {
name
orders { # Separate query per user - N+1 problem!
total
}
}
}
DataLoader solves this by batching and caching requests:
const orderLoader = new DataLoader(async (userIds) => {
const orders = await [Link](userIds);
return [Link](id => [Link](o => [Link] === id));
});
const resolvers = {
14
User: {
orders: (user, args, { orderLoader }) => {
return [Link]([Link]);
},
},
};
Error Handling
GraphQL returns 200 for all requests. Errors included in response:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"userId": "123"
}
}
]
}
Partial failures supported: some fields succeed, others error. Client receives
available data and errors.
9. API Documentation
OpenAPI Specification
OpenAPI (formerly Swagger) defines REST API structure in YAML/JSON.
Machine-readable enables generating: documentation, client SDKs, server stubs,
test cases.
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: List users
15
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required:
- id
- name
- email
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
Generate interactive documentation with Swagger UI or ReDoc.
Documentation Best Practices
Document all endpoints: purpose, parameters, responses, errors. Include exam-
ples for requests and responses.
Explain authentication requirements. Document rate limits. Provide getting
started guides.
Keep documentation synchronized with implementation. Automate generation
from code where possible.
Include code examples in multiple languages. Explain common use cases and
workflows.
16
API Changelog
Maintain changelog documenting all changes: new features, bug fixes, breaking
changes, deprecations.
## Version 2.1.0 (2023-12-01)
### Added
- New endpoint GET /api/users/{id}/orders
- Support for filtering users by status
### Changed
- Increased default page size from 10 to 20
### Deprecated
- Endpoint GET /api/getUserOrders (use GET /api/users/{id}/orders instead)
### Fixed
- Fixed pagination bug causing duplicate results
Communicate breaking changes early. Provide migration guides for version up-
grades.
10. Testing APIs
Unit Testing
Test individual components in isolation. Mock dependencies like databases and
external services.
def test_create_user():
mock_db = Mock()
mock_db.create_user.return_value = User(id=1, name="Alice")
service = UserService(mock_db)
user = service.create_user(name="Alice", email="alice@[Link]")
assert [Link] == 1
assert [Link] == "Alice"
mock_db.create_user.assert_called_once()
Integration Testing
Test components working together. Use test database with real data. Verify
end-to-end flows.
17
def test_create_user_integration():
client = TestClient(app)
response = [Link]('/api/users', json={
'name': 'Alice',
'email': 'alice@[Link]'
})
assert response.status_code == 201
user = [Link]()
assert user['name'] == 'Alice'
# Verify user in database
db_user = [Link](User).filter_by(id=user['id']).first()
assert db_user is not None
Contract Testing
Verify API conforms to specification. Test requests and responses against Ope-
nAPI schema.
def test_user_response_schema():
response = [Link]('/api/users/1')
validate_response(response, schema=USER_SCHEMA)
Contract tests catch breaking changes: removing fields, changing types, adding
required fields.
Performance Testing
Load test APIs to identify bottlenecks and capacity limits. Tools like Apache
JMeter, Locust, or k6.
// k6 load test
import http from 'k6/http';
export default function() {
[Link]('[Link]
}
export let options = {
vus: 100, // 100 virtual users
duration: '30s',
};
Monitor response times, error rates, throughput under load. Identify perfor-
mance degradation points.
18
Security Testing
Test authentication and authorization. Verify: unauthenticated requests re-
jected, insufficient permissions blocked, JWT validation, rate limiting, input
validation.
Test for common vulnerabilities: SQL injection, XSS, CSRF, insecure deserial-
ization.
Use automated security scanning tools. Perform manual penetration testing for
critical APIs.
This comprehensive guide covers API design from fundamentals to advanced
topics. Well-designed APIs enable developer productivity, system integration,
and long-term maintainability.
19