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 problem
  • traceId — 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Avoid Chaos in Software Development Projects

Software projects can easily spiral out of control. Small missteps in planning, communication, or process often snowball into chaos.

Read more

Java Thread Management — Why ExecutorService Exists and How to Use It Well

Creating threads directly is expensive, uncontrolled, and hard to shut down cleanly. ExecutorService solves all three problems — but its default configurations have tradeoffs that matter in production.

Read more

Why New York Fintech Startups Are Quietly Outsourcing Backend Work to Async Contractors

Your compliance team is growing faster than your engineering team. And somehow you're still behind on the payments integration that was supposed to ship last quarter.

Read more

Testing Rails APIs with RSpec — My Practical Approach

Request specs in Rails test the full stack efficiently, but most teams either over-test at the wrong layer or under-test the cases that matter. Here is the structure that finds real bugs without slowing the suite down.

Read more