API Design Principles That Age Well


APIs are commitment devices. Once external clients depend on your API, breaking changes become expensive - you have to coordinate with every client, version the API, or accept that some integrations will break. This asymmetry means API design decisions are much harder to undo than internal code decisions.

The principles that lead to APIs that age well are mostly about reducing surprises, communicating intent clearly, and leaving room to evolve without forcing clients to change.

Consistency Over Cleverness

The most valuable property of an API is consistency. Clients learn the patterns once and apply them everywhere. Inconsistency means learning exceptions, which means bugs and confusion.

Consistent naming:

# Consistent - all use the same pattern
GET  /users            -> list users
GET  /users/:id        -> get user
POST /users            -> create user
PUT  /users/:id        -> update user
DELETE /users/:id      -> delete user

GET  /posts            -> list posts
GET  /posts/:id        -> get post

# Inconsistent - clients have to learn exceptions
GET  /getUsers         -> ?
POST /user/create      -> ?
GET  /posts/:id/fetch  -> ?

Consistent response shapes:

// Always the same envelope for collections
{
  "data": [...],
  "meta": { "total": 47, "page": 1, "per_page": 20 }
}

// Always the same error shape
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "field": "email"
  }
}

When errors from your API all have the same shape, clients can handle them with one error handler. When each endpoint returns a different error format, every integration requires special-casing.

Resource Naming

REST APIs organize around resources (nouns), not actions (verbs). This isn’t a rule for its own sake - it produces more predictable and explorable APIs.

# Resource-oriented (predictable)
POST /orders                   -> create an order
GET  /orders/:id               -> get order details
POST /orders/:id/cancellations -> cancel an order (cancel as a sub-resource)

# Action-oriented (less predictable)
POST /createOrder
POST /getOrderDetails
POST /cancelOrder

The action-oriented pattern means clients have to know the exact action name. The resource-oriented pattern has predictable conventions clients can infer.

For operations that don’t map cleanly to CRUD (cancel, approve, publish), treat them as sub-resources or use a state transition pattern:

# Sub-resource pattern
POST /orders/:id/cancellations

# State transition pattern
PATCH /orders/:id
Body: { "status": "cancelled" }

HTTP Status Codes

Status codes communicate semantics. Clients use them to decide how to handle responses. Using them wrong confuses clients and breaks standard tooling.

The ones worth getting right:

200 OK - success
201 Created - resource was created (should include Location header pointing to the new resource)
204 No Content - success with no response body (common for DELETE)
400 Bad Request - client sent invalid data (invalid field value, missing required field)
401 Unauthorized - not authenticated (despite the name)
403 Forbidden - authenticated but not authorized
404 Not Found - resource doesn’t exist
409 Conflict - the request conflicts with current state (duplicate creation, stale update)
422 Unprocessable Entity - request is syntactically valid but semantically invalid
429 Too Many Requests - rate limited
500 Internal Server Error - something went wrong on the server

The common mistakes: returning 200 for errors (with error details in the body), returning 404 for “not authorized” (to avoid information disclosure), and returning 500 for client errors. All of these make clients harder to write.

Pagination

Any endpoint that returns a list needs pagination. Returning all records is fine when there are 10. It’s a problem when there are 100,000.

Two common approaches:

Offset-based: GET /posts?page=3&per_page=20. Simple for clients, easy to implement. Problem: if items are inserted or deleted between page fetches, clients see duplicates or skip items. Also slow for high offsets (database has to scan N rows to skip).

Cursor-based: GET /posts?after=cursor_token. The cursor encodes a position in the result set (often the ID or a timestamp of the last item seen). Consistent under insertions/deletions, efficient at any depth. Harder to implement and less convenient for UIs that need “jump to page 50.”

For most APIs, offset-based pagination is fine. For real-time feeds, infinite scrolls, and large datasets, cursor-based is worth the implementation cost.

Always return pagination metadata:

{
  "data": [...],
  "meta": {
    "total": 2847,
    "page": 3,
    "per_page": 20,
    "has_next": true,
    "next_cursor": "eyJpZCI6NDB9"
  }
}

Versioning

APIs change. How you handle change determines how much pain your clients feel.

URL versioning (/v1/users, /v2/users): explicit, easy to understand, lets you run multiple versions simultaneously. Downside: clients have to update URLs to adopt new versions.

Header versioning (Accept: application/vnd.api.v2+json): cleaner URLs, but less visible and harder to test with a browser.

Query parameter versioning (/users?version=2): works but pollutes query parameters.

URL versioning is the most common and has the lowest barrier to entry. The important thing is picking one strategy and being consistent.

Additive changes (new optional fields, new endpoints, new optional query parameters) are backward-compatible and don’t require a new version. Breaking changes (removed fields, changed field types, changed required parameters) do.

A versioning policy worth having: maintain the previous major version for at least 6-12 months after releasing a new one, with deprecation notices in responses:

Deprecation: Sat, 01 Jan 2027 00:00:00 GMT
Sunset: Sat, 01 Jul 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"

What Not to Do

Don’t return 200 with an error body: this breaks every HTTP library, monitoring system, and API gateway that uses status codes for routing.

Don’t expose internal identifiers that carry meaning: database auto-increment IDs reveal your record counts and can be enumerated. Use UUIDs or opaque IDs for resources accessible to external clients.

Don’t leak implementation details: returning error messages like “NullPointerException at line 47 of UserService.java” exposes your stack and gives attackers useful information. Return semantic error codes with user-friendly messages.

Don’t make required fields optional silently: if a field is required, make it required. Return 400 if it’s missing. An API that accepts missing required fields and returns garbage results is worse than one that rejects invalid inputs clearly.

Don’t change behavior without changing the version: silent behavior changes break clients who relied on the old behavior, even if the response shape is identical.

API design is interface design. The reader is another engineer - usually yourself in six months, or a client you’ll never meet. The standard is: would I be frustrated to receive this response? Would I be frustrated to debug this integration?



Read more