← back

Networking & Communication

REST API Design

Design clean, predictable HTTP APIs. Covers resource naming, HTTP methods, status codes, pagination, versioning, and common anti-patterns.

REST API Design

REST (Representational State Transfer) is the dominant architectural style for web APIs. Despite being widely used, many APIs get the fundamentals wrong — inconsistent naming, misuse of HTTP methods, poor error handling, and pagination that breaks at scale. In system design interviews, demonstrating that you can design a clean, predictable API shows that you think carefully about the interfaces between systems.

Core Principles

REST is built on a few key constraints defined by Roy Fielding in his 2000 dissertation:

  1. Client-Server separation: The client and server are independent. The server does not know about the client's UI; the client does not know about the server's storage.
  1. Statelessness: Each request contains all the information needed to process it. The server does not store session state between requests.
  1. Uniform interface: Resources are identified by URIs, manipulated through representations (JSON, XML), and actions are expressed through standard HTTP methods.
  1. Cacheability: Responses must define whether they are cacheable. Proper cache headers reduce server load and improve client performance.

Resource Naming

Resources are the nouns of your API. They represent the things your system manages — users, orders, products, messages. Good resource naming is the foundation of a well-designed API.

Rules

  • Use nouns, not verbs: `/users` not `/getUsers`. The HTTP method expresses the action.
  • Use plural nouns: `/users/123` not `/user/123`. The collection is plural; individual items are accessed by ID.
  • Use kebab-case: `/user-profiles` not `/userProfiles` or `/user_profiles`. URLs are case-insensitive by convention.
  • Nest for relationships: `/users/123/orders` — orders belonging to user 123.
  • Limit nesting depth: `/users/123/orders/456/items/789` is too deep. After 2 levels, use a top-level resource: `/order-items/789`.
1
2
3
4
5
6
7
8
9
10
11
12
13
Good:
  GET /users
  GET /users/123
  GET /users/123/orders
  POST /users
  PUT /users/123
  DELETE /users/123/orders/456

Bad:
  GET /getUser?id=123          (verb in URL)
  POST /createUser             (verb in URL)
  GET /user/123/order/456      (inconsistent pluralization)
  GET /users/123/orders/456/items/789/reviews  (too deeply nested)

HTTP Methods

Each HTTP method has specific semantics. Using them correctly makes your API predictable.

1
2
3
4
5
6
7
8
9
10
┌──────────┬─────────────────────┬─────────────┬────────────┐
│ Method   │ Purpose             │ Idempotent? │ Safe?      │
├──────────┼─────────────────────┼─────────────┼────────────┤
│ GET      │ Read a resource     │ Yes         │ Yes        │
│ POST     │ Create a resource   │ No          │ No         │
│ PUT      │ Replace a resource  │ Yes         │ No         │
│ PATCH    │ Partial update      │ No*         │ No         │
│ DELETE   │ Remove a resource   │ Yes         │ No         │
└──────────┴─────────────────────┴─────────────┴────────────┘
* PATCH can be made idempotent depending on implementation

Safe means the method does not modify server state. Clients can call safe methods without worrying about side effects. Idempotent means calling the method multiple times produces the same result as calling it once.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# PUT replaces the entire resource — idempotent
# Calling this 10 times produces the same result as calling it once
PUT /users/123
{
    "name": "Alice",
    "email": "alice@example.com",
    "role": "admin"
}

# PATCH updates specific fields — the resource is partially modified
PATCH /users/123
{
    "role": "admin"
}

# POST creates a new resource — NOT idempotent
# Calling this 10 times creates 10 users
POST /users
{
    "name": "Alice",
    "email": "alice@example.com"
}

Status Codes

Status codes communicate the result of a request. Use them correctly — do not return 200 for everything with an error message in the body.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2xx Success:
  200 OK              — Request succeeded (general success)
  201 Created         — Resource was created (POST)
  204 No Content      — Success with no response body (DELETE)

