← Back to all posts

API design that developers actually enjoy using.

An API is a product. Your consumers are developers. If they need to read the documentation three times to make a simple request, you've shipped a bad product — regardless of how elegant the implementation is.

Server infrastructure with glowing network connections

I've consumed hundreds of APIs over the years — payment gateways, mapping services, CMS platforms, internal microservices. The ones I remember fondly aren't the ones with the most features. They're the ones where I could guess the next endpoint after learning the first one. Where errors told me exactly what I did wrong. Where pagination just worked.

Good API design isn't about choosing the right technology. It's about making hundreds of small decisions that compound into an experience — an experience that either respects the developer's time or wastes it.

REST is not dead, GraphQL is not magic

The REST vs. GraphQL debate has been going on long enough to feel tired, and yet teams still make this choice based on hype rather than fit. Here's how I think about it:

REST is great for CRUD operations, caching, and simplicity. If your API is mostly "create a thing, read a thing, update a thing, delete a thing," REST gives you well-understood semantics, HTTP caching for free, and a mental model that every developer already knows.

GraphQL shines when you have complex relational data, mobile clients with bandwidth constraints, or consumers who need to avoid over-fetching. It's genuinely powerful when the shape of the data varies significantly between use cases.

The choice depends on your consumers, not on what's trendy. Most teams would be better served by a well-designed REST API than a poorly designed GraphQL one. The technology matters far less than the care you put into the design.

Naming conventions that scale

Naming is where API design either builds trust or erodes it. A few principles that have served me well:

  • Use nouns, not verbs. It's /users, not /getUsers. The HTTP method already carries the verb.
  • Plural for collections. /users returns a list, /users/123 returns a single resource. Consistent, predictable.
  • Consistent casing. Pick kebab-case for URLs and stick with it. /user-profiles, not /userProfiles or /user_profiles.
  • Nest for relationships. /users/123/orders reads like a sentence: "give me the orders for user 123." If the URL reads like a sentence, you're on the right track.

Error handling as a feature

Most APIs treat errors as an afterthought. The happy path gets all the attention, and error responses are whatever the framework spits out by default. This is a mistake. Developers spend more time debugging than celebrating — your error responses are arguably more important than your success responses.

Use a consistent error shape everywhere: { error: { code: string, message: string, details?: object } }. The code is machine-readable for programmatic handling. The message is human-readable for debugging. The details provide context when needed — validation errors, conflicting fields, etc.

HTTP status codes mean things — use them correctly. 400 means the request was malformed. 422 means the request was well-formed but semantically invalid. 409 means there's a conflict with the current state. Don't dump everything into 400 and call it a day.

Pagination worth thinking about

Pagination seems simple until it isn't. The approach you choose has real consequences:

  • Offset-based (?page=3&limit=20) is simple to implement and understand, but it breaks when data mutates between requests. Items get skipped or duplicated.
  • Cursor-based is robust against mutations — each page picks up exactly where the last one left off. It's harder to implement and you lose "jump to page 7," but for most APIs, that's a worthwhile trade.
  • Keyset pagination is the right choice for large datasets where performance matters. It uses indexed columns to seek directly to the next page.

Regardless of the approach, always return total, hasMore, and the pagination metadata in a wrapper object. Don't make clients guess whether there are more results. Don't make them count the items in the response array to figure out if they've reached the end.

Versioning without pain

API versioning is one of those topics where everyone has an opinion and nobody is entirely right. Here's the pragmatic take I've landed on:

URL versioning (/v1/users) is explicit and easy to understand. Developers can see the version right in the URL. But it's rigid — every version bump means new URLs, new documentation, new client updates.

Header versioning (Accept: application/vnd.api+json;version=2) is cleaner and keeps URLs stable. But it's invisible — developers forget to set the header, debugging gets harder, and curl commands get longer.

The pragmatic approach: version when you break contracts, don't version for additions. Adding a new field to a response? That's not a breaking change. Removing a field, changing a type, altering behavior? That's a new version. Additive changes don't need a new version. This keeps the version count low and the migration burden manageable.

The best API I've consumed had no documentation page — because the response shapes were so predictable that I could guess every endpoint after learning one.

Closing thought

Good API design is empathy engineering. Every naming choice, every error message, every pagination pattern is a conversation with a developer you'll never meet. You're making promises through your response shapes, setting expectations through your status codes, building trust through your consistency.

Make it a pleasant conversation. The developers consuming your API are trying to build something too — and the less time they spend fighting your interface, the more time they spend building something great on top of it.


↳ Keep reading

More from the blog.

— Stay in the loop

Like what you read?

Get an email when I publish something new. Roughly monthly, never spam.