Skip to content
API Design Principles — RESTful Resource Naming, Versioning, Pagination, and Error Handling

API Design Principles — RESTful Resource Naming, Versioning, Pagination, and Error Handling

DodaTech Updated Jun 15, 2026 6 min read

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 category

Bad 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 noun

HTTP Methods and Idempotency

MethodPurposeIdempotentSafeBody
GETRetrieve resourceYesYesNo
POSTCreate resourceNoNoYes
PUTReplace resourceYesNoYes
PATCHPartial updateNoNoYes
DELETERemove resourceYesNoMaybe

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/users

Header versioning:

Accept: application/vnd.myapi.v1+json

Never 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 user

Expected 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

  1. Using verbs in URLs: getUsers, createOrder → use HTTP methods on nouns instead.

  2. Inconsistent error format: Some errors return {error: "msg"}, others return {message: "msg"}. Document and enforce one format.

  3. No pagination defaults: Returning 100,000 records in one response crashes clients. Always paginate with sensible defaults (20-50 per page).

  4. Not returning standard HTTP status codes: Returning 200 for everything with {success: false} instead of using 400/404/500 correctly.

  5. Exposing internal IDs: Using auto-increment IDs in URLs lets users guess other resource IDs. Use UUIDs or hashids.

Practice Questions

  1. 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.

  2. Why should you use plural nouns for resource names? Consistency. GET /users reads as “get all users,” GET /users/5 reads as “get user 5.” Plural names work universally with all HTTP methods.

  3. 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.

  4. 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).

  5. What status code should you return for a rate-limited request? 429 Too Many Requests, with a Retry-After header 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

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro