API Design Principles — RESTful Resource Naming, Versioning, Pagination, and Error Handling
API design is the practice of defining consistent, predictable, and developer-friendly interfaces for services to communicate, following RESTful principles that make APIs intuitive to use and maintain.
Why API Design Matters
Your API is the contract between your service and every client that uses it. A poorly designed API creates confusion, bugs, and support requests. A well-designed API is intuitive — developers can guess endpoints and get the expected result. Stripe’s API is famous for its excellent design, contributing to its developer adoption. At companies like Twilio and GitHub, the API is the product. Getting it wrong means your API never gets adopted.
Plain-Language Explanation
Think of an API like a restaurant menu. The menu organizes dishes by category (appetizers, mains, desserts) — that’s resource naming. Each dish has a price and description — that’s the response format. If the restaurant changes its menu every season, it prints a new menu but keeps the old one available for regulars — that’s versioning.
RESTful APIs organize resources (users, orders, products) as nouns and use HTTP methods as verbs. You don’t write createUser — you send a POST to /users. You don’t write deleteOrder?id=5 — you send DELETE to /orders/5. This consistency means any developer familiar with REST can use your API immediately.
graph TD
Client[Client App] --> API[API Gateway]
API --> Users[/api/v1/users]
API --> Orders[/api/v1/orders]
API --> Products[/api/v1/products]
Users --> GET[GET /users - List]
Users --> POST[POST /users - Create]
Users --> GETID[GET /users/:id - Read]
Users --> PUT[PUT /users/:id - Update]
Users --> DEL[DELETE /users/:id - Delete]
style API fill:#3498db,color:#fff
style Users fill:#27ae60,color:#fff
style Orders fill:#e67e22,color:#fff
style Products fill:#9b59b6,color:#fff
Resource Naming Conventions
Use plural nouns for resources, nested routes for sub-resources, and consistent casing throughout.
/users → List all users
/users/42 → Get user 42
/users/42/orders → Get user 42's orders
/users/42/orders/5 → Get order 5 for user 42
/products?category=books → Filter products by categoryBad examples:
/getAllUsers ← Verb in URL
/updateUser?id=42 ← Verb, query param for ID
/api/v1/get-user/42 ← Inconsistent naming
/users/list/ ← Verb disguised as nounHTTP Methods and Idempotency
| Method | Purpose | Idempotent | Safe | Body |
|---|---|---|---|---|
| GET | Retrieve resource | Yes | Yes | No |
| POST | Create resource | No | No | Yes |
| PUT | Replace resource | Yes | No | Yes |
| PATCH | Partial update | No | No | Yes |
| DELETE | Remove resource | Yes | No | Maybe |
Idempotency means calling the same request multiple times produces the same result. PUT /users/42 with the same body always results in the same state. POST /users creates a new user each time — not idempotent.
Versioning
APIs evolve. Versioning lets you make breaking changes without breaking existing clients.
URL path versioning (most common):
/api/v1/users
/api/v2/usersHeader versioning:
Accept: application/vnd.myapi.v1+jsonNever version by query parameter (/api/users?version=1) — it pollutes URLs and is easily forgotten by clients.
Pagination
Lists should always be paginated. Use cursor-based pagination for consistency when data changes frequently.
from fastapi import FastAPI, Query
from typing import Optional
app = FastAPI()
ITEMS = [{"id": i, "name": f"Item {i}"} for i in range(1000)]
@app.get("/api/v1/items")
def list_items(
cursor: Optional[int] = None,
limit: int = Query(default=20, le=100)
):
start = cursor or 0
items = ITEMS[start:start + limit]
next_cursor = start + limit if start + limit < len(ITEMS) else None
return {
"data": items,
"pagination": {
"next_cursor": next_cursor,
"limit": limit,
"total": len(ITEMS),
}
}Expected response:
{
"data": [{"id": 0, "name": "Item 0"}, ...],
"pagination": {"next_cursor": 20, "limit": 20, "total": 1000}
}Error Response Format
Consistent error responses let clients handle errors programmatically.
from fastapi import HTTPException
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error: str
code: str
details: dict = {}
request_id: str
@app.exception_handler(HTTPException)
def handle_http_error(request, exc):
return {
"error": exc.detail,
"code": f"ERR_{exc.status_code}",
"request_id": request.headers.get("X-Request-ID", ""),
}
@app.get("/api/v1/users/{user_id}")
def get_user(user_id: int):
if user_id <= 0:
raise HTTPException(status_code=400, detail="Invalid user ID")
# ... fetch userExpected error response:
{
"error": "Invalid user ID",
"code": "ERR_400",
"request_id": "abc-123-def",
"details": {}
}Idempotency Keys for POST
For operations that shouldn’t duplicate (payment processing), clients send an idempotency key:
import hashlib
processed_requests = set()
@app.post("/api/v1/payments")
def create_payment(request: Request):
idempotency_key = request.headers.get("Idempotency-Key")
if not idempotency_key:
raise HTTPException(400, "Idempotency-Key header required")
if idempotency_key in processed_requests:
return {"status": "already_processed", "existing_result": ...}
processed_requests.add(idempotency_key)
# Process payment...
return {"status": "success", "payment_id": "pay_123"}Common Mistakes
Using verbs in URLs:
getUsers,createOrder→ use HTTP methods on nouns instead.Inconsistent error format: Some errors return
{error: "msg"}, others return{message: "msg"}. Document and enforce one format.No pagination defaults: Returning 100,000 records in one response crashes clients. Always paginate with sensible defaults (20-50 per page).
Not returning standard HTTP status codes: Returning 200 for everything with
{success: false}instead of using 400/404/500 correctly.Exposing internal IDs: Using auto-increment IDs in URLs lets users guess other resource IDs. Use UUIDs or hashids.
Practice Questions
What is idempotency and why does it matter in API design? An idempotent operation produces the same result regardless of how many times it’s executed. Crucial for retry logic — clients can safely retry PUT/DELETE without side effects.
Why should you use plural nouns for resource names? Consistency.
GET /usersreads as “get all users,”GET /users/5reads as “get user 5.” Plural names work universally with all HTTP methods.What is the difference between PUT and PATCH? PUT replaces the entire resource. PATCH applies a partial update. PUT is idempotent; PATCH is not necessarily.
How do you handle breaking changes in an API? Version the API through URL path (
/api/v2/) or headers. Maintain backward compatibility for a deprecation period (6-12 months).What status code should you return for a rate-limited request? 429 Too Many Requests, with a
Retry-Afterheader indicating when the client can retry.
Mini Project
Design and implement a /api/v1/tasks REST API:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import uuid4
app = FastAPI(title="Task API", version="1.0")
class TaskCreate(BaseModel):
title: str
description: str = ""
class Task(BaseModel):
id: str
title: str
description: str
completed: bool = False
tasks: dict[str, Task] = {}
@app.post("/api/v1/tasks", status_code=201)
def create_task(task: TaskCreate):
new_task = Task(id=uuid4().hex[:8], **task.model_dump())
tasks[new_task.id] = new_task
return new_task
@app.get("/api/v1/tasks")
def list_tasks(completed: bool | None = None):
result = list(tasks.values())
if completed is not None:
result = [t for t in result if t.completed == completed]
return {"data": result, "total": len(result)}
@app.get("/api/v1/tasks/{task_id}")
def get_task(task_id: str):
task = tasks.get(task_id)
if not task:
raise HTTPException(404, "Task not found")
return task
@app.delete("/api/v1/tasks/{task_id}", status_code=204)
def delete_task(task_id: str):
tasks.pop(task_id, None)
# Test the API
import httpx
with httpx.Client(app=app) as client:
r = client.post("/api/v1/tasks", json={"title": "Learn API design"})
print(r.status_code, r.json())
r = client.get("/api/v1/tasks")
print(r.status_code, r.json())Expected output:
201 {'id': 'a1b2c3d4', 'title': 'Learn API design', 'description': '', 'completed': False}
200 {'data': [...], 'total': 1}Cross-References
- Rate Limiting
- Microservices Patterns
- System Design Overview
- Event-Driven Architecture
- Caching
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro