← All posts

Opinions on API Design After Building Dozens of Them

Hard-won opinions on REST conventions, error handling, and pagination from building APIs across a lot of different projects

I've built a lot of APIs. Client work, side projects, internal tools — enough that I've stopped treating API design as a neutral engineering task and started treating it as a place where opinions matter. These are mine.

REST is fine until you start fighting it

REST conventions work well until you need an action that doesn't map cleanly to a resource. Then you start writing routes like POST /tracks/:id/archive or POST /playlists/:id/duplicate, and everyone gets uncomfortable because that doesn't look like REST.

My position: it's fine. HTTP doesn't actually require you to model everything as CRUD on a resource. RPC-style routes for actions are readable, debuggable, and honest. A route called /api/pitches/:id/send is clearer than trying to shoehorn a state change into a PATCH /pitches/:id that accepts a magic action field in the body.

Where I draw the line is inconsistency. Pick your conventions and hold them across the codebase. If you use POST /resource/:id/action for some things and PATCH /resource/:id with body flags for others, you get a API that requires archaeology to understand.

HTTP status codes are semantic, not decorative

I've worked on systems where every response came back 200 OK with an error field in the JSON body. This is the web equivalent of saying "everything went great" while handing someone a note that says "your house is on fire."

Use status codes for what they're for:

  • 400 when the caller sent bad input. Not a server failure — the request itself was wrong.
  • 401 when the caller isn't authenticated. 403 when they're authenticated but not allowed. These are not interchangeable.
  • 404 when the resource doesn't exist. Do not use this to mean "I don't want to tell you if it exists."
  • 409 when the request is valid but conflicts with current state — duplicate entry, stale update attempt, that kind of thing.
  • 422 for validation errors that pass structural checks but fail business logic.
  • 429 for rate limiting. Always include a Retry-After header.

The place I see teams go wrong is overcorrecting after learning this. You don't need to express every possible semantic nuance through status codes. Pick the closest fit and put detail in the response body.

Error response shape should be boring and consistent

The error response format doesn't need to be clever. It needs to be consistent. Every error from every route in your API should return the same shape.

A format I've settled on:

{ "error": { "code": "VALIDATION_FAILED", "message": "Rate tier end_age must be greater than start_age", "field": "end_age" } }

code is machine-readable — useful for clients that want to branch on specific errors programmatically. message is human-readable — useful for logging and debugging. field is optional but included for validation errors so the client knows exactly what failed.

The mistake I see most often is error messages written for the developer who built the system, not the developer consuming it. "Constraint violation on rates.end_age" is not an error message. "End age must be greater than start age" is.

Pagination is where API design debt compounds

Pagination is where a lot of APIs reveal how they were built: hastily, for the happy path, without thinking about what happens at scale.

Offset-based pagination (?page=2&limit=20) is fine for small datasets and admin UIs where exact positioning matters. It breaks down when data is changing underneath you — inserts between pages cause records to appear twice or not at all. It also gets expensive as page numbers climb, because the database is still scanning discarded rows.

Cursor-based pagination is better for most production use cases. The cursor encodes position in the result set, not an arithmetic offset. New rows don't affect your place. It's how every high-volume API I've worked with handles it once they've hit the offset ceiling.

For REST APIs, I return the cursor in the response alongside the data:

{ "items": [...], "pagination": { "next_cursor": "eyJpZCI6MzI0fQ==", "has_more": true } }

The caller passes ?cursor=eyJpZCI6MzI0fQ== on the next request. Simple, stateless, resilient to concurrent writes.

One thing I don't do: make callers parse link headers or follow hypermedia controls. That's theoretically elegant and practically annoying.

Versioning is a commitment you make to callers

I've shipped APIs that I fully controlled on both ends — no external consumers, just my own front end. For those, I don't version. If I change the contract, I change both sides simultaneously. Simple.

For any API with external consumers — partners, third-party integrations, public endpoints — versioning is a commitment, not a formality. The convention I use is path-based: /api/v1/, /api/v2/. Not ideal for purists, but pragmatically it makes versions explicit in URLs, which means they show up clearly in logs and browser dev tools without inspecting headers.

The discipline I try to maintain: backwards-compatible changes (adding optional fields, adding endpoints) go in the current version. Breaking changes (removing fields, changing semantics, renaming things) go in a new version. And when a new version ships, the old one has a sunset date that I communicate and honor.

One thing I got wrong for a long time

For years I treated validation as the API layer's responsibility and let business logic bleed into route handlers. Validation checks mixed with authorization checks mixed with database operations, all in one place.

The cleanup is unglamorous but worthwhile: route handlers should do exactly two things — parse and validate the incoming request, then call a service function that handles the actual logic. The service function doesn't know anything about HTTP. The route handler doesn't know anything about business rules.

When you split these responsibilities cleanly, testing gets easier, errors become more specific, and the next developer — including future you — can read the code without reconstructing what it's trying to accomplish.

None of this is novel. API design has been a solved problem in outline form for a long time. What takes experience is learning which principles to hold firmly, which to bend when the situation calls for it, and which ones sound convincing in blog posts but fall apart when you actually have to ship something.