- Published on
REST API Design Best Practices 2025: Essential Principles and Patterns Every Developer Must Know
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Why API Design Matters
- 2. REST Fundamentals: Richardson Maturity Model
- 3. URL Design: Naming Conventions and Patterns
- 4. HTTP Methods Deep Dive
- 5. Status Code Strategy
- 6. Pagination Patterns
- 7. Filtering, Sorting, and Field Selection
- 8. Versioning Strategies
- 9. Error Handling: RFC 9457 Problem Details
- 10. Rate Limiting and Throttling
- 11. Security
- 12. OpenAPI 3.1
- 13. API Testing
- 14. Real-World Examples: Leading API Design Patterns
- 15. Quiz
- References
- Conclusion
Introduction
The API economy is projected to reach $13.7 trillion by 2027. API-first companies like Stripe, Twilio, and SendGrid have built multi-billion dollar businesses, and all modern software is connected through APIs. A well-designed API maximizes developer experience (DX), simplifies maintenance, and becomes a key driver of business growth.
This article systematically covers everything from REST API design fundamentals to advanced patterns, including industry standards and battle-tested best practices as of 2025. We cover every topic with code examples: Richardson Maturity Model, URL design, HTTP methods, status codes, pagination, error handling, versioning, security, and OpenAPI.
1. Why API Design Matters
The Growth of the API Economy
| Metric | Value |
|---|---|
| Global API Economy (2027 projection) | $13.7 Trillion |
| Salesforce Revenue from APIs | Over 50% |
| Average APIs Used per Enterprise | 15,000+ |
| API-first Company Growth Rate | 2.6x vs Traditional |
The Cost of Poor API Design
- Increased developer onboarding time (average 2 weeks extra)
- Higher customer churn (poor DX leads to 30% higher churn)
- Exponentially growing maintenance costs
- Client outages from breaking changes
API-First Design Principles
API-First means designing the API specification before implementing the application.
# Step 1: Write OpenAPI spec first
openapi: 3.1.0
info:
title: User Service API
version: 1.0.0
paths:
/api/v1/users:
get:
summary: List users
parameters:
- name: page
in: query
schema:
type: integer
responses:
'200':
description: Success
Design Workflow:
1. Write API specification (OpenAPI)
2. Review and agree on the spec
3. Start frontend development with mock server
4. Implement backend
5. Contract Testing
2. REST Fundamentals: Richardson Maturity Model
The REST API maturity model proposed by Leonard Richardson consists of 4 levels.
Level 0: The Swamp of POX
All requests are sent to a single endpoint. Similar to SOAP-style services.
POST /api HTTP/1.1
Content-Type: application/json
{
"action": "getUser",
"userId": 123
}
Level 1: Resources
Individual resources are identified through URIs, but HTTP methods are not properly utilized.
POST /api/users/123 HTTP/1.1
Content-Type: application/json
{
"action": "get"
}
Level 2: HTTP Verbs
HTTP methods are used correctly to express operations on resources. Most REST APIs are at this level.
GET /api/users/123 HTTP/1.1
Accept: application/json
---
PUT /api/users/123 HTTP/1.1
Content-Type: application/json
{
"name": "Updated Name",
"email": "user@example.com"
}
Level 3: Hypermedia Controls (HATEOAS)
Responses include links to related resources, enabling clients to dynamically navigate the API.
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"_links": {
"self": { "href": "/api/v1/users/123" },
"orders": { "href": "/api/v1/users/123/orders" },
"update": { "href": "/api/v1/users/123", "method": "PUT" },
"delete": { "href": "/api/v1/users/123", "method": "DELETE" }
}
}
The 6 REST Constraints
| Constraint | Description |
|---|---|
| Client-Server | Separation of concerns between client and server |
| Stateless | Each request is independent, no session stored on server |
| Cacheable | Responses must indicate cacheability |
| Uniform Interface | Consistent interaction interface |
| Layered System | Allows intermediate layers (proxies, load balancers) |
| Code on Demand (optional) | Server can send executable code to client |
3. URL Design: Naming Conventions and Patterns
Core Principles
- Use nouns (not verbs) to represent resources
- Use plural forms such as
/users,/orders,/products - Lowercase with hyphens such as
/user-profiles(not snake_case or camelCase) - Hierarchical structure such as
/users/123/orders/456 - Verbs only for actions such as
/users/123/activate(state change actions)
20 URL Design Examples
# Collections
GET /api/v1/users # List users
POST /api/v1/users # Create user
# Individual Resources
GET /api/v1/users/123 # Get specific user
PUT /api/v1/users/123 # Replace entire user
PATCH /api/v1/users/123 # Partially update user
DELETE /api/v1/users/123 # Delete user
# Nested Resources
GET /api/v1/users/123/orders # List user orders
GET /api/v1/users/123/orders/456 # Get specific order
POST /api/v1/users/123/orders # Create order
# Filtering
GET /api/v1/users?role=admin # Filter by admin role
GET /api/v1/products?category=electronics&min_price=100
# Search
GET /api/v1/users/search?q=john # Search users
# State Changes (actions)
POST /api/v1/users/123/activate # Activate account
POST /api/v1/orders/456/cancel # Cancel order
POST /api/v1/payments/789/refund # Refund payment
# Batch Operations
POST /api/v1/users/batch # Batch create
DELETE /api/v1/users/batch # Batch delete
# Sub-resources (singleton)
GET /api/v1/users/123/profile # User profile
PUT /api/v1/users/123/avatar # Update avatar
Nested vs Flat: When to Use What?
| Pattern | Example | Pros | Cons |
|---|---|---|---|
| Nested | /users/123/orders | Ownership relationship is clear | Complex with deep nesting |
| Flat | /orders?user_id=123 | Independent access possible | Relationship unclear |
| Hybrid | Nest up to 2 levels, then use query params | Balanced approach | - |
Practical Guide: Limit nesting to a maximum of 2 levels.
/users/123/ordersis fine, but/users/123/orders/456/items/789/reviewsis too deep.
4. HTTP Methods Deep Dive
Method Semantics
GET Retrieve resource (read) — Safe, Idempotent
POST Create resource (write) — Unsafe, Non-idempotent
PUT Replace entire resource — Unsafe, Idempotent
PATCH Partially update resource — Unsafe, Non-idempotent (generally)
DELETE Delete resource — Unsafe, Idempotent
OPTIONS Check supported methods — Safe, Idempotent
HEAD Same as GET without body — Safe, Idempotent
Understanding Idempotency
Idempotency means that whether you send the same request 1 time or 100 times, the result (server state) is identical.
| Method | Idempotent | Safe | Description |
|---|---|---|---|
| GET | Yes | Yes | No resource state change |
| HEAD | Yes | Yes | Same as GET, no body |
| OPTIONS | Yes | Yes | Returns metadata only |
| PUT | Yes | No | Overwriting with same data yields same result |
| DELETE | Yes | No | Deleting already-deleted resource is 404 but state unchanged |
| POST | No | No | Creates new resource each time |
| PATCH | No | No | Relative changes can produce different results |
PUT vs PATCH Difference
// PUT: Replace entire resource (all fields required)
// PUT /api/v1/users/123
{
"name": "John Doe",
"email": "john@example.com",
"age": 30,
"role": "admin"
}
// PATCH: Partial update (changed fields only)
// PATCH /api/v1/users/123
{
"age": 31
}
POST vs PUT for Creation
POST /api/v1/users -> Server generates ID (201 Created + Location header)
PUT /api/v1/users/123 -> Client specifies ID (replace if exists, create if not)
5. Status Code Strategy
2xx Success
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | GET success, PUT/PATCH success |
| 201 | Created | Resource created via POST (include Location header) |
| 202 | Accepted | Async operation accepted (processed later) |
| 204 | No Content | DELETE success, PUT success (no body) |
3xx Redirection
| Code | Name | When to Use |
|---|---|---|
| 301 | Moved Permanently | Resource permanently moved (cached) |
| 302 | Found | Temporary redirect |
| 304 | Not Modified | Cache valid (ETag/Last-Modified) |
4xx Client Errors
| Code | Name | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed request, validation failure |
| 401 | Unauthorized | Authentication required (no/expired token) |
| 403 | Forbidden | Authenticated but no permission |
| 404 | Not Found | Resource not found |
| 405 | Method Not Allowed | Method not supported for resource |
| 409 | Conflict | Resource conflict (duplicate creation, etc.) |
| 422 | Unprocessable Entity | Syntactically correct but semantic error |
| 429 | Too Many Requests | Rate limit exceeded |
5xx Server Errors
| Code | Name | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected server error |
| 502 | Bad Gateway | Upstream server error |
| 503 | Service Unavailable | Service temporarily unavailable (maintenance) |
| 504 | Gateway Timeout | Upstream server timeout |
Status Code Decision Flow
Request successful?
├── YES -> Return data? -> 200 OK
│ Resource created? -> 201 Created
│ Async operation? -> 202 Accepted
│ No body needed? -> 204 No Content
└── NO -> Auth issue? -> 401 / 403
Request issue? -> 400 / 422
Resource missing? -> 404
Conflict? -> 409
Server issue? -> 500 / 502 / 503
6. Pagination Patterns
Offset-based Pagination
The simplest approach, but has performance issues with large datasets.
GET /api/v1/users?page=3&size=20
{
"data": [
{ "id": 41, "name": "User 41" },
{ "id": 42, "name": "User 42" }
],
"pagination": {
"page": 3,
"size": 20,
"total_items": 1000,
"total_pages": 50,
"has_next": true,
"has_prev": true
}
}
-- SQL implementation
SELECT * FROM users
ORDER BY id
LIMIT 20 OFFSET 40;
-- Problem: OFFSET 10000 means skipping 10000 rows -> performance degradation
Cursor-based Pagination
Ideal for large datasets. Uses a cursor (last items ID).
GET /api/v1/users?cursor=eyJpZCI6NDJ9&limit=20
{
"data": [
{ "id": 43, "name": "User 43" },
{ "id": 44, "name": "User 44" }
],
"pagination": {
"next_cursor": "eyJpZCI6NjJ9",
"has_next": true,
"limit": 20
}
}
// Node.js implementation
async function getCursorPaginatedUsers(cursor, limit = 20) {
let query = 'SELECT * FROM users'
const params = []
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString())
query += ' WHERE id > $1'
params.push(decoded.id)
}
query += ' ORDER BY id ASC LIMIT $' + (params.length + 1)
params.push(limit + 1) // +1 to check if next page exists
const rows = await db.query(query, params)
const hasNext = rows.length > limit
const data = hasNext ? rows.slice(0, limit) : rows
const nextCursor = hasNext
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64')
: null
return { data, pagination: { next_cursor: nextCursor, has_next: hasNext, limit } }
}
Keyset-based Pagination
Ideal for compound sorting.
GET /api/v1/users?after_date=2025-01-15&after_id=42&limit=20&sort=created_at
-- SQL implementation: created_at + id compound key
SELECT * FROM users
WHERE (created_at, id) > ('2025-01-15', 42)
ORDER BY created_at ASC, id ASC
LIMIT 20;
Pagination Comparison
| Feature | Offset | Cursor | Keyset |
|---|---|---|---|
| Implementation Difficulty | Easy | Medium | Hard |
| Jump to Specific Page | Yes | No | No |
| Large-scale Performance | Poor | Good | Good |
| Consistency on Data Change | Poor | Good | Good |
| Compound Sorting | Yes | Limited | Yes |
| Total Count | Yes | Expensive | Expensive |
| Use Case | Admin panels | Social feeds | Time-series data |
7. Filtering, Sorting, and Field Selection
Filtering Patterns
# Simple filter
GET /api/v1/products?category=electronics&brand=apple
# Comparison operators
GET /api/v1/products?price_gte=100&price_lte=500
# Array filter
GET /api/v1/products?tags=wireless,bluetooth
# Date range
GET /api/v1/orders?created_after=2025-01-01&created_before=2025-03-31
Sorting
# Single sort
GET /api/v1/products?sort=price
# Descending
GET /api/v1/products?sort=-price
# Compound sort
GET /api/v1/products?sort=-created_at,name
Field Selection (Sparse Fieldsets)
# Request only needed fields (reduces response size)
GET /api/v1/users?fields=id,name,email
# Include related resources
GET /api/v1/users/123?include=orders,profile
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
Advanced Filtering: RSQL/FIQL Style
# RQL (Resource Query Language) style
GET /api/v1/products?filter=category==electronics;price=gt=100;brand=in=(apple,samsung)
8. Versioning Strategies
Three Approaches
URL Path Versioning (Most Common)
GET /api/v1/users/123
GET /api/v2/users/123
Header Versioning
GET /api/users/123
Accept: application/vnd.myapp.v2+json
Query Parameter Versioning
GET /api/users/123?version=2
Comparison
| Feature | URL Path | Header | Query Param |
|---|---|---|---|
| Visibility | Very High | Low | Medium |
| Caching | Easy | Complex | Medium |
| Implementation Difficulty | Easy | Medium | Easy |
| Browser Testing | Easy | Hard | Easy |
| API Routing | Clear | Extra logic needed | Extra logic needed |
| Used By | GitHub, Stripe, Google | Microsoft, GitHub (GraphQL) | AWS, Netflix |
Versioning Best Practices
1. Manage only major versions (v1, v2) -- Minor/Patch are backward compatible
2. Support at least 2 versions simultaneously
3. Publish deprecation policy (minimum 6-month grace period)
4. Use Sunset header
Sunset: Sat, 01 Mar 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v2/users>; rel="successor-version"
9. Error Handling: RFC 9457 Problem Details
RFC 9457 Standard Error Format
RFC 9457, which replaced RFC 7807 in 2023, defines the standard error response format for HTTP APIs.
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/api/v1/users",
"timestamp": "2025-03-15T10:30:00Z",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Please enter a valid email address."
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be between 0 and 150."
}
]
}
Error Response Implementation (Node.js/Express)
// Error class definition
class ApiError extends Error {
constructor(status, type, title, detail, errors = []) {
super(detail)
this.status = status
this.type = type
this.title = title
this.detail = detail
this.errors = errors
}
}
// Factory functions
const ApiErrors = {
notFound: (resource, id) =>
new ApiError(
404,
'https://api.example.com/errors/not-found',
'Resource Not Found',
`${resource} with id ${id} was not found.`
),
validationFailed: (errors) =>
new ApiError(
422,
'https://api.example.com/errors/validation',
'Validation Failed',
'The request body contains invalid fields.',
errors
),
rateLimited: (retryAfter) =>
new ApiError(
429,
'https://api.example.com/errors/rate-limit',
'Rate Limit Exceeded',
`Too many requests. Retry after ${retryAfter} seconds.`
),
}
// Express error handler
app.use((err, req, res, next) => {
if (err instanceof ApiError) {
return res.status(err.status).json({
type: err.type,
title: err.title,
status: err.status,
detail: err.detail,
instance: req.originalUrl,
timestamp: new Date().toISOString(),
errors: err.errors.length > 0 ? err.errors : undefined,
})
}
// Unexpected error
console.error('Unexpected error:', err)
res.status(500).json({
type: 'https://api.example.com/errors/internal',
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred.',
instance: req.originalUrl,
timestamp: new Date().toISOString(),
})
})
Internationalized Error Messages
// i18n error messages
const errorMessages = {
INVALID_EMAIL: {
ko: '유효한 이메일 주소를 입력하세요.',
en: 'Please enter a valid email address.',
ja: '有効なメールアドレスを入力してください。',
},
FIELD_REQUIRED: {
ko: '필수 입력 항목입니다.',
en: 'This field is required.',
ja: '必須入力項目です。',
},
}
function getErrorMessage(code, lang = 'en') {
return errorMessages[code]?.[lang] || errorMessages[code]?.['en'] || code
}
10. Rate Limiting and Throttling
Algorithm Comparison
Token Bucket
Bucket capacity: 100 tokens
Refill rate: 10 tokens/sec
[Request arrives] -> Tokens available? -> YES -> Consume token, process request
-> NO -> 429 response
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity
this.tokens = capacity
this.refillRate = refillRate
this.lastRefill = Date.now()
}
consume() {
this.refill()
if (this.tokens > 0) {
this.tokens--
return true
}
return false
}
refill() {
const now = Date.now()
const elapsed = (now - this.lastRefill) / 1000
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate)
this.lastRefill = now
}
}
Sliding Window Log
class SlidingWindowLog {
constructor(windowMs, maxRequests) {
this.windowMs = windowMs
this.maxRequests = maxRequests
this.logs = new Map() // key -> timestamps[]
}
isAllowed(key) {
const now = Date.now()
const windowStart = now - this.windowMs
if (!this.logs.has(key)) {
this.logs.set(key, [])
}
const timestamps = this.logs.get(key).filter((ts) => ts > windowStart)
this.logs.set(key, timestamps)
if (timestamps.length < this.maxRequests) {
timestamps.push(now)
return true
}
return false
}
}
Rate Limit Response Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1678886400
---
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1678886400
Retry-After: 30
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/rate-limit",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the rate limit. Please retry after 30 seconds."
}
Rate Limit Tier Design
| Tier | Requests/min | Requests/day | Concurrent Connections |
|---|---|---|---|
| Free | 60 | 1,000 | 5 |
| Basic | 300 | 10,000 | 20 |
| Pro | 1,000 | 100,000 | 50 |
| Enterprise | 10,000 | 1,000,000 | 200 |
11. Security
OAuth2 Flow Summary
Authorization Code Flow (Server Apps):
1. User -> Auth Server (Login)
2. Auth Server -> Client (Authorization Code)
3. Client -> Auth Server (Code + Client Secret -> Access Token)
4. Client -> Resource Server (Access Token)
Authorization Code + PKCE (SPA/Mobile):
1. Generate code_verifier
2. code_challenge = SHA256(code_verifier)
3. Include code_challenge in authorization request
4. Send code_verifier during token exchange for verification
JWT Best Practices
1. Access Token expiry: 15 minutes (max 1 hour)
2. Refresh Token expiry: 7 days (max 30 days)
3. Signing algorithm: RS256 (asymmetric) or ES256 recommended
4. Never put sensitive info in payload
5. Always validate aud, iss, exp claims
6. Store in httpOnly cookies (prevent XSS)
7. Apply Token Rotation
API Key Management
# Header approach (recommended)
GET /api/v1/users HTTP/1.1
Authorization: Bearer <access_token>
X-API-Key: <api_key>
# Never include in URL
# BAD: /api/v1/users?api_key=abc123
CORS Configuration
// Express CORS setup
const cors = require('cors')
app.use(
cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
credentials: true,
maxAge: 86400, // Preflight cache 24 hours
})
)
Input Validation
// Request validation with Zod
const { z } = require('zod')
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
})
// Middleware
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(422).json({
type: 'https://api.example.com/errors/validation',
title: 'Validation Failed',
status: 422,
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: issue.code,
message: issue.message,
})),
})
}
req.body = result.data
next()
}
}
app.post('/api/v1/users', validate(CreateUserSchema), createUser)
12. OpenAPI 3.1
Spec Structure
openapi: 3.1.0
info:
title: E-Commerce API
description: E-Commerce REST API
version: 1.0.0
contact:
name: API Team
email: api@example.com
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/products:
get:
operationId: listProducts
tags: [Products]
summary: List all products
parameters:
- name: category
in: query
schema:
type: string
- name: sort
in: query
schema:
type: string
enum: [price, -price, name, -name, created_at]
- name: cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Product'
pagination:
$ref: '#/components/schemas/CursorPagination'
'429':
$ref: '#/components/responses/RateLimited'
components:
schemas:
Product:
type: object
required: [id, name, price]
properties:
id:
type: integer
format: int64
name:
type: string
minLength: 1
maxLength: 200
price:
type: number
format: decimal
minimum: 0
category:
type: string
created_at:
type: string
format: date-time
CursorPagination:
type: object
properties:
next_cursor:
type: string
nullable: true
has_next:
type: boolean
limit:
type: integer
responses:
RateLimited:
description: Rate limit exceeded
headers:
X-RateLimit-Limit:
schema:
type: integer
Retry-After:
schema:
type: integer
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
security:
- BearerAuth: []
- ApiKeyAuth: []
Contract-First vs Code-First
| Approach | Pros | Cons |
|---|---|---|
| Contract-First | Spec-based agreement, instant mock server, code generation | Additional spec maintenance cost |
| Code-First | Fast development, code is source of truth | Spec-code drift possible |
Code Generation
# Generate client SDK with OpenAPI Generator
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./generated/client
# Generate server stub
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g nodejs-express-server \
-o ./generated/server
13. API Testing
Automated Testing with Postman/Newman
// Postman Test Script example
pm.test('Status code is 200', () => {
pm.response.to.have.status(200)
})
pm.test('Response has correct structure', () => {
const json = pm.response.json()
pm.expect(json).to.have.property('data')
pm.expect(json.data).to.be.an('array')
pm.expect(json).to.have.property('pagination')
})
pm.test('Pagination cursor exists', () => {
const json = pm.response.json()
if (json.data.length > 0) {
pm.expect(json.pagination).to.have.property('next_cursor')
}
})
# Run in CI/CD pipeline with Newman
newman run collection.json \
--environment production.json \
--reporters cli,junit \
--reporter-junit-export results.xml
Contract Testing with Pact
// Consumer Test (Frontend)
const { PactV3 } = require('@pact-foundation/pact')
const provider = new PactV3({
consumer: 'WebApp',
provider: 'UserService',
})
describe('User API', () => {
it('fetches a user by ID', async () => {
await provider
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/api/v1/users/123',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 123,
name: 'John Doe',
email: 'john@example.com',
},
})
.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/api/v1/users/123`, {
headers: { Accept: 'application/json' },
})
expect(response.status).toBe(200)
})
})
})
Load Testing with k6
// k6 load test
import http from 'k6/http'
import { check, sleep } from 'k6'
export const options = {
stages: [
{ duration: '30s', target: 50 }, // Ramp up
{ duration: '1m', target: 100 }, // Sustain
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Error rate under 1%
},
}
export default function () {
const res = http.get('https://api.example.com/v1/products?limit=20')
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has data array': (r) => JSON.parse(r.body).data.length > 0,
})
sleep(1)
}
14. Real-World Examples: Leading API Design Patterns
Stripe API
Stripe provides industry-leading API DX.
Stripe Core Design Principles:
1. Consistent resource naming (/v1/customers, /v1/charges, /v1/subscriptions)
2. Expand parameter for including related resources
3. Idempotency keys (Idempotency-Key header)
4. Detailed error responses
5. Auto-pagination (has_more + starting_after)
# Stripe-style request
POST /v1/charges HTTP/1.1
Authorization: Bearer sk_test_xxx
Idempotency-Key: unique-request-id-123
Content-Type: application/x-www-form-urlencoded
amount=2000¤cy=usd&source=tok_visa
GitHub API
GitHub API Features:
1. REST + GraphQL hybrid
2. Conditional requests (ETag, If-None-Match)
3. Link header pagination
4. Hypermedia (URL templates)
5. Clear Rate Limit display
# GitHub conditional request
GET /repos/octocat/hello-world HTTP/1.1
If-None-Match: "etag-value"
# 304 Not Modified (use cache)
# or 200 OK (with new ETag)
Twitter(X) API v2
Twitter API v2 Features:
1. Field selection (fields parameter)
2. Expansions for related objects
3. Streaming endpoints
4. Granular Rate Limits
5. OAuth2 + App-only Auth
# Twitter-style: field selection + expansion
GET /2/tweets?ids=123,456&tweet.fields=created_at,public_metrics&expansions=author_id&user.fields=username
15. Quiz
Q1. What is the main difference between PUT and PATCH?
PUT replaces the entire resource (all fields required), while PATCH modifies only specific fields of a resource. PUT guarantees idempotency, but PATCH may not be idempotent in cases of relative changes (e.g., incrementing a counter).
Q2. What does Richardson Maturity Model Level 3 mean?
Level 3 represents HATEOAS (Hypermedia as the Engine of Application State). API responses include links to related resources, allowing clients to dynamically navigate the API without hardcoded URLs. It is the final stage of true REST.
Q3. What are the problems with Offset pagination and what are the alternatives?
Offset pagination degrades in performance as the OFFSET grows larger because the database must skip more rows. Also, when data is added or deleted, duplicates or gaps can occur. The alternative is Cursor-based pagination, which uses the last items ID as a cursor and queries efficiently using WHERE id conditions.
Q4. What is the difference between HTTP 401 and 403?
401 Unauthorized means authentication is required (no token or expired token). 403 Forbidden means the request is authenticated but the user lacks permission for that resource. In other words, 401 is "Who are you?" and 403 is "I know who you are, but you cannot access this."
Q5. What are the pros and cons of URL Path versioning?
Pros: High visibility, easy caching, browser-testable, clear routing. Cons: URLs become longer, all clients must update URLs on version change, and multiple URLs exist for the same resource.
Q6. What are the key fields in RFC 9457 Problem Details?
The core fields of RFC 9457 are type (error type URI), title (human-readable error summary), status (HTTP status code), detail (specific description), and instance (URI where the problem occurred). type and title are effectively required, while others are optional but recommended.
Q7. What is the difference between Token Bucket and Sliding Window algorithms?
Token Bucket replenishes tokens at a constant rate and consumes them per request. It allows burst traffic. Sliding Window tracks the number of requests within a time window, removing old requests as the window moves. It is more precise but uses more memory.
References
- RFC 9110: HTTP Semantics
- RFC 9457: Problem Details for HTTP APIs
- RFC 6749: OAuth 2.0 Authorization Framework
- OpenAPI Specification 3.1.0
- Roy Fielding's REST Dissertation (2000)
- Stripe API Reference
- GitHub REST API Documentation
- Google API Design Guide
- Microsoft REST API Guidelines
- JSON:API Specification
Conclusion
REST API design carries significance beyond just technical decisions. A well-designed API enhances developer experience, facilitates system integration, and creates business value.
Key takeaways:
- Resource-centric URL design with nouns, plural forms, and maximum 2-level nesting
- Correct HTTP methods with understanding of semantics and idempotency
- Accurate status codes so clients can handle errors correctly
- Cursor pagination which is essential for large datasets
- RFC 9457 error format for consistent and useful error responses
- URL Path versioning as the most practical choice
- Rate Limiting to ensure API stability and fair usage
- OpenAPI 3.1 for specification automation and code generation
Apply these principles to build APIs that developers love.