SSourav Saha
HomeExperienceSoftware DesignSystem DesignLearningBooksToolsContact
SSourav Saha

Building scalable backend systems, distributed infrastructure, and cloud-native applications.

Navigation

  • Home
  • Experience
  • Software Design
  • System Design
  • Learning

More

  • Books
  • Tools
  • Contact

Connect

  • LinkedIn
  • Email

© 2026 Sourav Saha. All rights reserved.

Built with using Next.js

System DesignREST API Design
System Designbeginner

REST API Design

The universal interface for distributed systems — learn how REST constrains HTTP into a predictable, scalable API pattern, with a from-scratch implementation of a RESTful service.

January 2, 202417 min read
restapi-designhttpfundamentalsbuilding-blocktier-0

Metadata

FieldValue
Slugrest-api-design
DifficultyBeginner
Estimated Reading Time10 min
Estimated Coding Time15 min
Tier0 — Networking Fundamentals
Implementation LanguagePython
SEO DescriptionMaster REST API design principles — resource modeling, HTTP method semantics, status codes, versioning, pagination, and idempotency — with a complete Python implementation.

1. Overview

What problem does it solve?

HTTP gives you raw request-response communication. But it doesn't tell you how to structure your API. Without conventions, every API becomes a snowflake:

# Without REST — chaos
POST /createUser
GET /fetchAllUsers
POST /deleteUserById
GET /getUserOrders?userId=42
POST /updateUserAddress

REST (Representational State Transfer) imposes constraints that make APIs predictable, cacheable, and evolvable:

# With REST — uniform interface
POST   /users              → Create user
GET    /users              → List users
GET    /users/42           → Get user 42
PUT    /users/42           → Replace user 42
PATCH  /users/42           → Update user 42
DELETE /users/42           → Delete user 42
GET    /users/42/orders    → Get user 42's orders

