REST API Design
What is REST?
REST (Representational State Transfer) is an architectural style for designing web APIs around resources — things that have identity and state. Coined by Roy Fielding in his 2000 dissertation, REST has become the dominant paradigm for web APIs because it maps naturally to HTTP and is simple to understand.
REST is not a protocol or standard — it's a set of constraints. An API that follows these constraints is called "RESTful."
Core Principles
- Resources are identified by URLs:
/users/123,/orders/456/items - HTTP methods map to operations: GET (read), POST (create), PUT (replace), PATCH (update), DELETE (remove)
- Stateless — Each request contains all information needed to process it. No server-side sessions between requests.
- Representations — Resources can have multiple representations (JSON, XML, HTML). Clients specify preference via
Acceptheader. - Uniform interface — Consistent URL patterns, standard HTTP semantics, self-descriptive messages.
Resource Modeling
The most important decision in REST API design is choosing your resources. Resources should model your domain, not your database tables.
# Good — nouns, hierarchical
GET /users # List users
GET /users/123 # Get user 123
POST /users # Create a user
PATCH /users/123 # Update user 123
DELETE /users/123 # Delete user 123
GET /users/123/orders # List orders for user 123
GET /users/123/orders/456 # Get order 456 for user 123
# Bad — verbs, RPC-style
POST /getUser # Not RESTful
POST /createUser # The HTTP method already says "create"
GET /getUserOrders?userId=123 # Hierarchy not reflected in URL
Guidelines for resource naming:
- Use plural nouns:
/users, not/user - Use hierarchy to express relationships:
/users/123/orders - Keep URLs shallow — two levels of nesting is usually enough
- Use hyphens for multi-word resources:
/order-items, not/orderItems - Avoid actions in URLs. If you must, treat them as sub-resources:
POST /orders/123/cancel
HTTP Methods
| Method | Semantics | Idempotent | Safe | Example |
|--------|-----------|------------|------|---------|
| GET | Read a resource | Yes | Yes | GET /users/123 |
| POST | Create a resource | No | No | POST /users |
| PUT | Replace a resource entirely | Yes | No | PUT /users/123 |
| PATCH | Partially update a resource | Yes* | No | PATCH /users/123 |
| DELETE | Remove a resource | Yes | No | DELETE /users/123 |
*PATCH is idempotent when using JSON Merge Patch or JSON Patch. Not guaranteed in all implementations.
Idempotent means calling it multiple times produces the same result as calling it once. This matters for retries — safe to retry a PUT, dangerous to retry a POST without an idempotency key.
HTTP Status Codes
Status codes are part of the API contract, not decoration. Use them correctly.
| Code | Meaning | When to use | |------|---------|-------------| | 200 | OK | Successful GET, PATCH, DELETE | | 201 | Created | Successful POST that created a resource | | 204 | No Content | Successful DELETE with no response body | | 400 | Bad Request | Invalid input, validation failure | | 401 | Unauthorized | Missing or invalid authentication | | 403 | Forbidden | Authenticated but not authorized | | 404 | Not Found | Resource doesn't exist | | 409 | Conflict | Duplicate resource, state conflict | | 422 | Unprocessable Entity | Valid syntax but semantic errors | | 429 | Too Many Requests | Rate limit exceeded | | 500 | Internal Server Error | Unexpected server failure |
Error response design — be specific and actionable:
{
"error": {
"type": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "Must be a positive integer" }
]
}
}
Pagination
Never return unbounded lists. There are two main approaches.
Offset-Based Pagination
GET /users?page=3&per_page=20
Simple to implement, but has problems: skipping rows is O(N) in most databases, and inserting/deleting rows causes items to shift between pages.
Cursor-Based Pagination
GET /users?after=cursor_abc123&limit=20
# Response:
{
"data": [...],
"pagination": {
"next_cursor": "cursor_def456",
"has_more": true
}
}
Cursors are opaque tokens (typically base64-encoded primary keys or timestamps). They perform consistently regardless of dataset size and handle concurrent inserts/deletes correctly. Stripe, Slack, and Facebook all use cursor-based pagination.
When to use which: Offset for small datasets or when users need "jump to page 50." Cursor for everything else.
Filtering, Sorting, and Field Selection
GET /orders?status=pending&sort=-created_at&fields=id,total,status
- Filtering: Use query parameters matching field names. For complex filters, consider a filter syntax:
?filter=status:eq:pending,total:gt:100 - Sorting: Prefix with
-for descending. Multiple fields:?sort=-created_at,name - Field selection: Let clients request only the fields they need. Reduces payload size for mobile clients.
HATEOAS
Hypermedia As The Engine Of Application State — responses include links to related resources and available actions.
{
"id": "order_123",
"status": "pending",
"total": 99.50,
"_links": {
"self": { "href": "/orders/order_123" },
"cancel": { "href": "/orders/order_123/cancel", "method": "POST" },
"items": { "href": "/orders/order_123/items" },
"customer": { "href": "/users/user_456" }
}
}
HATEOAS is the most debated REST constraint. In theory, it makes APIs self-discoverable. In practice, most APIs skip it because clients are usually coded against known endpoints. GitHub's REST API uses it well — every response includes URL links to related resources.
Rust axum Example
STRUCTURE ListParams:
after ← optional string
limit ← optional integer
status ← optional string
sort ← optional string
STRUCTURE PaginatedResponse:
data ← list of items
pagination ← Pagination
STRUCTURE Pagination:
next_cursor ← optional string
has_more ← boolean
PROCEDURE LIST_ORDERS(db, params):
limit ← IF params.limit IS present THEN MIN(params.limit, 100) ELSE 20
orders ← AWAIT db.LIST_ORDERS(params.after, limit + 1, params.status)
has_more ← LENGTH(orders) > limit
data ← TAKE first limit items FROM orders
next_cursor ← IF has_more THEN id OF LAST item IN data ELSE None
RETURN JSON PaginatedResponse {
data ← data,
pagination ← Pagination { next_cursor, has_more }
}
PROCEDURE GET_ORDER(db, id):
result ← AWAIT db.GET_ORDER(id)
IF result IS present THEN
RETURN JSON(result)
ELSE
RETURN 404 NOT FOUND
PROCEDURE CREATE_ORDER(db, input):
order ← AWAIT db.CREATE_ORDER(input)
RETURN (201 CREATED, JSON(order))
PROCEDURE APP(state):
REGISTER route "/orders" with GET → LIST_ORDERS, POST → CREATE_ORDER
REGISTER route "/orders/{id}" with GET → GET_ORDER
SET state
Model APIs: Stripe and Google
Stripe
Stripe's API is widely regarded as the gold standard for REST design:
- Consistent
snake_casenaming everywhere - Every object has an
idfield and anobjectfield (e.g.,"object": "customer") - Expandable fields:
GET /charges/ch_123?expand[]=customerinlines the customer object instead of returning just the ID - Idempotency keys:
Idempotency-Key: abc123header makes POST requests safe to retry - Date-based versioning:
Stripe-Version: 2024-03-15
Google Cloud APIs
Google's publicly available API Design Guide standardizes:
- Resource-oriented design with standard methods (List, Get, Create, Update, Delete)
- Consistent error model across all APIs
- Standard fields:
name,create_time,update_time,delete_time,etag - Long-running operations pattern for async work
Both demonstrate that consistency and predictability matter more than cleverness.
Common Mistakes
- Inconsistent naming — Mixing
camelCaseandsnake_case. Pick one and enforce it. - Using POST for everything — Treating REST as RPC. Use the right HTTP method.
- Leaking internals — Exposing database column names or internal IDs. The API is a public contract.
- No pagination — Works with 10 records, crashes with 10 million.
- Poor error messages —
{"error": "Bad Request"}tells the client nothing.