REST API Design in Practice — The Decisions That Determine Developer Experience
by Eric Hanson, Backend Developer at Clean Systems Consulting
Resource modeling — nouns, not verbs
REST resources are nouns. HTTP methods are the verbs. A URL that includes a verb is a sign that the action doesn't fit the resource model cleanly:
# Action-oriented — common but problematic
POST /api/orders/123/cancel
POST /api/users/456/activate
POST /api/payments/789/refund
# Resource-oriented
POST /api/orders/123/cancellation # creates a cancellation resource
PATCH /api/users/456 {"status": "active"} # updates user state
POST /api/refunds {"paymentId": "789"} # creates a refund resource
The resource-oriented approach is not always cleaner — POST /cancellation requires thinking about what a "cancellation" is as a resource, which isn't always natural. The test: if the action has meaningful state you'd want to retrieve later — a cancellation with a timestamp and reason, a refund with a status and amount — modeling it as a resource is correct. If the action is a pure command with no retrievable state, POST with a verb path is acceptable.
Nesting depth. Nested resources (/users/{userId}/orders/{orderId}) express ownership relationships. Two levels of nesting are manageable. Three or more becomes unwieldy for clients:
# Fine — clear ownership
GET /users/123/orders
GET /users/123/orders/456
# Unwieldy — client must know the full path to access a leaf resource
GET /users/123/orders/456/line-items/789/variants/012
Resources that are addressable by their own ID — orders, invoices, products — should also be accessible at a top-level collection path (/orders/456) regardless of nesting. Deep nesting often signals that the resource should be addressable by its own ID without traversing the parent hierarchy.
HTTP methods — use them semantically
GET is safe (no side effects) and idempotent (multiple identical requests produce the same result). PUT is idempotent — applying it multiple times produces the same result as applying it once. POST is neither safe nor idempotent. DELETE is idempotent. PATCH is neither safe nor idempotent.
The idempotency of PUT has a specific implication: PUT /orders/123 replaces the entire order. If the client sends only the fields it wants to change, missing fields are treated as null. PATCH applies a partial update — only the fields sent are changed.
// PUT — replaces entire resource
PUT /orders/123
{"status": "shipped", "trackingNumber": "TRK-456"}
// Fields not included (items, total, userId) would be null — wrong if you meant partial update
// PATCH — applies partial update
PATCH /orders/123
{"trackingNumber": "TRK-456"}
// Only trackingNumber is updated — status, items, total, userId unchanged
In practice, many APIs use PUT for partial updates (the Rails convention), which is technically incorrect but widely understood. Whatever you choose, be consistent — a mix of PUT for full replacement and PATCH for partial update in the same API causes client bugs.
Status codes — semantic precision matters
The status code is the first thing a client checks. Using it incorrectly forces clients to parse the body to understand what happened:
200 OK — success, body contains result
201 Created — success, resource was created, Location header has the new URL
204 No Content — success, no body (DELETE, successful PATCH with no return value)
400 Bad Request — client sent invalid data (failed validation)
401 Unauthorized — authentication required or authentication failed
403 Forbidden — authenticated but not authorized
404 Not Found — resource doesn't exist
409 Conflict — state conflict (duplicate, version mismatch, concurrent modification)
422 Unprocessable — valid format but failed business validation
429 Too Many Requests — rate limit exceeded
500 Internal Server — unexpected server error (never for expected failures)
The 400 vs 422 distinction: 400 is for malformed requests — missing required fields, wrong type, invalid format. 422 is for requests that are structurally valid but fail business rules — a well-formed order request where the product is out of stock. Many APIs use 400 for both; the distinction is worth making because clients can handle them differently.
The 500 line: never return 500 for a condition you anticipated. A payment decline is not a server error — it's an expected business outcome. Return 422 or a domain-specific status code. Reserve 500 for genuinely unexpected failures where the server has no better characterization.
Error response design
The error response shape is the part of an API design that clients will write the most code against. It must be consistent and machine-readable:
{
"errors": [
{
"code": "validation_failed",
"message": "Email address is already registered",
"field": "email",
"traceId": "abc123def456"
}
]
}
Four fields with specific purposes:
code— machine-readable, stable identifier. Clients branch on this. Never change a code once published.message— human-readable, for developers. Can change wording freely.field— for validation errors, identifies which input caused the problemtraceId— links the error to server logs. Essential for debugging.
A traceId in every error response — including 500s — transforms "an error occurred" into "here's the log entry." Clients can include it in bug reports; support teams can look it up immediately.
The code values must form a stable, versioned contract. Publish a list of possible codes for each endpoint in documentation. Adding new codes is backward compatible; removing or changing codes is a breaking change.
Versioning — choosing the approach you can live with
Three versioning strategies, each with tradeoffs:
URL path versioning (/api/v1/orders): explicit, easy to test in browsers, easy to route at the proxy/gateway layer, visible in logs. The most practical for most APIs. The downside: clients must update base URLs when upgrading versions.
Header versioning (Accept: application/vnd.myapp.v2+json): cleaner URLs, but harder to test without tooling, harder to log and proxy. Preferred in academic API design; less common in practice.
Query parameter versioning (/orders?version=2): same visibility as URL versioning but semantically awkward — version isn't a filter, it's a protocol version. Generally the weakest option.
Regardless of strategy, plan for the transition: v1 and v2 must coexist until clients migrate. The practical implementation:
// Both versions served simultaneously
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 { ... }
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 { ... }
The v2 controller can share service layer code with v1; only the request/response shapes and behaviors differ. Sunset v1 with a Deprecation and Sunset header that tells clients when it will be removed:
Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset: Sun, 01 Jul 2026 00:00:00 GMT
Link: <https://api.example.com/api/v2/orders>; rel="successor-version"
Pagination — cursor beats offset at scale
Offset pagination (?page=2&per_page=25) is familiar but breaks on live data: if records are inserted between page 1 and page 2 requests, page 2 may repeat or skip items. For stable data that doesn't change between requests, offset is fine.
Cursor-based pagination encodes position:
{
"data": [...],
"meta": {
"nextCursor": "eyJpZCI6MTAwfQ==",
"hasMore": true,
"total": null
}
}
The cursor is opaque to clients — Base64-encoded position data they pass back unchanged. Exposing cursor internals (like raw IDs) constrains your implementation; an opaque cursor lets you change the pagination mechanism without a breaking change.
Avoid total in cursor-based pagination. A total count requires a separate COUNT(*) query — expensive on large tables. If clients don't need the total, don't pay for it. If they do, make it opt-in: ?includeTotalCount=true.
Field selection and response shaping
A resource with 50 fields sent in full on every response is wasteful for clients that need 5. Two approaches:
Sparse fieldsets (?fields=id,status,total): client specifies which fields to return. Reduces payload size, reduces serialization cost. Requires server-side field filtering.
Projection endpoints for fundamentally different views of the same resource: /orders/{id}/summary vs /orders/{id}. When two consumers consistently need very different subsets of a resource, separate endpoints are cleaner than a general field selection mechanism.
The practical minimum: always include id and enough fields that clients can display the resource meaningfully. Don't require clients to make two requests to render a list item — include the fields a list view needs in the collection response.
The design decisions that age poorly
Exposing internal IDs in URLs. Auto-increment integer IDs in URLs expose record counts (how many orders do you have?), enable enumeration attacks, and require coordination when merging databases. UUIDs or opaque identifiers are better at boundaries.
Boolean flags that accumulate. A resource that starts with isActive accrues isLocked, isSuspended, isPending, isVerified over time. These flags often represent states in an implicit state machine. Model the state machine explicitly — a status enum with defined transitions — rather than accumulating boolean flags.
Arrays vs paginated resources for relationships. GET /orders/123 returning the full list of line items inline is fine when orders have 1–10 items. It's wrong when orders can have 10,000 items. Decide early whether embedded arrays should be paginated sub-resources.
Date formats. Always ISO 8601 with timezone: 2026-04-17T14:30:00Z. Never Unix timestamps in some endpoints and ISO strings in others. Never local time without a timezone. Never MM/DD/YYYY.
Mutation via GET. Any endpoint that changes state when called with GET breaks caching, breaks browser pre-fetching, and violates REST semantics. GET must be safe. If state changes, use POST, PUT, PATCH, or DELETE.
The decisions that age well share a property: they're made once and hold up across clients, across time, and across scale changes. Invest design time in the error shape, the versioning strategy, and the resource model — these are the parts that compound.