3xx Redirection:
  301 Moved Permanently — Resource has a new URL
  304 Not Modified      — Cached version is still valid

4xx Client Error:
  400 Bad Request     — Malformed request (validation error)
  401 Unauthorized    — Authentication required
  403 Forbidden       — Authenticated but not authorized
  404 Not Found       — Resource does not exist
  409 Conflict        — Conflicting state (duplicate email)
  422 Unprocessable   — Valid syntax but semantic error
  429 Too Many Reqs   — Rate limit exceeded

5xx Server Error:
  500 Internal Error  — Unexpected server failure
  502 Bad Gateway     — Upstream service failure
  503 Unavailable     — Server overloaded or in maintenance
  504 Gateway Timeout — Upstream service timeout

Error response format

Always return structured error responses:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Consistent error format
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Email address is invalid",
        "details": [
            {
                "field": "email",
                "message": "Must be a valid email address",
                "value": "not-an-email"
            }
        ]
    }
}

Pagination

Any endpoint that returns a list needs pagination. There are two main approaches:

Offset-based pagination

Simple and intuitive. The client specifies which page or offset to start from.

1
2
3
4
5
6
7
8
9
10
11
12
GET /users?offset=20&limit=10

Response:
{
    "data": [...],
    "pagination": {
        "total": 1532,
        "offset": 20,
        "limit": 10,
        "has_more": true
    }
}

Drawbacks: If items are inserted or deleted between page requests, results can shift — you might see duplicates or miss items. Also, `OFFSET 100000` in SQL is slow because the database scans and discards 100,000 rows.

Cursor-based pagination

The server returns an opaque cursor that the client sends to get the next page. The cursor typically encodes the last item's sort key.

1
2
3
4
5
6
7
8
9
10
GET /users?limit=10&cursor=eyJpZCI6MTIzfQ==

Response:
{
    "data": [...],
    "pagination": {
        "next_cursor": "eyJpZCI6MTMzfQ==",
        "has_more": true
    }
}

Advantages: Stable results even with concurrent inserts/deletes. Efficient — the database uses an indexed `WHERE id > cursor_id` instead of `OFFSET`.

Drawbacks: Cannot jump to an arbitrary page. No total count (which requires scanning the entire dataset).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Cursor-based pagination implementation
import base64
import json

def get_users(cursor=None, limit=10):
    if cursor:
        last_id = json.loads(base64.b64decode(cursor))["id"]
        users = db.query(
            "SELECT * FROM users WHERE id > %s ORDER BY id LIMIT %s",
            [last_id, limit + 1]  # Fetch one extra to determine has_more
        )
    else:
        users = db.query(
            "SELECT * FROM users ORDER BY id LIMIT %s", [limit + 1]
        )

    has_more = len(users) > limit
    users = users[:limit]

    next_cursor = None
    if has_more and users:
        next_cursor = base64.b64encode(
            json.dumps({"id": users[-1].id}).encode()
        ).decode()

    return {"data": users, "next_cursor": next_cursor, "has_more": has_more}

When to use which

Use offset for internal tools, admin dashboards, and small datasets where you need page numbers. Use cursor for production APIs, feeds, timelines, and any dataset that changes frequently or is large.

API Versioning

APIs evolve, and breaking changes are inevitable. Version your API from day one.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Strategies:

1. URL path (most common):
   GET /v1/users/123
   GET /v2/users/123

2. Query parameter:
   GET /users/123?version=2

3. Header:
   GET /users/123
   Accept: application/vnd.myapi.v2+json

4. Content negotiation:
   Accept: application/json; version=2

URL path versioning is the most practical: it is visible, cacheable, and easy to route. Header-based versioning is more "RESTful" but harder to test and debug (you cannot share a versioned URL in a browser).

Idempotency

Idempotency is critical for reliability. Networks are unreliable — requests can be sent multiple times due to retries, timeouts, or client bugs. If your API is not idempotent where it should be, duplicate requests cause duplicate side effects (double charges, duplicate orders).

