System Design Fundamentals

RESTful API Design

A

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:

  1. Client-Server: Clean separation between client (consumer of the service) and server (provider). They evolve independently.
  2. Stateless: Each request contains all information needed to process it. The server stores no client context between requests.
  3. Cacheable: Responses are implicitly or explicitly marked as cacheable. This reduces bandwidth and latency dramatically.
  4. Uniform Interface: A standardized way to communicate that decouples clients from servers.
  5. Layered System: A client cannot tell whether it’s connected directly to an endpoint or through intermediaries (load balancers, caches, proxies).
  6. 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:

LevelCharacteristicsExample
Level 0Single URI, HTTP as transport only. All operations via POST to one endpoint.POST /api with action field in body
Level 1Multiple resources, each with own URI. Still uses mainly POST.POST /users, POST /orders
Level 2Proper HTTP verbs and status codes.GET /users/123, POST /orders, DELETE /orders/456
Level 3Level 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

MethodPurposeIdempotentSafeWhen to Use
GETRetrieve a resourceYesYesFetch data without side effects
POSTCreate a new resourceNoNoSubmit form data, create resources
PUTReplace entire resourceYesNoUpdate by sending complete representation
PATCHPartial resource updateNoNoUpdate specific fields only
DELETERemove a resourceYesNoDelete a resource
HEADLike GET but no response bodyYesYesCheck 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:

CodeMeaningExample
200OK—request succeeded, response body includedGET successful, POST with response
201Created—resource created successfullyPOST creates a new order
204No Content—success but no response bodyDELETE successful, PATCH with no return needed
301, 302Redirect—resource movedRedirect old API version to new one
304Not Modified—cached copy is freshETag matches, no need to resend body
400Bad Request—client error in request formatMissing required field, invalid JSON
401Unauthorized—authentication requiredMissing or invalid API key
403Forbidden—authenticated but not permittedUser can’t access this resource
404Not Found—resource doesn’t existOrder doesn’t exist in system
409Conflict—request conflicts with current stateTrying to delete already-deleted resource
429Too Many Requests—rate limit exceededRate limiting enforced
500Internal Server Error—server faultUnexpected exception in service
503Service Unavailable—server temporarily downMaintenance, 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-PatternProblemSolution
Verb-based URLs (/getUser, /createOrder)Violates REST; ignores HTTP methodsUse HTTP verbs, resource-based URLs
GET for mutations (GET /users/123/delete)GET should be side-effect freeUse DELETE, POST for mutations
Always returning 200 OKHides error types from clientsUse appropriate status codes
Custom error formatsClients must parse unstructured errorsUse RFC 7807 Problem Details
Over-fetching (returning all fields always)Wastes bandwidthSupport partial responses via query params
Chatty APIs (many requests per user action)Increases latency and server loadUse 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

ScenarioBest ChoiceWhy
Public web API, browser clientsRESTSimplicity, caching, tooling
Internal microservices, high throughputgRPCSpeed, streaming, type safety
Multiple clients with different data needsGraphQLQuery flexibility, reduced over-fetching
Real-time data streamsWebSocket / SSETrue push capability
Monolith with rich UIRESTDeveloper 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.