RESTful API Design
Introduction
Imagine your team is building a new microservice that manages user accounts and preferences. You need to expose its functionality to mobile apps, web frontends, internal services, and third-party partners. How you design the API will determine whether developers love or curse your service for years. A well-designed API is a joy to work with—self-documenting, predictable, and efficient. A poorly designed one creates technical debt that ripples through every consumer, breeding workarounds, custom client logic, and eventual rewrites.
REST has dominated web API design for over two decades. It’s in your Slack integrations, your cloud provider SDKs, your microservices communication layers. But here’s the honest truth: most APIs claiming to be “RESTful” aren’t actually REST. We get REST’s spirit right while missing the architectural principles that make it powerful. In this chapter, we’ll explore what REST truly is, why the architecture matters, and how to design APIs that scale with your system—not against it.
This connects directly to our broader system design philosophy: constraints enable elegance. REST isn’t a set of rules to follow blindly—it’s an architectural style that, when applied thoughtfully, gives you caching, statelessness, loose coupling, and developer predictability for free.
What REST Actually Means
REST, or Representational State Transfer, emerged from Roy Fielding’s 2000 dissertation on distributed systems. It’s not a protocol or framework—it’s an architectural style for building distributed systems around resources rather than actions.
The six REST constraints form the foundation:
- Client-Server: Clean separation between client (consumer of the service) and server (provider). They evolve independently.
- Stateless: Each request contains all information needed to process it. The server stores no client context between requests.
- Cacheable: Responses are implicitly or explicitly marked as cacheable. This reduces bandwidth and latency dramatically.
- Uniform Interface: A standardized way to communicate that decouples clients from servers.
- Layered System: A client cannot tell whether it’s connected directly to an endpoint or through intermediaries (load balancers, caches, proxies).
- Code-on-Demand (optional): Servers can extend client functionality by transferring executable code (rarely used; think JavaScript in browsers).
The uniform interface constraint breaks down further into four principles:
- Resource Identification: Resources are identified in requests (via URIs). A resource is a conceptual thing—a user, an order, a review—distinct from its representation.
- Resource Manipulation Through Representations: When you send a POST request with a JSON body, that JSON is a representation of a resource state. The server manipulates the actual resource based on the representation you provide.
- Self-Descriptive Messages: A request should contain all context needed. Responses should indicate what can be done next.
- HATEOAS: Hypermedia As The Engine Of Application State. Responses include links to related resources and available actions.
The key insight: resources are nouns, not verbs. You operate on resources using a small, standardized set of verbs (HTTP methods).
The REST Maturity Spectrum
Leonard Richardson created the Maturity Model to help us assess how RESTful an API truly is:
| Level | Characteristics | Example |
|---|---|---|
| Level 0 | Single URI, HTTP as transport only. All operations via POST to one endpoint. | POST /api with action field in body |
| Level 1 | Multiple resources, each with own URI. Still uses mainly POST. | POST /users, POST /orders |
| Level 2 | Proper HTTP verbs and status codes. | GET /users/123, POST /orders, DELETE /orders/456 |
| Level 3 | Level 2 plus HATEOAS. Responses include hypermedia links. | Response includes _links with next actions |
Most production APIs sit at Level 2—and that’s fine. Level 3 (true HATEOAS) adds complexity that often isn’t worth it for internal APIs or when clients are tightly coupled. But Level 2 should be your minimum.
The Library Analogy
REST is like a well-organized public library:
- Resources: Books, identified uniquely by ISBN. Your patron ID is a resource too.
- URIs: “Fiction > Science Fiction > Author: Asimov” identifies which book. It’s a path, a stable reference.
- HTTP Methods: Standardized operations. Check out (GET), return (DELETE), place a hold (POST), update your preferences (PATCH).
- Status Codes: The librarian’s responses. “Here’s your book” (200 OK), “That book doesn’t exist” (404 Not Found), “You have overdue items; can’t check out more” (403 Forbidden), “Our system is down” (503 Service Unavailable).
- Stateless Interactions: The librarian doesn’t remember you from last week. Your membership card (authentication token) proves who you are. Every interaction is self-contained.
- Caching: Some books are frequently checked out, so the library keeps multiple copies on the shelf (cached responses). The library posts when a copy was last updated (ETag), so patrons know if their mental model is stale.
This structure works because it’s predictable. You don’t need a librarian to explain how to find a book every time. The system is self-documenting.
Designing RESTful Resources
URI Design Principles
Use plural nouns for collections:
✓ /users
✓ /orders
✓ /products
✗ /user (singular inconsistency)
✗ /getAllUsers (verb in URL)
✗ /users.php (implementation detail)
Resources are hierarchical:
/users/123/orders # All orders by user 123
/users/123/orders/456 # Specific order
/users/123/orders/456/items # Items in that order
Filter via query parameters, not URL paths:
/orders?status=pending&customer_id=123
/products?category=electronics&price_max=500
/logs?start_date=2024-01-01&severity=ERROR
Avoid verb-based URLs:
✗ /users/123/sendMessage (POST instead)
✗ /orders/create (POST to /orders instead)
✗ /products/delete/123 (DELETE /products/123 instead)
HTTP Methods: The Uniform Interface
| Method | Purpose | Idempotent | Safe | When to Use |
|---|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes | Fetch data without side effects |
| POST | Create a new resource | No | No | Submit form data, create resources |
| PUT | Replace entire resource | Yes | No | Update by sending complete representation |
| PATCH | Partial resource update | No | No | Update specific fields only |
| DELETE | Remove a resource | Yes | No | Delete a resource |
| HEAD | Like GET but no response body | Yes | Yes | Check if resource exists, get headers |
Idempotency matters: Calling DELETE /orders/123 ten times should have the same effect as calling it once. Same with PUT. POST is not idempotent—each call creates a new resource. This property is crucial for reliability in distributed systems; you can safely retry failed requests without worrying about duplicates.
Status Codes and Semantics
HTTP status codes tell the client what happened:
| Code | Meaning | Example |
|---|---|---|
| 200 | OK—request succeeded, response body included | GET successful, POST with response |
| 201 | Created—resource created successfully | POST creates a new order |
| 204 | No Content—success but no response body | DELETE successful, PATCH with no return needed |
| 301, 302 | Redirect—resource moved | Redirect old API version to new one |
| 304 | Not Modified—cached copy is fresh | ETag matches, no need to resend body |
| 400 | Bad Request—client error in request format | Missing required field, invalid JSON |
| 401 | Unauthorized—authentication required | Missing or invalid API key |
| 403 | Forbidden—authenticated but not permitted | User can’t access this resource |
| 404 | Not Found—resource doesn’t exist | Order doesn’t exist in system |
| 409 | Conflict—request conflicts with current state | Trying to delete already-deleted resource |
| 429 | Too Many Requests—rate limit exceeded | Rate limiting enforced |
| 500 | Internal Server Error—server fault | Unexpected exception in service |
| 503 | Service Unavailable—server temporarily down | Maintenance, overload, dependency failure |
Pro Tip: Avoid returning 200 OK for errors. Use the status code to communicate the type of failure. Clients can route based on status code without parsing the response body.
Request and Response Design
Content Negotiation
Always support content negotiation via the Accept header. Most APIs standardize on JSON, but your contract should be flexible:
GET /users/123 HTTP/1.1
Accept: application/json
Response:
{
"id": 123,
"name": "Alice Chen",
"email": "[email protected]",
"created_at": "2023-06-15T10:30:00Z"
}
Envelope vs Flat Structure
Flat structure (preferred for simplicity):
{
"id": 123,
"name": "Alice Chen",
"email": "[email protected]"
}
Envelope structure (useful for pagination, metadata):
{
"data": {
"id": 123,
"name": "Alice Chen",
"email": "[email protected]"
},
"meta": {
"version": "1.0",
"timestamp": "2024-01-20T15:30:00Z"
}
}
For collections, envelopes make pagination explicit:
{
"data": [
{ "id": 1, "name": "Product A" },
{ "id": 2, "name": "Product B" }
],
"pagination": {
"page": 1,
"page_size": 10,
"total": 47
}
}
Error Responses: RFC 7807 Problem Details
Don’t invent custom error formats. Use the standard:
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "The 'email' field must be a valid email address.",
"instance": "/orders",
"errors": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
This gives clients a consistent way to handle errors—they can check the type field, the status code, and extract structured error details if needed.
Request Body Design
POST and PUT requests should have clear, documented request bodies:
{
"name": "Alice Chen",
"email": "[email protected]",
"role": "admin"
}
For PATCH, consider JSON Patch (RFC 6902) for sophisticated partial updates, or simply accept a partial object:
{
"email": "[email protected]"
}
Practical Example: E-Commerce API
Let’s design a RESTful API for an e-commerce platform with users, products, orders, and reviews.
Resource URIs
Users:
GET /users # List all users
POST /users # Create new user
GET /users/123 # Get user 123
PUT /users/123 # Replace user 123
PATCH /users/123 # Update user 123
DELETE /users/123 # Delete user 123
Orders:
GET /users/123/orders # Orders by user 123
POST /users/123/orders # Create order for user 123
GET /users/123/orders/456 # Get order 456 for user 123
GET /orders/456 # Get order 456 (alt. endpoint)
Reviews:
GET /products/789/reviews # Reviews for product 789
POST /products/789/reviews # Create review for product 789
GET /products/789/reviews/321 # Get specific review
Request and Response Examples
Create an order:
POST /users/123/orders HTTP/1.1
Content-Type: application/json
{
"items": [
{ "product_id": 456, "quantity": 2 },
{ "product_id": 789, "quantity": 1 }
],
"shipping_address": {
"street": "123 Main St",
"city": "New York",
"zip": "10001"
}
}
Response:
HTTP/1.1 201 Created
Location: /orders/9999
Content-Type: application/json
{
"id": 9999,
"user_id": 123,
"status": "pending",
"items": [
{ "product_id": 456, "quantity": 2, "price": 29.99 },
{ "product_id": 789, "quantity": 1, "price": 49.99 }
],
"total": 109.97,
"created_at": "2024-01-20T15:30:00Z"
}
OpenAPI Specification Snippet
openapi: 3.0.0
info:
title: E-Commerce API
version: 1.0.0
paths:
/orders:
post:
summary: Create a new order
tags: [Orders]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
type: object
properties:
product_id:
type: integer
quantity:
type: integer
shipping_address:
$ref: '#/components/schemas/Address'
required: [items, shipping_address]
responses:
'201':
description: Order created successfully
headers:
Location:
schema:
type: string
description: URL of the created order
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetail'
components:
schemas:
Order:
type: object
properties:
id:
type: integer
user_id:
type: integer
status:
type: string
enum: [pending, shipped, delivered]
items:
type: array
items:
type: object
total:
type: number
created_at:
type: string
format: date-time
ProblemDetail:
type: object
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
Advanced Concerns
Caching with ETags
ETags allow clients to avoid re-downloading unchanged resources:
GET /products/123 HTTP/1.1
HTTP/1.1 200 OK
ETag: "abc123def"
Cache-Control: max-age=3600
{ "id": 123, "name": "Widget", "price": 29.99 }
On subsequent requests:
GET /products/123 HTTP/1.1
If-None-Match: "abc123def"
HTTP/1.1 304 Not Modified
The client uses its cached copy—zero bandwidth.
HATEOAS: The Missing Piece
True REST (Level 3) includes hypermedia links in responses, allowing clients to discover available actions:
{
"id": 123,
"name": "Alice Chen",
"email": "[email protected]",
"_links": {
"self": { "href": "/users/123" },
"all_orders": { "href": "/users/123/orders" },
"update": { "href": "/users/123", "method": "PUT" },
"delete": { "href": "/users/123", "method": "DELETE" }
}
}
This decouples clients from your URL structure—clients follow links instead of constructing URLs. However, HATEOAS adds complexity and is rarely adopted in practice. For internal APIs or when clients are frontend apps you control, Level 2 REST is sufficient and simpler.
Common Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
Verb-based URLs (/getUser, /createOrder) | Violates REST; ignores HTTP methods | Use HTTP verbs, resource-based URLs |
GET for mutations (GET /users/123/delete) | GET should be side-effect free | Use DELETE, POST for mutations |
| Always returning 200 OK | Hides error types from clients | Use appropriate status codes |
| Custom error formats | Clients must parse unstructured errors | Use RFC 7807 Problem Details |
| Over-fetching (returning all fields always) | Wastes bandwidth | Support partial responses via query params |
| Chatty APIs (many requests per user action) | Increases latency and server load | Use proper resource nesting, batch endpoints |
Strengths and Limitations of REST
Why REST Works
- Simplicity: Leverages HTTP, which all developers know. No special frameworks needed.
- Tooling: Every language has HTTP libraries. Caching proxies (nginx, CDN) understand REST.
- Statelessness: Services scale horizontally. Any server can handle any request.
- Caching: HTTP caching is built-in, reducing load and latency dramatically.
- Browser-friendly: Easier to test with simple tools; JavaScript in browsers can consume REST APIs.
When REST Struggles
- Over-fetching/Under-fetching: GET /orders/123 returns all fields even if you only need status. Listing orders requires multiple requests.
- Real-time Communication: REST is request-response. WebSockets, Server-Sent Events (SSE), or event streams are better for real-time updates.
- Complex Queries: Filtering and pagination via query strings become verbose. GraphQL excels here.
- Chatty Interactions: Microservices with high fan-out (one action spawns many requests) suffer latency. gRPC with binary protocols is faster.
REST vs Alternatives
| Scenario | Best Choice | Why |
|---|---|---|
| Public web API, browser clients | REST | Simplicity, caching, tooling |
| Internal microservices, high throughput | gRPC | Speed, streaming, type safety |
| Multiple clients with different data needs | GraphQL | Query flexibility, reduced over-fetching |
| Real-time data streams | WebSocket / SSE | True push capability |
| Monolith with rich UI | REST | Developer simplicity, CORS support |
In a typical system, you’ll use multiple styles: REST for external APIs, gRPC between internal services, WebSockets for real-time dashboards.
Key Takeaways
- REST is an architectural style, not a protocol: It’s about organizing systems around resources and leveraging HTTP semantics, not just using JSON over HTTP.
- Use HTTP methods correctly: GET for reads, POST for creation, PUT/PATCH for updates, DELETE for removal. This enables caching and client libraries to route appropriately.
- Status codes are part of your contract: Don’t return 200 for every response. Use 201 for creation, 204 for no-content updates, 400/401/403 to distinguish client errors.
- URI design should be intuitive: Plural nouns, proper nesting, query parameters for filtering. Avoid verbs. Your API should be self-documenting.
- Error handling requires a standard format: Use RFC 7807 Problem Details so clients can consistently parse failures without custom parsing logic.
- REST excels at simplicity and caching, but struggles with real-time and complex queries: Know when to use REST and when alternatives like GraphQL, gRPC, or WebSockets better fit your needs.
Practice Scenarios
Scenario 1: Multi-tenant SaaS Platform
Design a RESTful API for a project management tool where multiple organizations can exist. Users belong to organizations, and organizations have projects, tasks, and team members. Consider:
- How would you structure URIs for org-scoped resources?
- How would you handle pagination for a task list with 10,000 items?
- What status codes would you use when a user tries to access a project they don’t have permission for?
- How would you version the API when adding new fields to tasks?
Scenario 2: File Storage Service
You’re building an API for storing and retrieving files (like AWS S3). Consider:
- How would you design URIs for buckets and objects?
- What would DELETE on a non-existent file return? 404 or 204?
- How would you handle metadata (creation date, size, MIME type)?
- How would you enable upload resumption for large files?
Scenario 3: Real-time Notification System
You’re adding real-time notifications to your REST API. Users should be notified immediately when certain events occur. Consider:
- Can you achieve this with pure REST? What are the limitations?
- Would WebSockets or server-sent events be better? Why?
- How would you design an endpoint to fetch historical notifications? (This one stays REST.)
Connection to Next Steps
REST has served us well for decades, but it’s not the only way to structure communication. As systems scale and demands change—when microsecond latencies matter, when you have hundreds of internal services communicating, when type safety becomes critical—other paradigms emerge. In the next section, we’ll explore RPC and gRPC: how to move beyond HTTP for raw speed and better developer experience within trusted network boundaries.