What breaks without it?

  • API consumers guess how to interact with your service
  • Caching breaks (cache servers don't know which responses are safe to cache)
  • Load balancers can't make routing decisions based on method semantics
  • Client libraries become tightly coupled to server implementation
  • API versioning and evolution become nightmares

2. Motivation

Why was REST invented?

In 2000, Roy Fielding (co-author of HTTP/1.1) defined REST in his PhD dissertation. He observed that the web already worked at massive scale — billions of documents, millions of servers, zero central coordination. He extracted six architectural constraints that made this possible and formalized them as REST.

The key insight: if you model your API as operations on resources (nouns) instead of remote procedure calls (verbs), you inherit all of HTTP's infrastructure — caches, proxies, load balancers, browser support — for free.

The six REST constraints

#ConstraintWhat it meansWhy it matters
1Client-ServerClient and server evolve independentlyDeploy backend without updating apps
2StatelessEach request contains all info neededAny server can handle any request → horizontal scaling
3CacheableResponses declare if they're cacheableReduce server load, improve latency
4Uniform InterfaceResources identified by URLs; manipulated via representationsPredictable API shape
5Layered SystemClient doesn't know if it's talking to origin or intermediaryCDNs, load balancers, API gateways work transparently
6Code-on-Demand (optional)Server can send executable codeJavaScript in browsers

3. Real-World Usage

SystemREST API Example
TinyURLPOST /urls to create, GET /urls/:code to redirect
Twitter/XGET /2/tweets/:id, POST /2/tweets
GitHubGET /repos/:owner/:repo/pulls
StripePOST /v1/charges, GET /v1/customers/:id
UberPOST /v1.2/requests (request a ride)
InstagramGET /me/media (user's photos)
KubernetesGET /api/v1/namespaces/default/pods
Google DrivePOST /upload/drive/v3/files
DropboxPOST /2/files/upload

4. Prerequisites

ConceptBlock
HTTP methods, status codes, headers001 HTTP & TCP/IP Fundamentals

5. Visual Explanation

REST Resource Model

                    /api/v1
                       │
              ┌────────┼────────┐
              │        │        │
           /users   /orders   /products
              │        │        │
         ┌────┼────┐   │   ┌───┼───┐
         │    │    │   │   │       │
      /users /users /users │  /products /products
       /:id  /:id   /:id  │   /:id      /:id
             │      │     │             │
          /orders /addresses          /reviews

Request Flow Through a REST API

sequenceDiagram
    participant C as Client
    participant GW as API Gateway
    participant LB as Load Balancer
    participant S1 as Server 1
    participant DB as Database

    C->>GW: POST /api/v1/users<br/>Authorization: Bearer token<br/>{"name": "Sourav", "email": "..."}
    GW->>GW: Authenticate token
    GW->>GW: Rate limit check
    GW->>LB: Forward request
    LB->>S1: Route to server

    S1->>S1: Validate request body
    S1->>DB: INSERT INTO users
    DB-->>S1: Row created (id=42)

    S1-->>LB: 201 Created<br/>Location: /api/v1/users/42<br/>{"id": 42, "name": "Sourav"}
    LB-->>GW: Forward response
    GW-->>C: 201 Created

HTTP Method Semantics Decision Tree

  Need to do something with a resource?
            │
  ┌─────────┼──────────────────┐
  │         │                  │
 READ    WRITE             DELETE
  │         │                  │
 GET    ┌───┼───┐          DELETE
        │       │
    CREATE  UPDATE
        │       │
      POST  ┌───┼───┐
            │       │
         REPLACE  PARTIAL
            │       │
           PUT    PATCH

6. Internal Working

6.1 Resource Design

A resource is any concept you can name — a user, an order, a photo, a transaction. Each resource gets a unique URL (Uniform Resource Locator).

Rules for URL design:

✅ Use nouns, not verbs:      /users, /orders, /photos
❌ Don't use verbs:           /getUsers, /createOrder, /deletePhoto

✅ Use plural nouns:          /users/42 (user 42 from the users collection)
❌ Don't use singular:        /user/42

✅ Use hierarchy for nesting: /users/42/orders (orders belonging to user 42)
❌ Don't flatten everything:  /getUserOrders?userId=42

✅ Use query params for filtering: /users?role=admin&sort=created_at
❌ Don't encode filters in path:   /users/role/admin/sort/created_at

6.2 Method-to-Operation Mapping

OperationHTTP MethodURLRequest BodyResponseIdempotent?
CreatePOST/users{"name": "Sourav"}201 + Location header❌
ListGET/users—200 + array✅
ReadGET/users/42—200 + object✅
ReplacePUT/users/42Full object200 + updated object✅
Partial UpdatePATCH/users/42Partial object200 + updated object❌
DeleteDELETE/users/42—204 No Content✅

6.3 Pagination

For large collections, never return everything. Three common patterns:

# Offset-based (simple but slow on large datasets)
GET /users?offset=100&limit=25

# Cursor-based (fast, used by Twitter, Facebook)
GET /users?cursor=eyJ0IjoxNjk...&limit=25

# Page-based (simple, used by GitHub)
GET /users?page=5&per_page=25

6.4 Versioning

# URL versioning (most common, explicit)
GET /api/v1/users
GET /api/v2/users

# Header versioning (cleaner URLs but harder to test)
GET /api/users
Accept: application/vnd.myapp.v2+json

# Query param versioning
GET /api/users?version=2

6.5 Error Responses

Always return structured errors:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      {
        "field": "email",
        "constraint": "required",
        "message": "The email field must not be empty"
      }
    ]
  },
  "request_id": "req_abc123"
}

7. Implementation

Why Python? Builds directly on the Block 001 HTTP server — you can see REST as a layer of conventions on top of raw HTTP, not a separate technology.

"""
002 - RESTful API Server from Scratch
Builds on 001-HTTP to add REST conventions:
- Resource-based routing
- Proper HTTP method handling
- JSON request/response
- Status codes and Location headers
- Pagination
"""
import socket
import threading
import json
from datetime import datetime, timezone
from typing import Optional


# ── In-Memory Database ──

users_db: dict[int, dict] = {
    1: {"id": 1, "name": "Sourav", "email": "sourav@example.com", "role": "admin"},
    2: {"id": 2, "name": "Alice", "email": "alice@example.com", "role": "user"},
    3: {"id": 3, "name": "Bob", "email": "bob@example.com", "role": "user"},
}
next_id = 4


# ── HTTP Parsing (reused from Block 001) ──

def parse_request(raw: bytes) -> dict:
    text = raw.decode("utf-8", errors="replace")
    lines = text.split("\r\n")
    parts = lines[0].split(" ")
    method, path = parts[0], parts[1] if len(parts) > 1 else "/"

    headers = {}
    body_start = 0
    for i, line in enumerate(lines[1:], 1):
        if line == "":
            body_start = i + 1
            break
        if ":" in line:
            k, v = line.split(":", 1)
            headers[k.strip().lower()] = v.strip()

    body = "\r\n".join(lines[body_start:]).strip()

    # Parse query string
    query = {}
    if "?" in path:
        path, qs = path.split("?", 1)
        for param in qs.split("&"):
            if "=" in param:
                k, v = param.split("=", 1)
                query[k] = v

    return {"method": method, "path": path, "headers": headers,
            "body": body, "query": query}


