Search…

Designing RESTful APIs

In this series (15 parts)
  1. Backend system design scope
  2. Designing RESTful APIs
  3. Authentication and session management
  4. Database design for backend systems
  5. Caching in backend systems
  6. Background jobs and task queues
  7. File upload and storage
  8. Search integration
  9. Email and notification delivery
  10. Webhooks: design and security
  11. Payments integration
  12. Multi-tenancy patterns
  13. Backend for Frontend (BFF) pattern
  14. GraphQL server design
  15. gRPC and internal service APIs

A REST API exposes your backend as a set of resources that clients manipulate through HTTP. The quality of your API design determines how easy your system is to integrate with, debug, and evolve. Good APIs feel obvious. Bad ones generate support tickets.

Resource modeling

Resources are the nouns of your API. Each resource represents a concept in your domain: a user, an order, a product, a review. The first step in API design is identifying these resources and their relationships.

Rules of thumb:

  • Use plural nouns for collection endpoints: /users, /orders, /products.
  • Nest only one level deep: /users/{id}/orders is fine. /users/{id}/orders/{id}/items/{id}/reviews is not. Flatten deeply nested resources into their own top-level endpoints.
  • Use IDs, not names: /users/42 is stable. /users/john-doe breaks when John changes his display name (unless you use slugs deliberately).
