Most APIs I have inherited were not badly written. They were written for the moment and never expected to live past the next quarter. Then they did. API design is mostly the practice of deciding, on day one, which choices you are willing to be stuck with for five years, because some of them you will be. The endpoints that age well are rarely clever. They are the ones where someone thought about the second and third caller before the first one even existed.
Design for the caller you do not know yet
The single biggest mistake I see is shaping an endpoint around one screen in one app. You ship a /dashboard endpoint that returns exactly what the dashboard needs, and three months later the mobile team wants 80 percent of that data in a different shape. Now you either fork the endpoint or stuff it with flags. Both are bad. I try to model resources around the domain, not around the current UI. A user is a user. An order is an order. Let the client compose what it needs from clean resources instead of guessing at every view in advance.
This sounds obvious and almost nobody does it under deadline. The pull is always toward the convenient one-off, because it is faster today. The cost shows up later as a pile of near-duplicate endpoints that all have to change together when the data model shifts.
Names are the contract, so treat them that way
You can change almost anything behind an API. You cannot easily change a field name once real clients depend on it, because renaming it breaks them silently or loudly. So I spend real time on naming before launch. Consistent casing, consistent pluralization, consistent tense for booleans (isActive, not active in one place and enabled in another). Pick a convention and hold the line even when a single field would read slightly nicer the other way.
A few naming rules I will not bend on:
- Timestamps end in _at and are always UTC ISO 8601, never a raw epoch some clients will parse wrong
- Booleans read as a yes or no question, so is_, has_, or can_ prefixes
- IDs are strings, even when they are currently numeric, because the day you switch to UUIDs you do not want a type change rippling through every client
Versioning you can actually live with
Everyone argues about URL versioning versus header versioning. I have shipped both and the mechanism matters far less than the discipline behind it. What actually keeps an API alive is an additive-first mindset. Adding a field is safe. Removing one is not. Changing the meaning of an existing field is the worst, because it passes every type check and quietly corrupts whatever the client does next.
So I treat v1 as a promise: existing fields keep their existing meaning forever. New behavior gets new fields or new endpoints. A real v2 only happens when the model genuinely changed shape, and even then I keep v1 running with a clear deprecation window. Breaking changes are not free just because you bumped a number. Someone still has to migrate, and that someone is usually annoyed.
Errors are part of the API, not an afterthought
The happy path gets all the design attention and the error path gets a 500 with a stack trace. Then a client tries to handle failures and discovers every endpoint fails differently. I settled on one error envelope across the whole surface: a stable machine-readable code, a human-readable message, and an optional details object for field-level validation. The code is the contract. Clients switch on the code, never on the message string, because the message will get reworded the moment a designer reads it.
Get the status codes right too. A 400 means the caller sent something wrong and should not retry without changing it. A 409 means there was a conflict they might resolve. A 422 for validation. A 500 means it was your fault and a retry might work. When clients can trust your status codes, they can build sane retry logic instead of treating every non-200 as a black box.
Pagination and filtering, decided once
Any list endpoint will eventually return more rows than anyone wants in one response. Decide your pagination strategy before that happens, not during the incident when a query times out. I default to cursor-based pagination for anything that can grow without bound, because offset pagination quietly breaks when rows are inserted between requests and gets slow on large tables anyway. Cursors are a little more work to implement and save you from a class of bugs that are miserable to debug in production.
Same logic applies to filtering and sorting. Pick a small, documented set of filterable fields rather than letting callers query on anything. An open-ended filter API looks generous and turns into an unindexed query nightmare the first time someone filters on a column with no index behind it.
The boring stuff is the durable stuff
Good API design almost never feels exciting. It is consistent names, additive changes, predictable errors, and saying no to the convenient one-off endpoint. The flashy parts of a system get rewritten every couple of years. The API contract is the thing other teams build against, and every break costs someone a migration. If you optimize for the engineer two years out who has to integrate without being able to ask you a single question, you tend to make the right call. That person cannot read your mind. The API is the only thing they get.
Building something where this matters?
I am open to senior full-stack, Web3, or AI engineering roles, fully remote and any timezone. If the hard part of your product is fighting you, that is the work I like.
Get in touch →