Skip to main content

On This Page

REST API Design: Beyond the Dogma

12 min read
Share

REST is not a protocol. It’s not a standard. It’s a set of architectural constraints that Roy Fielding described in his dissertation, and most APIs calling themselves RESTful violate half of them. That’s fine. You’re not writing an academic paper. You’re building something people need to use.

The real question isn’t “is this truly REST?” It’s “can another developer figure out how to use this in five minutes, and will they curse your name when requirements change?”

URLs Are Interfaces

Your URL structure is a promise. Change it and you break that promise. Design URLs like you’re carving them in stone because once you ship them, they might as well be.

Start with resources, not actions. Resources are nouns. If your URL has a verb in it, you’re probably doing it wrong.

Bad:  POST /api/createUser
Good: POST /api/users

The exception: actions that don’t map cleanly to CRUD. Sometimes you need /api/users/123/activate or /api/orders/456/cancel. That’s reality. The purists will tell you to model these as state changes on a resource. They’re not wrong, but they’re also not paying your bills. Do what makes sense for your team.

Nesting is seductive. It feels organized. But GET /users/123/posts/456/comments/789/likes is a maintenance nightmare. You’ve encoded a rigid hierarchy that will break the moment your data model evolves. Flat is better than nested. If you need relationships, use query parameters or separate endpoints.

Better: GET /comments/789
Best:   GET /comments/789?include=post,user,likes

Plurals for collections, always. /users not /user. Yes, even when English grammar makes it weird (/people not /person). Consistency beats correctness.

HTTP Verbs: Use Them or Lose Credibility

GET is for reading. POST is for creating. PUT is for replacing. PATCH is for updating. DELETE is for deleting. This isn’t controversial. Yet somehow half the APIs I’ve integrated with use POST for everything because “it’s simpler.”

It’s not simpler. It’s lazy. And it makes caching impossible, breaks browser behavior, and confuses every developer who has to read your code.

GET must be safe and idempotent. That means no side effects. If your GET endpoint modifies state, you’ve violated the social contract of the web. Proxies will cache it. Browsers will prefetch it. Bots will crawl it. Your database will suffer.

PUT versus PATCH: PUT replaces the entire resource. Send incomplete data to PUT and you’re saying “yes, I want all those missing fields to be null.” PATCH is for partial updates. Use it.

DELETE should be idempotent. Deleting a resource that doesn’t exist? Return 204 or 404, but don’t crash. The client asked for the resource to be gone. It’s gone. Mission accomplished.

Status Codes Matter

You have dozens of status codes. Use maybe seven of them well.

  • 200 OK: Request succeeded. You’re returning data.
  • 201 Created: Resource created. Include a Location header pointing to it.
  • 204 No Content: Request succeeded. You’re not returning data.
  • 400 Bad Request: Client screwed up. Tell them why.
  • 401 Unauthorized: No credentials or invalid credentials.
  • 403 Forbidden: Valid credentials, insufficient permissions.
  • 404 Not Found: Resource doesn’t exist.
  • 500 Internal Server Error: You screwed up. Log it, fix it, apologize.

That’s it. You don’t need 418 I’m a teapot. You probably don’t need 402 Payment Required. And if you’re using 200 for errors because “the HTTP request succeeded,” we need to have a different conversation.

Use 422 Unprocessable Entity for validation errors if you want to be pedantic about separating syntactic errors (400) from semantic ones (422). Most clients won’t care. I don’t care. But be consistent.

Error Design Is Where You Show Your Empathy

Errors are not edge cases. They’re the normal case. Networks fail. Users mistype. Services go down. Your error responses should be as carefully designed as your success responses.

Return machine-readable error codes. HTTP status codes aren’t enough. You need application-level codes.

{
  "error": {
    "code": "INVALID_EMAIL",
    "message": "Email address is not valid",
    "field": "email",
    "documentation": "https://api.example.com/docs/errors/INVALID_EMAIL"
  }
}

Don’t leak implementation details. Stack traces in production are a security risk and useless to API consumers. Log them server-side. Return something actionable.

Don’t use error codes like “ERROR_1234”. Use codes that mean something: RESOURCE_NOT_FOUND, DUPLICATE_EMAIL, RATE_LIMIT_EXCEEDED. Your clients will thank you.

Multiple validation errors? Return them all at once. Don’t make me fix one field, resubmit, and discover five more problems. That’s sadism.

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Email address is not valid"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Age must be between 18 and 120"
      }
    ]
  }
}

Pagination Is Non-Negotiable

Never return unbounded collections. Ever. Your database will grow. Your response times will degrade. Your users will complain.

