APIs Are Contracts
An API is a contract between your service and its consumers. Once published, changing it breaks people. A well-designed API is intuitive, consistent, and evolves gracefully. A poorly designed API generates support tickets, confuses developers, and creates backward compatibility nightmares.
This article covers RESTful design principles, the GraphQL alternative, versioning strategies, pagination patterns, error handling, rate limiting, and API documentation β everything you need to design APIs that developers enjoy using.
REST Principles
REST (Representational State Transfer) is an architectural style, not a specification. Most "REST APIs" are actually HTTP APIs that follow some REST conventions. Here are the principles that matter in practice.
Resources and URLs
Resources are nouns. URLs identify resources. HTTP methods define actions on those resources.
Good (resource-oriented):
GET /api/users β List users
GET /api/users/123 β Get specific user
POST /api/users β Create user
PUT /api/users/123 β Replace user entirely
PATCH /api/users/123 β Update user partially
DELETE /api/users/123 β Delete user
Bad (action-oriented):
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser/123
GET /api/getUserOrders?userId=123
Nested resources for clear relationships:
GET /api/users/123/orders β Orders belonging to user 123
GET /api/users/123/orders/456 β Specific order for user 123
POST /api/users/123/orders β Create order for user 123
GET /api/orders/456/items β Items in order 456
GET /api/orders/456/items/789 β Specific item in order 456
Guideline: Nest at most 2 levels deep. Beyond that, use a top-level resource with a filter.
Bad: /api/users/123/orders/456/items/789/reviews
Good: /api/reviews?itemId=789
HTTP Methods
βββββββββββ¬βββββββββββββββ¬ββββββββββββββ¬βββββββββββββββ
β Method β Purpose β Idempotent? β Request Body β
βββββββββββΌβββββββββββββββΌββββββββββββββΌβββββββββββββββ€
β GET β Read β Yes β No β
β POST β Create β No β Yes β
β PUT β Replace β Yes β Yes β
β PATCH β Partial β No* β Yes β
β DELETE β Remove β Yes β No (usually) β
β HEAD β Headers only β Yes β No β
β OPTIONS β CORS/methods β Yes β No β
βββββββββββ΄βββββββββββββββ΄ββββββββββββββ΄βββββββββββββββ
* PATCH can be made idempotent depending on implementation
Idempotency matters. An idempotent operation produces the same result whether called once or multiple times. This is critical for retry logic β if a network timeout occurs, the client can safely retry a PUT or DELETE without side effects.
HTTP Status Codes
Use the right status code. Do not return 200 for everything.
2xx Success:
200 OK β General success (GET, PUT, PATCH, DELETE)
201 Created β Resource created (POST)
204 No Content β Success with no response body (DELETE)
3xx Redirection:
301 Moved Permanently β Resource URL changed permanently
304 Not Modified β Client cache is still valid
4xx Client Error:
400 Bad Request β Invalid input / validation error
401 Unauthorized β Missing or invalid authentication
403 Forbidden β Authenticated but not authorized
404 Not Found β Resource does not exist
405 Method Not Allowed β HTTP method not supported for this resource
409 Conflict β Resource state conflict (duplicate, version mismatch)
422 Unprocessable Entityβ Syntactically valid but semantically wrong
429 Too Many Requests β Rate limit exceeded
5xx Server Error:
500 Internal Server Error β Unexpected server failure
502 Bad Gateway β Upstream service unavailable
503 Service Unavailable β Server temporarily overloaded
504 Gateway Timeout β Upstream service timeout
Implementation Example
import express from 'express';
const router = express.Router();
// GET /api/users β List users with pagination
router.get('/users', async (req, res) => {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
db.users.findMany({ skip: offset, take: limit }),
db.users.count(),
]);
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});
// GET /api/users/:id β Get specific user
router.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: {
code: 'USER_NOT_FOUND',
message: `User with id ${req.params.id} not found`,
},
});
}
res.json({ data: user });
});
// POST /api/users β Create user
router.post('/users', async (req, res) => {
const { email, name } = req.body;
// Validation
if (!email || !name) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Missing required fields',
details: [
...(!email ? [{ field: 'email', message: 'Email is required' }] : []),
...(!name ? [{ field: 'name', message: 'Name is required' }] : []),
],
},
});
}
try {
const user = await db.users.create({ email, name });
res.status(201).json({ data: user });
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
return res.status(409).json({
error: {
code: 'DUPLICATE_EMAIL',
message: 'A user with this email already exists',
},
});
}
throw error;
}
});
// PATCH /api/users/:id β Partial update
router.patch('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'USER_NOT_FOUND', message: 'User not found' },
});
}
const updated = await db.users.update(req.params.id, req.body);
res.json({ data: updated });
});
// DELETE /api/users/:id β Delete user
router.delete('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'USER_NOT_FOUND', message: 'User not found' },
});
}
await db.users.delete(req.params.id);
res.status(204).send();
});
GraphQL vs REST
GraphQL is not a replacement for REST. It solves different problems and introduces different trade-offs.
REST β multiple endpoints, fixed response shape:
GET /api/users/123 β { id, name, email, address, phone, ... }
GET /api/users/123/orders β [{ id, total, status, items, ... }]
GET /api/users/123/reviews β [{ id, rating, text, product, ... }]
= 3 HTTP requests, potentially over-fetching fields you do not need
GraphQL β single endpoint, client specifies response shape:
POST /graphql
{
user(id: "123") {
name
email
orders(last: 5) {
id
total
status
}
}
}
= 1 HTTP request, only the fields you need
GraphQL Schema Example
type User {
id: ID!
name: String!
email: String!
orders(first: Int, after: String): OrderConnection!
createdAt: DateTime!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
createdAt: DateTime!
}
type OrderItem {
id: ID!
product: Product!
quantity: Int!
unitPrice: Float!
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
order(id: ID!): Order
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
createOrder(input: CreateOrderInput!): Order!
}
Comparison Table
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (per resource) | Single (/graphql) |
| Data fetching | Over/under fetching common | Client gets exactly what it asks for |
| Caching | HTTP caching (simple) | Complex (query-level caching) |
| Error handling | HTTP status codes | Always 200, errors in response body |
| File uploads | Straightforward (multipart) | Requires workarounds |
| Real-time | WebSocket, SSE | Subscriptions (built-in) |
| Tooling | Mature (Postman, curl, OpenAPI) | Growing (Apollo, Relay, GraphiQL) |
| Learning curve | Lower | Higher |
| N+1 problem | Managed by server | Must use DataLoader or similar |
| Best for | Simple CRUD, public APIs, microservices | Complex UIs, mobile apps, multiple client types |
When to Use Each
Choose REST when:
- Your API is simple CRUD operations
- You have a public API consumed by external developers
- HTTP caching is important for performance
- Your team is not experienced with GraphQL
Choose GraphQL when:
- Multiple clients (web, mobile, TV) need different data shapes
- You have deeply nested, related data
- Under-fetching or over-fetching is a real performance problem
- You want a strongly-typed, self-documenting API
API Versioning
Your API will change. How you handle those changes determines whether your consumers are happy or furious.
URL Path Versioning (Most Common)
GET /api/v1/users/123
GET /api/v2/users/123
// Express route organization
import v1Router from './routes/v1';
import v2Router from './routes/v2';
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Pros: Explicit, easy to understand, easy to route. Cons: URL pollution, entire API versioned even if only one endpoint changed.
Header Versioning
GET /api/users/123
Accept: application/vnd.myapi.v2+json
app.get('/api/users/:id', (req, res) => {
const version = req.headers['accept']?.match(/vnd\.myapi\.v(\d+)/)?.[1] || '1';
if (version === '2') {
return res.json(formatUserV2(user));
}
return res.json(formatUserV1(user));
});
Pros: Clean URLs, version only what changes. Cons: Harder to test (cannot just change URL), less discoverable.
Query Parameter Versioning
GET /api/users/123?version=2
Pros: Simple. Cons: Easy to forget, mixes versioning with other query params.
Versioning Strategy
Recommended approach:
1. Use URL path versioning (/api/v1/)
2. Increment major version only for breaking changes
3. Support at most 2 versions simultaneously
4. Deprecate old versions with a sunset date
5. Communicate changes via changelog and migration guide
// Deprecation header
app.use('/api/v1', (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Nov 2026 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
next();
}, v1Router);
Pagination
Never return unbounded lists. Always paginate.
Offset-Based Pagination
GET /api/users?page=3&limit=20
Response:
{
"data": [...20 users...],
"pagination": {
"page": 3,
"limit": 20,
"total": 1543,
"totalPages": 78
}
}
Pros: Simple, supports "jump to page 50". Cons: Slow on large datasets (OFFSET scans rows), inconsistent if data changes between pages.
Cursor-Based Pagination
GET /api/users?limit=20&after=eyJpZCI6MTAwfQ==
Response:
{
"data": [...20 users...],
"pagination": {
"hasNextPage": true,
"hasPreviousPage": true,
"startCursor": "eyJpZCI6MTAxfQ==",
"endCursor": "eyJpZCI6MTIwfQ=="
}
}
// Cursor-based pagination implementation
router.get('/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const after = req.query.after as string | undefined;
let query = db('users').orderBy('id', 'asc').limit(limit + 1); // +1 to check hasNextPage
if (after) {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString());
query = query.where('id', '>', cursor.id);
}
const rows = await query;
const hasNextPage = rows.length > limit;
const data = hasNextPage ? rows.slice(0, -1) : rows;
res.json({
data,
pagination: {
hasNextPage,
endCursor: data.length > 0
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64')
: null,
},
});
});
Pros: Consistent results even with data changes, performant on large datasets (uses index). Cons: Cannot jump to arbitrary page, slightly more complex.
Which Pagination to Use
| Use Case | Recommended |
|---|---|
| Admin dashboard, tables with "page 1, 2, 3" | Offset-based |
| Infinite scroll, mobile feeds | Cursor-based |
| Real-time data (new items added frequently) | Cursor-based |
| Small datasets (< 10,000 rows) | Either works |
| Large datasets (> 100,000 rows) | Cursor-based |
Error Response Format
Consistent error responses make debugging easier for API consumers.
// Standard error response format
type ApiError = {
error: {
code: string; // Machine-readable error code
message: string; // Human-readable message
details?: { // Field-level validation errors
field: string;
message: string;
}[];
requestId?: string; // For support/debugging
};
};
// Error handling middleware
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
const requestId = req.headers['x-request-id'] || generateRequestId();
if (err instanceof ValidationError) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: err.errors.map(e => ({
field: e.path,
message: e.message,
})),
requestId,
},
});
}
if (err instanceof NotFoundError) {
return res.status(404).json({
error: {
code: 'NOT_FOUND',
message: err.message,
requestId,
},
});
}
// Unexpected errors β log full details, return generic message
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
requestId,
path: req.path,
method: req.method,
});
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId,
},
});
}
app.use(errorHandler);
Rate Limiting
Protect your API from abuse and ensure fair usage.
Rate limit: 100 requests per minute per API key
Request 1: 200 OK (remaining: 99)
Request 2: 200 OK (remaining: 98)
...
Request 100: 200 OK (remaining: 0)
Request 101: 429 Too Many Requests
Retry-After: 35 (seconds until window resets)
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Basic rate limiter
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
// Use Redis for distributed rate limiting (multiple servers)
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
handler: (req, res) => {
res.status(429).json({
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests. Please try again later.',
retryAfter: Math.ceil(res.getHeader('Retry-After') as number),
},
});
},
});
app.use('/api/', apiLimiter);
// Tiered rate limits by plan
const tierLimits = {
free: rateLimit({ windowMs: 60000, max: 30 }),
pro: rateLimit({ windowMs: 60000, max: 300 }),
enterprise: rateLimit({ windowMs: 60000, max: 3000 }),
};
app.use('/api/', (req, res, next) => {
const tier = req.user?.tier || 'free';
tierLimits[tier](req, res, next);
});
Rate limit headers (RFC standard):
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 1714060800
API Documentation (OpenAPI/Swagger)
Document your API with OpenAPI (formerly Swagger) so consumers can explore it without reading source code.
# openapi.yaml
openapi: 3.0.3
info:
title: User Management API
version: 2.0.0
description: API for managing users and orders
servers:
- url: https://api.example.com/v2
description: Production
paths:
/users:
get:
summary: List users
operationId: listUsers
tags: [Users]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
"200":
description: List of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/User"
pagination:
$ref: "#/components/schemas/Pagination"
post:
summary: Create user
operationId: createUser
tags: [Users]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserInput"
responses:
"201":
description: User created
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/User"
"400":
$ref: "#/components/responses/ValidationError"
"409":
$ref: "#/components/responses/ConflictError"
components:
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
email:
type: string
format: email
createdAt:
type: string
format: date-time
required: [id, name, email, createdAt]
CreateUserInput:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 100
email:
type: string
format: email
required: [name, email]
Pagination:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
totalPages:
type: integer
responses:
ValidationError:
description: Validation failed
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: VALIDATION_ERROR
message:
type: string
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
Key Takeaways
- Design around resources, not actions. Use HTTP methods for CRUD operations on noun-based URLs.
- Use proper HTTP status codes. 200 is not the only status code. Clients depend on status codes for error handling and retry logic.
- GraphQL solves the under/over-fetching problem but adds complexity. Use it when you have multiple client types with different data needs.
- Version your API from day one with URL path versioning. Support at most two versions and deprecate with sunset headers.
- Always paginate list endpoints. Use cursor-based pagination for large datasets and real-time data.
- Standardize your error response format. Include a machine-readable code, human-readable message, and a request ID for debugging.
- Rate limit every API. Even internal APIs need protection from runaway clients and bugs.
- Document with OpenAPI. Auto-generate docs, client SDKs, and validation from a single source of truth.