Good API design is invisible. Nobody praises an API for having sensible error messages or consistent naming conventions — but everyone notices when it doesn’t. After years of building APIs (and debugging other people’s), here are the principles I keep coming back to.
1. Version from Day One
The single most costly API mistake is shipping v1 without a versioning strategy. Once clients are in production, you can never break them — and “I’ll add versioning later” is how you end up with haunted legacy endpoints that can never be removed.
The simplest workable approach: put the version in the URL path — /api/v1/, /api/v2/. It’s not the most “RESTful” approach (proponents of content negotiation via Accept headers will object), but it’s transparent, easy to route, and works in every client including curl.
2. Error Responses Are Part of Your API Contract
Your error bodies should be as carefully designed as your success bodies. At minimum:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The 'email' field must be a valid email address.",
"field": "email",
"requestId": "a3f2e9c1"
}
}
The code is machine-readable and stable across versions. The message is human-readable and can change. The requestId makes support conversations possible. Clients should never need to parse error message strings — give them a code they can switch on.
3. Don’t Expose Your Database Schema
A common shortcut is to map API resources directly to database tables. It’s fast to build but creates painful coupling:
- Renaming a DB column becomes a breaking API change.
- Internal implementation details (surrogate keys, junction tables, soft-delete flags) leak into the contract.
- Pagination, filtering, and sorting that make sense for a DB query may not make sense for an API consumer.
Treat your API as a separate layer. Use DTOs. Name things from the consumer’s perspective, not the storage perspective.
4. Pagination: Cursor > Offset
Offset-based pagination (?page=3&limit=20) is easy to implement but breaks under concurrent writes — if a record is inserted on page 1 while a client is reading page 2, they’ll see a duplicate or miss an item.
Cursor-based pagination is the correct solution for most real applications:
GET /posts?after=eyJpZCI6NDJ9&limit=20
The cursor is an opaque token (usually a base64-encoded sort key + ID). It’s stable and stateless, and the server doesn’t need to remember anything between requests.
5. Use HTTP Semantics Correctly — Then Be Consistent
GET is idempotent and safe. POST creates. PUT is a full replacement. PATCH is a partial update. DELETE removes. These semantics matter because they affect caching, retry logic, and browser behavior.
That said, I’ve seen more harm from inconsistent use within a single API than from any particular choice of HTTP method. If your team decides POST handles updates, document it and apply it everywhere rather than mixing conventions.
6. Design for Change, Not Perfection
No API design survives first contact with real users. Build observability in early (request logging with correlation IDs, latency metrics per endpoint), deprecate old fields with Deprecated response headers before removing them, and keep your changelog up to date.
The best API I ever worked with wasn’t the most technically elegant — it was the one with the best documentation, the most predictable behavior, and the fastest support response time.