Idempotency keys

For non-idempotent operations (like POST), the client sends a unique idempotency key. The server stores the result of the first request and returns it for subsequent requests with the same key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Server-side idempotency implementation
def create_payment(request):
    idempotency_key = request.headers.get("Idempotency-Key")
    if not idempotency_key:
        return error(400, "Idempotency-Key header required")

    # Check if we have already processed this key
    cached = redis.get(f"idempotency:{idempotency_key}")
    if cached:
        return json.loads(cached)  # Return the original response

    # Process the request
    result = payment_service.charge(request.body)

    # Store the result (with expiry — keys do not live forever)
    redis.setex(
        f"idempotency:{idempotency_key}",
        86400,  # 24 hour expiry
        json.dumps(result)
    )

    return result

Stripe's API is the gold standard for idempotency. Every POST request accepts an `Idempotency-Key` header, and the server guarantees that the same key always returns the same result.

HATEOAS

HATEOAS (Hypermedia as the Engine of Application State) means including links in responses that tell the client what actions are available. The client does not need to hardcode URLs — it discovers them from the API responses.

1
2
3
4
5
6
7
8
9
10
11
12
# A HATEOAS response for a user resource
{
    "id": 123,
    "name": "Alice",
    "email": "alice@example.com",
    "links": [
        {"rel": "self", "href": "/users/123", "method": "GET"},
        {"rel": "update", "href": "/users/123", "method": "PUT"},
        {"rel": "delete", "href": "/users/123", "method": "DELETE"},
        {"rel": "orders", "href": "/users/123/orders", "method": "GET"}
    ]
}

In practice, full HATEOAS is rarely implemented. Most APIs include basic self-links and pagination links, but the full hypermedia approach is uncommon outside of enterprise APIs. Knowing about it shows depth, but do not over-invest in it for interview designs.

Example: Designing an E-Commerce API

Putting it all together for an interview scenario:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Products:
  GET    /v1/products                    — List products (paginated, filterable)
  GET    /v1/products/:id                — Get product details
  POST   /v1/products                    — Create product (admin)
  PUT    /v1/products/:id                — Update product (admin)
  DELETE /v1/products/:id                — Remove product (admin)

Cart:
  GET    /v1/users/:id/cart              — Get user's cart
  POST   /v1/users/:id/cart/items        — Add item to cart
  PATCH  /v1/users/:id/cart/items/:itemId — Update quantity
  DELETE /v1/users/:id/cart/items/:itemId — Remove from cart

Orders:
  GET    /v1/orders                      — List user's orders (cursor paginated)
  POST   /v1/orders                      — Place order (idempotency key required)
  GET    /v1/orders/:id                  — Get order details

Search:
  GET    /v1/products/search?q=laptop&category=electronics&sort=price&order=asc

Interview Tips

  1. Design the resource model first. Before writing endpoints, identify your resources and their relationships. Draw them out: "We have Users, Products, Orders, and Reviews. An Order belongs to a User and contains Products."
  1. Use the HTTP contract correctly. POST for creation, PUT for full replacement, PATCH for partial updates, DELETE for removal. Getting this right shows attention to detail.
  1. Always mention pagination. Any list endpoint should be paginated. Say "I would use cursor-based pagination here because the feed changes frequently" to show you understand the trade-offs.
  1. Discuss idempotency for writes. "The create-order endpoint needs an idempotency key because network retries could cause duplicate orders" demonstrates production awareness.
  1. Version from the start. "I would put this under /v1/ so we have room to make breaking changes later" is a small detail that shows experience.
  1. Think about filtering and sorting. "GET /products?category=electronics&min_price=100&sort=rating&order=desc" shows you think about the consumer's needs.
  1. Consider rate limiting. Mention that your API should return 429 status codes with a `Retry-After` header when rate limits are exceeded.