erDiagram
  USER {
      uuid id PK
      string email
      string name
      timestamp created_at
  }
  ORDER {
      uuid id PK
      uuid user_id FK
      string status
      decimal total
      timestamp created_at
  }
  ORDER_ITEM {
      uuid id PK
      uuid order_id FK
      uuid product_id FK
      int quantity
      decimal unit_price
  }
  PRODUCT {
      uuid id PK
      string name
      string slug
      decimal price
      boolean active
  }
  REVIEW {
      uuid id PK
      uuid user_id FK
      uuid product_id FK
      int rating
      text body
  }

  USER ||--o{ ORDER : places
  ORDER ||--|{ ORDER_ITEM : contains
  PRODUCT ||--o{ ORDER_ITEM : "appears in"
  USER ||--o{ REVIEW : writes
  PRODUCT ||--o{ REVIEW : receives

A clean API resource model for an e-commerce backend.

This entity model maps directly to API endpoints:

ResourceEndpointNotes
UsersGET /users/{id}No list endpoint (admin only)
OrdersGET /users/{id}/ordersScoped to authenticated user
Order itemsGET /orders/{id}/itemsNested one level
ProductsGET /productsPublic, paginated
ReviewsGET /products/{id}/reviewsPublic, paginated

HTTP verbs and their semantics

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

MethodPurposeIdempotentSafe
GETRead a resourceYesYes
POSTCreate a resourceNoNo
PUTReplace a resourceYesNo
PATCHPartially updateYes*No
DELETERemove a resourceYesNo

PATCH is idempotent if you apply the same patch operation repeatedly. JSON Merge Patch (RFC 7396) is inherently idempotent. JSON Patch (RFC 6902) may not be.

Key principle: PUT replaces the entire resource. PATCH modifies specific fields. If a client sends a PUT without a field, that field should be set to its default or null. This distinction matters.

Status codes

Use the right status code. Clients depend on them for control flow.

Success codes:

  • 200 OK: request succeeded, response body contains the result.
  • 201 Created: resource was created. Include a Location header pointing to the new resource.
  • 204 No Content: request succeeded, no response body (common for DELETE).

Client error codes:

  • 400 Bad Request: malformed input. Include a structured error body explaining what is wrong.
  • 401 Unauthorized: missing or invalid authentication.
  • 403 Forbidden: authenticated but not authorized.
  • 404 Not Found: resource does not exist (or the user cannot see it).
  • 409 Conflict: request conflicts with current state (e.g., duplicate email).
  • 422 Unprocessable Entity: syntactically valid but semantically invalid input.
  • 429 Too Many Requests: rate limit exceeded. Include a Retry-After header.

Server error codes:

  • 500 Internal Server Error: something broke. Log the details, return a generic message.
  • 503 Service Unavailable: the server is overloaded or in maintenance. Include Retry-After.

Idempotency

An idempotent operation produces the same result whether you call it once or ten times. This is critical for reliability. Networks are unreliable; clients will retry.

GET, PUT, and DELETE are naturally idempotent. POST is not. To make POST idempotent, use an idempotency key:

POST /orders
Idempotency-Key: abc-123-def-456
Content-Type: application/json

{ "product_id": "p1", "quantity": 2 }

The server stores the idempotency key with the response. If the same key arrives again, the server returns the stored response without processing the request again.

sequenceDiagram
  participant C as Client
  participant S as Server
  participant DB as Database

  C->>S: POST /orders (Idempotency-Key: abc-123)
  S->>DB: Check idempotency key
  DB-->>S: Not found
  S->>DB: Create order + store key with response
  DB-->>S: Order created
  S-->>C: 201 Created

  Note over C,S: Network timeout, client retries

  C->>S: POST /orders (Idempotency-Key: abc-123)
  S->>DB: Check idempotency key
  DB-->>S: Found, return stored response
  S-->>C: 201 Created (same response)

Idempotency key flow. The second request returns the stored response without creating a duplicate order.

Pagination

Any endpoint that returns a list must be paginated. There are two main approaches.

Offset-based pagination

GET /products?offset=20&limit=10

Simple to implement. Problematic when data changes between pages: inserts or deletes can cause items to appear twice or be skipped entirely. Fine for admin dashboards where exactness is less critical.

Cursor-based pagination

GET /products?cursor=eyJpZCI6MTAwfQ&limit=10

The cursor encodes the position of the last item seen. The server returns items after that position. This is stable even when data changes. Use it for user-facing feeds, timelines, and any list that changes frequently.

Cursor-based pagination maintains constant performance regardless of how deep into the dataset you paginate. Offset pagination degrades linearly because the database must skip rows.

Pagination response format

Always include pagination metadata in the response:

{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTEwfQ",
    "has_more": true
  }
}

For offset pagination, include total_count, offset, and limit. For cursor pagination, include next_cursor and has_more.

Versioning

APIs evolve. Breaking changes are inevitable. Versioning strategies:

URL path versioning: GET /v1/products. Simple, explicit, easy to route. Most teams start here.

Header versioning: Accept: application/vnd.myapi.v2+json. Cleaner URLs but harder to test in a browser.

Query parameter versioning: GET /products?version=2. Works but pollutes the query string.

The practical choice for most teams is URL path versioning. It is visible, easy to document, and works with every HTTP client.

When to bump the version

Bump the major version when you:

  • Remove a field from a response.
  • Change the type of a field.
  • Change the meaning of a field.
  • Remove an endpoint.

Do not bump for:

  • Adding new optional fields to responses.
  • Adding new endpoints.
  • Adding new optional query parameters.

HATEOAS

HATEOAS (Hypermedia as the Engine of Application State) means including links in responses so clients can discover available actions:

{
  "id": "order-42",
  "status": "pending",
  "links": {
    "self": "/orders/42",
    "cancel": "/orders/42/cancel",
    "items": "/orders/42/items"
  }
}

In theory, this makes APIs self-documenting and decouples clients from hardcoded URLs. In practice, most teams skip it because the tooling cost outweighs the benefit for internal APIs. Consider it for public APIs where you want clients to be resilient to URL changes.

API documentation

An undocumented API is an unusable API. Use OpenAPI (Swagger) to describe your API:

  • Write the OpenAPI spec alongside your code, not after.
  • Generate client SDKs from the spec.
  • Use the spec to drive integration tests.
  • Host interactive documentation (Swagger UI, Redoc) so consumers can explore endpoints without reading source code.

Treat your OpenAPI spec as a contract. If the spec says the endpoint returns a user_id field, the endpoint must return a user_id field. Break the spec, break the contract.

Error response format

Standardize your error responses across all endpoints:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address."
      },
      {
        "field": "quantity",
        "message": "Must be a positive integer."
      }
    ]
  }
}

A consistent structure lets clients parse errors programmatically. Include machine-readable codes (not just messages) so clients can handle specific errors without string matching.

What comes next

The next article covers authentication and session management: session tokens vs JWTs, refresh token rotation, OAuth 2.0, and multi-device sessions. Once your API exists, you need to know who is calling it.

Start typing to search across all content
navigate Enter open Esc close