def json_response(status: int, body: dict, headers: Optional[dict] = None) -> bytes:
    reasons = {200: "OK", 201: "Created", 204: "No Content",
               400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed"}
    body_bytes = json.dumps(body, indent=2).encode("utf-8") if status != 204 else b""
    now = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")

    resp_headers = {
        "Content-Type": "application/json",
        "Content-Length": str(len(body_bytes)),
        "Date": now,
        "Connection": "close",
    }
    if headers:
        resp_headers.update(headers)

    header_str = "\r\n".join(f"{k}: {v}" for k, v in resp_headers.items())
    return f"HTTP/1.1 {status} {reasons.get(status, 'Unknown')}\r\n{header_str}\r\n\r\n".encode() + body_bytes


# ── REST Router ──

def route(req: dict) -> bytes:
    global next_id
    method, path, query = req["method"], req["path"], req["query"]

    # ── GET /users — List (with pagination) ──
    if method == "GET" and path == "/users":
        page = int(query.get("page", "1"))
        per_page = int(query.get("per_page", "10"))
        all_users = list(users_db.values())

        # Filter by role if provided
        role = query.get("role")
        if role:
            all_users = [u for u in all_users if u["role"] == role]

        start = (page - 1) * per_page
        end = start + per_page
        page_users = all_users[start:end]

        return json_response(200, {
            "data": page_users,
            "pagination": {
                "page": page, "per_page": per_page,
                "total": len(all_users),
                "total_pages": max(1, -(-len(all_users) // per_page)),
            }
        })

    # ── GET /users/:id — Read ──
    if method == "GET" and path.startswith("/users/"):
        user_id = int(path.split("/")[2])
        user = users_db.get(user_id)
        if not user:
            return json_response(404, {"error": {"code": "NOT_FOUND", "message": f"User {user_id} not found"}})
        return json_response(200, {"data": user})

    # ── POST /users — Create ──
    if method == "POST" and path == "/users":
        try:
            body = json.loads(req["body"]) if req["body"] else {}
        except json.JSONDecodeError:
            return json_response(400, {"error": {"code": "INVALID_JSON", "message": "Request body is not valid JSON"}})

        if "name" not in body or "email" not in body:
            return json_response(400, {"error": {"code": "VALIDATION_ERROR", "message": "name and email are required"}})

        user = {"id": next_id, "name": body["name"], "email": body["email"], "role": body.get("role", "user")}
        users_db[next_id] = user
        next_id += 1

        return json_response(201, {"data": user}, {"Location": f"/users/{user['id']}"})

    # ── PUT /users/:id — Replace ──
    if method == "PUT" and path.startswith("/users/"):
        user_id = int(path.split("/")[2])
        if user_id not in users_db:
            return json_response(404, {"error": {"code": "NOT_FOUND", "message": f"User {user_id} not found"}})

        try:
            body = json.loads(req["body"]) if req["body"] else {}
        except json.JSONDecodeError:
            return json_response(400, {"error": {"code": "INVALID_JSON", "message": "Invalid JSON"}})

        users_db[user_id] = {"id": user_id, "name": body.get("name", ""), "email": body.get("email", ""), "role": body.get("role", "user")}
        return json_response(200, {"data": users_db[user_id]})

    # ── DELETE /users/:id — Delete ──
    if method == "DELETE" and path.startswith("/users/"):
        user_id = int(path.split("/")[2])
        if user_id not in users_db:
            return json_response(404, {"error": {"code": "NOT_FOUND", "message": f"User {user_id} not found"}})
        del users_db[user_id]
        return json_response(204, {})

    # ── Method not allowed ──
    if path.startswith("/users"):
        return json_response(405, {"error": {"code": "METHOD_NOT_ALLOWED", "message": f"{method} not supported on {path}"}})

    return json_response(404, {"error": {"code": "NOT_FOUND", "message": f"No resource at {path}"}})


# ── Server (from Block 001) ──

def handle_client(sock: socket.socket, addr: tuple):
    try:
        data = sock.recv(8192)
        if not data:
            return
        req = parse_request(data)
        print(f"[{addr[0]}] {req['method']} {req['path']}")
        sock.sendall(route(req))
    except Exception as e:
        sock.sendall(json_response(500, {"error": {"code": "INTERNAL_ERROR", "message": str(e)}}))
    finally:
        sock.close()


def main():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("127.0.0.1", 8080))
    server.listen(128)
    print("REST API on http://127.0.0.1:8080")
    print("Try:")
    print("  curl http://127.0.0.1:8080/users")
    print("  curl http://127.0.0.1:8080/users/1")
    print('  curl -X POST http://127.0.0.1:8080/users -d \'{"name":"Eve","email":"eve@x.com"}\'')
    print("  curl -X DELETE http://127.0.0.1:8080/users/1")
    print("  curl http://127.0.0.1:8080/users?role=admin")

    try:
        while True:
            client, addr = server.accept()
            threading.Thread(target=handle_client, args=(client, addr), daemon=True).start()
    except KeyboardInterrupt:
        print("\nShutting down.")
    finally:
        server.close()


if __name__ == "__main__":
    main()

Sample Output

$ curl http://127.0.0.1:8080/users | python -m json.tool
{
  "data": [
    {"id": 1, "name": "Sourav", "email": "sourav@example.com", "role": "admin"},
    {"id": 2, "name": "Alice", "email": "alice@example.com", "role": "user"},
    {"id": 3, "name": "Bob", "email": "bob@example.com", "role": "user"}
  ],
  "pagination": {"page": 1, "per_page": 10, "total": 3, "total_pages": 1}
}

$ curl -X POST http://127.0.0.1:8080/users \
    -d '{"name": "Eve", "email": "eve@example.com"}' | python -m json.tool
{
  "data": {"id": 4, "name": "Eve", "email": "eve@example.com", "role": "user"}
}
# Response includes: Location: /users/4

$ curl http://127.0.0.1:8080/users?role=admin | python -m json.tool
{
  "data": [
    {"id": 1, "name": "Sourav", "email": "sourav@example.com", "role": "admin"}
  ],
  "pagination": {"page": 1, "per_page": 10, "total": 1, "total_pages": 1}
}

$ curl -X DELETE http://127.0.0.1:8080/users/1
# 204 No Content

$ curl http://127.0.0.1:8080/users/999
{"error": {"code": "NOT_FOUND", "message": "User 999 not found"}}

8. Complexity

MetricValueNotes
Route matchingO(n) per requestn = number of routes. Production routers use radix trees for O(k) where k = path depth
JSON serializationO(n)n = size of response object
Pagination scanO(n) offset-basedCursor-based pagination is O(1) seek
MemoryO(n)n = number of stored resources

Scalability Characteristics

  • Statelessness enables horizontal scaling — add more servers behind a load balancer
  • Cacheability of GET requests reduces database load
  • Idempotency of GET/PUT/DELETE enables safe retries
  • Rate limiting (Block 009) protects against abuse

9. Trade-offs

RESTgRPCGraphQL
ProtocolHTTP/1.1+ (text)HTTP/2 (binary)HTTP (text)
SchemaOpenAPI/Swagger (optional)Protobuf (required)GraphQL Schema (required)
OverheadHigher (JSON text)Lower (binary encoding)Variable
Caching✅ HTTP caching works❌ Requires custom caching❌ POST-only, no HTTP caching
Browser support✅ Native❌ Requires grpc-web✅ With client library
Over-fetching✅ Common problem❌ Fixed schema❌ Client picks fields
Learning curveLowMediumMedium-High
Best forPublic APIs, CRUDService-to-service, streamingMobile apps, complex UIs

When NOT to use REST

  • Real-time bidirectional: Use WebSockets (Block 003) — REST requires client to poll
  • High-throughput inter-service: Use gRPC — binary encoding saves bandwidth
  • Complex nested queries: Use GraphQL — avoids N+1 REST calls
  • File streaming: Use chunked transfer or direct TCP
  • Event-driven: Use message queues (Block 023) — REST is request-response

10. Production Evolution

ConcernThis ImplementationProduction (Spring Boot / Express / Gin)
RoutingString matchingRadix tree router with path params and regex
ValidationManual if checksSchema validation (JSON Schema, Zod, Pydantic)
AuthenticationNoneJWT/OAuth2 middleware
Serializationjson.dumps()Auto-serialization with field filtering
Error handlingManual try/catchGlobal error handler with stack traces
DocumentationNoneOpenAPI/Swagger auto-generated from code
VersioningNoneURL prefix routing (/v1/, /v2/)
CORSNoneCORS middleware for browser clients
Rate limitingNoneMiddleware (Block 009)
Request loggingprint()Structured JSON logs with request IDs

11. Common Bugs

BugWhat happensFix
Using verbs in URLs/createUser, /deleteItem — not RESTfulUse nouns: POST /users, DELETE /items/:id
Returning 200 for errorsClient can't distinguish success from failureUse proper status codes: 400, 404, 422
No paginationGET /users returns 10M rowsAlways paginate collections
PUT for partial updateMissing fields get set to nullUse PATCH for partial updates, PUT for full replace
Not returning Location headerClient doesn't know the URL of the created resourcePOST should return 201 + Location: /resource/:id
Inconsistent error formatSome errors return strings, some objectsUse a standard error envelope everywhere
Sequential IDs exposedCompetitors can enumerate your dataUse UUIDs (Block 006)
No rate limitingOne client can DDoS your APIAdd rate limiting middleware (Block 009)

12. Interview Questions

  1. Design a RESTful API for a URL shortening service. Hint: Two resources — POST /urls to create, GET /:code for redirect. Consider what the response body and status codes should be.

  2. What's the difference between PUT and PATCH? Give an example where using the wrong one causes a bug. Hint: PUT replaces the entire resource. If you PUT with only {"name": "X"}, all other fields become null.

  3. How would you design pagination for a feed with real-time inserts (like Twitter)? Hint: Offset-based breaks when new items are inserted. Cursor-based pagination uses a pointer to the last seen item.

  4. Your REST API is called by 50 microservices. How do you evolve the API without breaking clients? Hint: API versioning + backward compatibility. Add new fields but never remove or rename existing ones.

  5. Should you use REST or gRPC for inter-service communication? What are the tradeoffs? Hint: gRPC is faster (binary + HTTP/2) but REST is simpler to debug (curl-friendly) and has better tooling.


13. Used By (Downstream Blocks)

  • 008 API Gateway — routes and aggregates REST calls
  • 009 Rate Limiter — limits requests per REST endpoint
  • 023 Message Queues — REST producer APIs
  • 028 Circuit Breaker — wraps REST calls to failing services
  • 029 Retry & Backoff — retries failed REST requests
  • 030 Idempotency — ensures safe retries of POST/PATCH
  • 035 Observability — traces REST request flows

14. Used In (Case Studies)

SystemREST API Pattern
TinyURLPOST /urls → create, GET /:code → 301 redirect
WhatsAppREST for auth, contacts, media upload
UberPOST /rides, GET /rides/:id, GET /drivers/nearby
NetflixREST for catalog, user profiles, recommendations
YouTubeREST for video metadata, comments, subscriptions
Twitter/XGET /2/tweets/:id, POST /2/tweets, streaming REST
InstagramREST for feed, stories, profile, media upload
Google DriveREST + resumable uploads for large files
DropboxREST for file metadata, content endpoints
KubernetesFully RESTful API server — GET /api/v1/pods

15. Related Blocks

RelationshipBlock
Previous001 HTTP & TCP/IP
Parallel003 WebSockets (alternative communication model)
Next008 API Gateway
Next009 Rate Limiter
Next030 Idempotency
AlternativegRPC, GraphQL, SOAP, JSON-RPC

16. Try It Yourself

Exercise 1: Add PATCH Support

Implement PATCH /users/:id that updates only the fields provided in the request body, leaving other fields unchanged. Compare the behavior with your existing PUT handler.

Exercise 2: Cursor-Based Pagination

Replace offset-based pagination with cursor-based pagination. The cursor should be an opaque string encoding the last seen id. The API should return a next_cursor field that the client passes back in the next request.


Website Metadata

FieldValue
Hero TitleREST API Design
Hero SubtitleThe universal interface — model resources, use HTTP semantics, scale horizontally
BreadcrumbSystem Design → Building Blocks → REST API Design
Sidebar CategoryTier 0 — Networking Fundamentals
Search Keywordsrest api, api design, http methods, status codes, pagination, versioning, restful, crud, resource modeling
Internal Links← 001 HTTP & TCP/IP · → 008 API Gateway · → 009 Rate Limiter
Suggested IllustrationA filing cabinet with labeled drawers (/users, /orders, /products), with HTTP methods (GET, POST, PUT, DELETE) as different colored keys that open the drawers
Suggested AnimationAnimated CRUD cycle: POST creates a card → GET retrieves it → PUT replaces it → DELETE removes it with a fade-out
PreviousWebSockets & Long PollingNextHTTP & TCP/IP Fundamentals