Offset-based pagination is simple but dangerous at scale. Page 500 of a million-record table is slow. Use it anyway if your data is small and you need simplicity.

GET /users?page=2&per_page=50

Cursor-based pagination is better for large datasets. Less intuitive, but it performs consistently regardless of position.

GET /users?cursor=eyJpZCI6MTIzfQ&limit=50

Return metadata. Tell clients what they’re looking at.

{
  "data": [...],
  "pagination": {
    "total": 10543,
    "page": 2,
    "per_page": 50,
    "total_pages": 211,
    "next_page": "/users?page=3&per_page=50",
    "prev_page": "/users?page=1&per_page=50"
  }
}

For cursor-based, return next_cursor and prev_cursor. Don’t make clients reverse-engineer your cursor format.

Filtering, Sorting, and Searching

Query parameters for filtering. Not path parameters, not POST bodies. Query parameters.

GET /users?role=admin&status=active&created_after=2024-01-01

Be explicit about operators. ?age=25 is ambiguous. Is that exact match? Minimum? Use clear names.

GET /products?price_min=10&price_max=100&category=electronics

Sorting: use a sort parameter. Support multiple fields. Use - for descending order.

GET /articles?sort=-published_at,title

Full-text search doesn’t belong in query parameters when it gets complex. Use POST with a search endpoint.

POST /articles/search
{
  "query": "REST API design",
  "filters": {
    "tags": ["api", "architecture"],
    "published_after": "2024-01-01"
  },
  "sort": ["-relevance", "-published_at"],
  "page": 1,
  "per_page": 20
}

Yes, POST for reads feels wrong. Get over it. GET requests with complex JSON bodies are worse. Some proxies strip them. Some clients can’t send them easily. POST at least works everywhere.

Versioning:

You will change your API. Plan for it.

URL versioning is ugly but honest. /v1/users and /v2/users coexist. It’s explicit. It’s cache-friendly. It’s easy to route.

GET /v1/users/123
GET /v2/users/123

Header versioning is cleaner but hidden. Clients need to remember to set Accept: application/vnd.api+json; version=2. Debugging is harder. Caching is trickier.

Neither is wrong. Both work. Pick one and stick with it.

Don’t version for every tiny change. Minor additions (new optional fields, new endpoints) shouldn’t bump versions. Breaking changes (removing fields, changing behavior, renaming things) should.

Support old versions longer than you want to. Deprecate loudly. Give clients months to migrate, not weeks. Your version 1 will outlive your tenure at the company.

Authentication:

Use established standards. OAuth 2.0 for user authentication. API keys for service-to-service. JWT for stateless auth.

Don’t invent your own signature scheme. Don’t use Basic Auth over HTTP. Don’t store passwords in plain text. These aren’t opinions. These are baseline competence.

API keys go in headers, not query parameters. Query parameters get logged everywhere. Logs leak.

Authorization: Bearer YOUR_API_KEY

Rate limit by API key or user. Return current limits in headers.

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 432
X-RateLimit-Reset: 1640000000

When rate limited, return 429 Too Many Requests and tell them when to retry.

Retry-After: 60

HATEOAS: The Unrealized Dream

Hypermedia as the Engine of Application State. It’s a core REST principle. Almost nobody implements it. You probably shouldn’t either.

The idea: responses include links to related resources and available actions. Clients follow links instead of constructing URLs.

{
  "id": 123,
  "name": "John Doe",
  "links": {
    "self": "/users/123",
    "posts": "/users/123/posts",
    "avatar": "/users/123/avatar"
  }
}

It’s elegant in theory. In practice, it adds overhead, complicates responses, and most client developers ignore it anyway. They hardcode URLs. They read documentation, not responses.

Use HATEOAS if you’re building a genuinely hypermedia-driven application. Otherwise, write good documentation and move on.

Documentation Is Part of the API

Your API is only as good as its documentation. Undocumented endpoints might as well not exist.

OpenAPI is the standard. Generate it from code or write it by hand, but have it. Interactive documentation is table stakes now.

Document error responses. Document rate limits. Document authentication. Document pagination. Document every query parameter and what it does.

Show examples. Real ones. Not “string” and “integer” in a schema, but actual requests and responses.

POST /users
Content-Type: application/json

{
  "email": "[email protected]",
  "name": "Jane Smith",
  "role": "admin"
}

Response: 201 Created
Location: /users/456

{
  "id": 456,
  "email": "[email protected]",
  "name": "Jane Smith",
  "role": "admin",
  "created_at": "2024-12-22T10:30:00Z"
}

