API Design That Doesn't Rot
The decisions that feel minor at v1 — versioning strategy, error shapes, contract discipline — are the ones that determine whether your API is a pleasure or a graveyard to maintain at v3.
DESCRIPTION
An API is a contract. Unlike internal code, you cannot refactor it in isolation — every breaking change is a bug report waiting to happen in someone else's system. Most API design mistakes aren't made out of ignorance. They're made under deadline pressure, when "we'll fix it later" feels reasonable. Later never comes.
LESSON 1: VERSION FROM DAY ONE
The single most expensive API mistake is shipping v1 without a versioning strategy. Adding /v1/ to your route prefix costs nothing on day one. Migrating 40 consumers to a new contract two years later costs weeks.
GET /api/v1/users/123
GET /api/v2/users/123
URL versioning is explicit and debuggable. Header versioning is clean but invisible in logs and browser history. Pick URL versioning until you have a strong reason not to.
LESSON 2: YOUR ERROR SHAPE IS PART OF YOUR CONTRACT
Most APIs return a 400 with a raw string message and call it validation. That's not an error response — it's a log line dressed up as JSON. Consumers need to parse errors programmatically, not with string.Contains().
{
"type": "https://yourdomain.com/errors/validation-failed",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-abc123-01",
"errors": {
"email": ["Email is required.", "Email must be a valid address."],
"age": ["Age must be greater than 0."]
}
}
RFC 9457 (Problem Details) gives you a standard shape for free. Use it. Every error your API returns should be machine-readable, traceable, and consistent across every endpoint.
LESSON 3: NEVER BREAK IMPLICIT CONTRACTS
Breaking changes aren't just removing fields. They include: changing a field from nullable to required, narrowing an enum, changing a date format from ISO 8601 to a locale string, or returning an empty array where you used to return null. Consumers build assumptions around everything you return — not just what you documented.
// This is a breaking change. Null and [] are not the same to a consumer.
// Before
"tags": null
// After
"tags": []
Add a contract test suite that runs against your serialized responses. If the shape changes unexpectedly, the build breaks — not a production consumer.
LESSON 4: PAGINATION IS NOT OPTIONAL AT v1
Shipping a list endpoint that returns everything is a decision that compounds. The collection is small at launch, so it feels harmless. At 50,000 records it becomes an incident.
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalCount": 4823,
"hasNextPage": true
}
}
Cursor-based pagination scales better than offset for large or frequently-updated datasets. Either way, build it in from the first endpoint. Retrofitting pagination into a contract that shipped without it is a breaking change.
LESSON 5: IDEMPOTENCY IS A FEATURE, NOT A DETAIL
Any write operation that can be retried — and under network conditions, all of them can — needs idempotency handling. The client sends an idempotency key, the server deduplicates on it.
POST /api/v1/payments
Idempotency-Key: a3f9b2c1-4d8e-4f1a-9c2b-7e3d1a5f6b2c
Without this, a timeout that triggers a client retry creates a duplicate order, a double charge, or a ghost booking. Stripe got this right in 2012. There is no excuse for a new API to skip it.
LESSON 6: DOCUMENT THE DECISIONS, NOT JUST THE ENDPOINTS
Swagger covers what the API does. It does not cover why the versioning strategy is what it is, why a field is a string instead of an enum, or what the retry contract is. Put that in a DECISIONS.md alongside your spec.
The engineer maintaining this API in two years is not you. Give them the context they need to not repeat the mistakes that already got made.
SEE ALSO
rest-versioning(1), problem-details-rfc9457(7), idempotency-keys(3)