REST API Design Principles
Resource Naming
✅ Correct:
GET /users → List users
POST /users → Create user
GET /users/{id} → Get user
PUT /users/{id} → Replace user (full update)
PATCH /users/{id} → Update user (partial)
DELETE /users/{id} → Delete user
GET /users/{id}/orders → User's orders
POST /users/{id}/orders → Create order for user
❌ Wrong:
GET /getUser
POST /createUser
GET /user/getAll
POST /users/delete/{id} → Use DELETE verb
HTTP Status Codes
2xx Success:
200 OK → GET, PUT, PATCH success
201 Created → POST success; include Location header
202 Accepted → Async operation started (not yet complete)
204 No Content → DELETE, or PUT/PATCH that returns nothing
3xx Redirect:
301 Moved Permanently → Old URL gone forever
302 Found → Temporary redirect
4xx Client Error:
400 Bad Request → Malformed request, validation error
401 Unauthorized → Not authenticated (no/invalid token)
403 Forbidden → Authenticated but not authorized
404 Not Found → Resource doesn't exist
409 Conflict → Duplicate, optimistic locking failure
410 Gone → Resource permanently deleted
422 Unprocessable → Validation failed (semantic error)
429 Too Many Requests → Rate limit exceeded
5xx Server Error:
500 Internal Server Error → Bug / unhandled error
502 Bad Gateway → Upstream service failed
503 Service Unavailable → Overloaded or maintenance
504 Gateway Timeout → Upstream timeout
Consistent Error Response
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Must be between 18 and 120"
}
],
"request_id": "req_8f7d6e5c4b3a",
"docs_url": "https://api.example.com/docs/errors#VALIDATION_FAILED"
}
}
Offset pagination:
GET /users?limit=20&offset=0
GET /users?limit=20&offset=20
✅ Simple, random access
❌ Performance degrades at high offset
❌ Inconsistent results when data changes during pagination
Cursor pagination (preferred for large datasets):
GET /users?limit=20
Response: { "data": [...], "cursor": "eyJpZCI6MTAwfQ==" }
GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
✅ Consistent results (no skipping/duplicates)
✅ O(log n) performance with index
❌ No random access ("go to page 5")
Page-based (user-friendly):
GET /users?page=1&per_page=20
Response: { "data": [...], "pagination": { "page": 1, "per_page": 20, "total": 150, "total_pages": 8 } }
Standard pagination envelope:
{
"data": [...],
"pagination": {
"cursor": "next_cursor_value", // or "next_page"
"has_more": true,
"total": 1500 // optional, expensive to compute
}
}
Versioning Strategies
1. URL versioning (most common)
/api/v1/users
/api/v2/users
✅ Explicit, cacheable
❌ URL pollution
2. Header versioning
Accept: application/vnd.myapi.v2+json
API-Version: 2024-01
✅ Clean URLs
❌ Less visible, harder to test in browser
3. Query parameter
GET /users?version=2
✅ Easy to test
❌ Easy to forget, breaks caching
Recommendation: URL versioning + Sunset header for deprecation
API-Deprecation: Fri, 01 Jan 2026 00:00:00 GMT
Sunset: Fri, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2>; rel="successor-version"
Authentication Patterns
API Key (simple services, M2M):
Authorization: Bearer sk_live_abc123
X-API-Key: sk_live_abc123
Store: hash (bcrypt/SHA-256), never plaintext
Prefix with env: sk_live_ sk_test_ sk_dev_
JWT (stateless, user sessions):
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Best practices:
- Short expiry: 15-60 min for access tokens
- Refresh token: 7-30 days, stored in httpOnly cookie
- Use RS256 (asymmetric) for microservices
- Include: sub, iat, exp, jti (JWT ID for revocation)
OAuth 2.1 (third-party access):
- Authorization Code + PKCE (browser/mobile)
- Client Credentials (M2M)
- Never use Implicit flow (deprecated)
mTLS (high-security M2M):
Both parties present certificates
Best for: financial services, healthcare
Idempotency
Idempotency key pattern — safe retries for non-GET operations:
POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Server behavior:
1. Check if key seen before
2. If yes: return cached response (same status code + body)
3. If no: process request, cache response for 24 hours, respond
// Express middleware
async function idempotencyMiddleware(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key || req.method === 'GET') return next();
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
// Intercept response to cache it
const originalJson = res.json.bind(res);
res.json = (body) => {
redis.setex(`idempotency:${key}`, 86400, JSON.stringify({
status: res.statusCode,
body,
}));
return originalJson(body);
};
next();
}
OpenAPI/Swagger Documentation
# openapi.yaml
openapi: "3.1.0"
info:
title: Users API
version: "2.0.0"
contact:
email: [email protected]
license:
name: Apache 2.0
servers:
- url: https://api.example.com/v2
description: Production
- url: https://sandbox.example.com/v2
description: Sandbox
paths:
/users:
get:
summary: List users
operationId: listUsers
tags: [Users]
parameters:
- name: cursor
in: query
schema:
type: string
description: Pagination cursor from previous response
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
"200":
description: Paginated user list
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
"401":
$ref: '#/components/responses/Unauthorized'
"429":
$ref: '#/components/responses/RateLimited'
security:
- bearerAuth: []
post:
summary: Create user
operationId: createUser
tags: [Users]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
"201":
description: User created
headers:
Location:
schema:
type: string
description: URL of created user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
"422":
$ref: '#/components/responses/ValidationError'
components:
schemas:
User:
type: object
required: [id, email, created_at]
properties:
id:
type: string
format: uuid
readOnly: true
email:
type: string
format: email
name:
type: string
maxLength: 100
created_at:
type: string
format: date-time
readOnly: true
CreateUserRequest:
type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: string
minLength: 1
maxLength: 100
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds until rate limit resets
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
GraphQL Schema Design
# schema.graphql — Good schema design
type Query {
user(id: ID!): User
users(
first: Int
after: String # Cursor-based pagination (Relay spec)
filter: UserFilter
orderBy: UserOrderBy
): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
type Subscription {
userCreated: User!
orderStatusChanged(orderId: ID!): Order!
}
# Relay-spec connection (enables pagination)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Payload types for mutations (best practice)
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
code: String!
message: String!
}
type User {
id: ID!
email: String!
name: String!
createdAt: DateTime!
# Nested connections (N+1 risk — use DataLoader!)
orders(first: Int, after: String): OrderConnection!
posts(first: Int): PostConnection!
}
# Input types
input CreateUserInput {
email: String!
name: String!
role: UserRole = USER
}
enum UserRole {
ADMIN
USER
GUEST
}
scalar DateTime
scalar UUID
N+1 Prevention with DataLoader
// Without DataLoader: 1 query for users + N queries for each user's orders
// With DataLoader: 1 query for users + 1 batched query for ALL orders
import DataLoader from 'dataloader';
// Create per-request dataloader (not global — prevents cache bleed)
function createLoaders(db: DB) {
return {
orders: new DataLoader<string, Order[]>(async (userIds) => {
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ANY($1)',
[userIds as string[]]
);
// Group by userId and preserve order
return userIds.map(id => orders.filter(o => o.userId === id));
}),
user: new DataLoader<string, User | null>(async (ids) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids as string[]]
);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
}),
};
}
// In resolver
const resolvers = {
User: {
orders: async (user, args, context) => {
return context.loaders.orders.load(user.id); // Batched automatically
}
}
};
gRPC Design
// users.proto
syntax = "proto3";
package users.v1;
import "google/protobuf/timestamp.proto";
service UserService {
// Unary RPC
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
// Server streaming — good for real-time feeds
rpc ListUsers(ListUsersRequest) returns (stream User);
// Bidirectional streaming — good for chat
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message GetUserRequest {
string user_id = 1;
}
message User {
string id = 1;
string email = 2;
string name = 3;
google.protobuf.Timestamp created_at = 4;
UserRole role = 5;
}
enum UserRole {
USER_ROLE_UNSPECIFIED = 0; // Always start with 0 as unknown
USER_ROLE_ADMIN = 1;
USER_ROLE_USER = 2;
}
message CreateUserRequest {
string email = 1;
string name = 2;
}
API Design Checklist
Design:
[ ] Resources are nouns, not verbs
[ ] Consistent naming convention (snake_case vs camelCase — pick one)
[ ] Appropriate HTTP verbs used
[ ] Status codes are correct
[ ] Error responses are consistent and include error codes
Security:
[ ] Authentication required (except explicitly public endpoints)
[ ] Authorization checked (user can only see their own data)
[ ] Input validation on all fields
[ ] Rate limiting implemented
[ ] Idempotency keys for mutations
Documentation:
[ ] OpenAPI/GraphQL schema is current
[ ] Error codes are documented
[ ] Rate limits documented
[ ] Authentication flow documented
[ ] Example requests/responses provided
Reliability:
[ ] Pagination on all list endpoints
[ ] Timeouts set appropriately
[ ] Deprecated endpoints have Sunset headers
[ ] Breaking changes use new version