Keep documentation up to date. Out-of-date documentation is worse than no documentation because it’s actively misleading.

What You Send Back Matters

JSON is the default. Don’t fight it. XML lost. If you need XML for legacy reasons, support both, but make JSON the default.

Use camelCase or snake_case consistently. Not both. JavaScript developers expect camelCase. Python and Ruby developers expect snake_case. Pick your audience.

ISO 8601 for dates. Always. UTC preferred. Include timezone info if you must use local time.

"created_at": "2024-12-22T10:30:00Z"

Don’t stringify numbers. "id": "123" should be "id": 123. JSON supports numbers. Use them.

Omit missing values entirely. Be consistent. Returning "" for missing strings and 0 for missing numbers is confusing.

Envelope or no envelope?

// Envelope
{
  "data": { "id": 123, "name": "John" },
  "meta": { "timestamp": "2024-12-22T10:30:00Z" }
}

// No envelope
{ "id": 123, "name": "John" }

Envelopes let you add metadata without polluting the resource. But they add nesting. I prefer no envelope for simple cases, envelope when you need metadata or are returning errors.

Idempotency:

GET, PUT, and DELETE should be idempotent. Make the same request twice and the result should be the same.

POST is the odd one out. Two identical POST requests might create two resources. That’s a problem for unreliable networks.

Idempotency keys solve this. Client sends a unique key with each request. Server checks if it’s seen that key before.

POST /orders
Idempotency-Key: a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6

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

If the server has seen that key, it returns the previous response instead of creating a duplicate order. Financial transactions, email sending, any irreversible operation benefits from this.

Stripe and Twilio does this. You should too if you’re doing anything important.

The Things Nobody Talks About

Bulk operations: /users/bulk endpoints that accept arrays of operations. Faster than N individual requests. Harder to implement. Worth it at scale.

Partial responses: Let clients specify which fields they want. GET /users/123?fields=id,name,email. Reduces bandwidth. Increases complexity. Useful for mobile clients.

ETags and conditional requests: Support If-None-Match and If-Modified-Since. Let clients cache effectively. Reduce server load. Barely anyone uses them, but when they do, you look professional.

Webhook alternatives: Polling is wasteful. Webhooks are finicky. Server-sent events (SSE) or WebSockets might be better if you control both ends.

Request IDs: Generate a unique ID for every request. Return it in responses. Log it everywhere. When something breaks, clients can give you the request ID and you can trace exactly what happened.

X-Request-ID: a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6

When to Break the Rules

Pragmatism beats purity.

Need to download a file? GET /files/123/download is clearer than trying to make it purely resourceful.

Need a health check endpoint? GET /health works. Nobody cares that “health” isn’t a resource.

Need to expose a complex algorithm? POST /recommendations/generate is fine. Not everything is a resource.

Background jobs? POST /jobs to create them, GET /jobs/123 to check status. Perfectly RESTful and perfectly practical.

The rules exist to make your API predictable and maintainable. When following a rule makes your API worse, break it. Just do it consistently.

What Matters More Than REST

Developer experience. Can someone use your API without reading 50 pages of documentation? Can they debug when something breaks? Can they iterate quickly?

Performance. REST doesn’t guarantee good performance. Careful design does. N+1 queries through your API will kill you. Overfetching will annoy your mobile users. Underfetching forces multiple round trips.

Evolution. Can you add features without breaking existing clients? Can you deprecate gracefully? Can you fix mistakes without a flag day migration?

Monitoring and observability. Can you tell when your API is slow? When it’s failing? Which endpoints are unused? Which clients are abusing rate limits?

Those things matter more than whether your API is theoretically RESTful.

The Real Constraints

REST was designed for the web. Long-lived client applications. Decentralized deployment. Unknown clients. Those constraints led to specific design choices.

Your API might have different constraints. Internal microservices don’t need versioning. Single-page apps might prefer GraphQL. Realtime systems might need WebSockets.

REST is a good default because it’s well-understood and supported everywhere. But it’s a default, not a requirement.

Build APIs You’d Want to Use

The best API design advice is simple: build something you wouldn’t hate to integrate with.

Clear URLs. Sensible status codes. Useful error messages. Good documentation. Consistent patterns. Those things aren’t REST-specific. They’re basic respect for whoever has to use what you built.

REST gives you a framework for making those decisions. It’s not the only framework. It’s not always the best framework. But it’s a decent starting point, and most APIs would be better if they at least tried to follow it.

Design your API like you’re going to maintain it for a decade and document it for someone who will curse your name when you’re gone. That’s the real constraint that matters.

Continue reading

Next article

The Poor Man's Homelab

Related Content