API Design Principles That Stand the Test of Time — and the Ones That Don't
Most API advice ages like milk. Here's what actually holds up across REST, GraphQL, and gRPC: naming conventions that survive refactors, error models your clients will thank you for, versioning strategies that don't force breaking changes, and the pagination patterns that scale.
# API Design Principles That Stand the Test of Time — and the Ones That Don't
I've designed REST APIs that were abandoned within six months and one that's still running in production nine years later with zero breaking changes. The difference wasn't the framework or the serialisation format. It was the principles baked into the contract from day one.
Everyone has opinions on API design. Most of them are cargo-culted from a blog post written in 2014 by someone who'd never operated an API at scale. This post is the opposite — it's the principles I've validated by being woken up at 2 AM when they were violated, and the patterns that have survived decade-long production runs.
The Three Things That Actually Matter
After a decade of building and maintaining APIs, I've narrowed it down to three non-negotiable principles. Everything else is negotiable:
- Your API will outlive your implementation. The contracts you write today will still be in production five years from now, proxied through three layers of middleware you haven't built yet, consumed by a mobile app that's been abandoned by its original team.
- Errors are part of the contract. Clients don't just need successful responses — they need to understand failures programmatically without parsing human-readable strings.
- Predictability beats cleverness. A boring, consistent API that follows the same patterns everywhere will be loved. An API with one beautifully elegant endpoint and twenty inconsistent ones will be hated.
These sound obvious. Yet I keep seeing APIs that violate all three.
Naming: The Most Expensive Decision You'll Make
Naming consistency is the single highest-leverage investment in API design. Every inconsistency compounds: your client library needs special cases, your documentation needs exceptions, and every new team member needs to learn Yet Another Pattern.
Resource Names: Plural or Singular?
Always plural for collections. This isn't a style choice — it's about URI consistency:| Pattern | Example | Problem |
|---|---|---|
| ✅ Plural collections | GET /users/123 | Consistent: /users returns a list, /users/123 returns one |
| ✅ Plural collections | GET /users/123/orders | Nested collections follow the same rule |
| ❌ Mixed | GET /user/123 + GET /orders | Why is user singular but orders plural? |
| ❌ Verb-based | GET /getUser | You've made HTTP verbs redundant |
The rule is simple: if the URI segment represents a collection of resources, make it plural. /users, /orders, /invoices. Every time.
Action Names: Verbs Don't Belong in URLs
// ❌ RPC-in-disguise URLs
POST /api/activateUser
POST /api/generateInvoice
POST /api/resendEmailConfirmation
// ✅ Resource-oriented URLs — actions are sub-resources
POST /users/123/activate
POST /invoices/456/generate
POST /users/123/email-confirmation/resend
The second form is better because:
- The resource is explicit (
/users/123) - The action reads like a state transition on that resource
- You can add permissions at the resource level, not the action level
- It's obvious what entity an audit log entry refers to
If you find yourself writing POST /api/do-something-to-X, stop and ask: "What resource is this acting on, and what state transition represents?" The answer is your API endpoint.
Property Naming: snake_case, camelCase, or kebab-case?
Pick one and enforce it with a linter. I don't care which one you pick — it's the inconsistency that kills.
That said, here's my recommendation based on ecosystem:
| Format | Best For | Why |
|---|---|---|
snake_case | REST APIs consumed by multiple languages | Works cleanly in Python, Ruby, Go, SQL. A pain in JavaScript (needs transformation) |
camelCase | APIs consumed primarily by frontend JS/TS | JavaScript-native. Requires server-side transformation in most backends |
PascalCase | GraphQL schemas (by convention) | GraphQL convention, but don't use in JSON APIs |
kebab-case | URI paths only | Fine for URLs, terrible for JSON keys (needs quoting in most languages) |
snake_case. It's the most broadly compatible across languages and frameworks. If you're building an API that's 80% consumed by a single frontend, camelCase is fine — just be consistent across all endpoints.
The important thing: don't let your framework's serialiser decide for you. Configure it explicitly.
// Express with explicit serialisation config
import { serialize } from "camelcase-keys";
import { deserialize } from "snakecase-keys";
// At the boundary — not scattered across controllers
app.use(express.json({ reviver: reviveSnakeCase }));
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
return originalJson(serialize(body, { deep: true }));
};
next();
});
Error Models: Your Most Underrated Contract
Most APIs return errors like this:
{
"error": "User not found"
}
``$
This is useless. It's a human-readable string that no client can programmatically act on. What does the client do with "User not found"? Display it to the user? Retry? Navigate to a create-user flow? Without a machine-readable error type, every client has to string-match, which breaks the moment you rephrase the message.
### The RFC 7807 Problem Details Model
Use a structured error model. RFC 7807 (now RFC 9457) gives you a solid starting point:typescript
interface ProblemDetails {
type: string; // URI identifying the error class
title: string; // Short, human-readable summary
status: number; // HTTP status code
detail: string; // Human-readable explanation (for developers)
instance: string; // URI identifying the specific occurrence
// Extensions — domain-specific fields
errors?: FieldError[];
}
interface FieldError {
field: string;
code: string;
message: string;
rejectedValue?: unknown;
}
Concrete examples:json
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.rrezvin.com/errors/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/orders/req-abc123",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address",
"rejectedValue": "not-an-email"
},
{
"field": "items",
"code": "TOO_FEW",
"message": "Order must contain at least one item"
}
]
}
`$
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
{
"type": "https://api.rrezvin.com/errors/rate-limited",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the rate limit of 100 requests per minute. Retry after 42 seconds.",
"instance": "/orders/req-def456"
}
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{
"type": "https://api.rrezvin.com/errors/dependency-down",
"title": "Payment Service Unavailable",
"status": 503,
"detail": "The payment processing service is temporarily unavailable. Our team has been notified.",
"instance": "/orders/req-ghi789",
"retryAfter": 30
}
``$
**The `type` field is the key.** It's a stable identifier that clients can switch on. Don't make it `about:blank` — make it a documented URI that links to documentation for that error class.typescript
// Client-side handling — switching on the type URI
async function createOrder(input: CreateOrderInput): Promise<Order> {
const response = await fetch("/orders", { method: "POST", body: JSON.stringify(input) });
if (!response.ok) {
const problem = await response.json() as ProblemDetails;
switch (problem.type) {
case "https://api.rrezvin.com/errors/validation-error":
// Show field-level errors in the form
for (const err of problem.errors ?? []) {
showFieldError(err.field, err.message);
}
return;
case "https://api.rrezvin.com/errors/rate-limited":
// Extract retry info from headers
const retryAfter = response.headers.get("Retry-After");
scheduleRetry(Number(retryAfter) * 1000);
return;
case "https://api.rrezvin.com/errors/dependency-down":
showBanner("Payment is temporarily unavailable. Please try again later.");
return;
default:
showBanner("Something went wrong. Our team has been notified.");
reportError(problem);
return;
}
}
return response.json();
}
$$
Versioning: The Anti-Pattern Hall of Fame
I need to be blunt: URL-based versioning (/v1/orders, /v2/orders) is a crutch, not a strategy. It tells your clients "we expect to break you" and tells your team "we can make a mess in v1 and clean it up in v2." Neither is true.
The Better Way: Compatible Evolution
The vast majority of API changes can be backward-compatible:
// Adding a field — always compatible
interface OrderV1 {
id: string;
status: string;
total: number;
}
// ✅ V2: New optional field, old clients ignore it
interface OrderV2 {
id: string;
status: string;
total: number;
discountApplied?: number; // optional = non-breaking
}
// Adding an enum value — compatible 99% of the time
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled" | "refunded";
// Changing a field type — NOT compatible
interface OrderV3 {
// ❌ Changed from number to string — breaks every client
total: string;
}
``$
**Rules for compatible evolution:**
1. Never remove a field — mark it deprecated and document the replacement
2. Never change a field's type — add a new field instead
3. Never require a new field — all new fields are optional
4. Never change the semantics of an existing field — create a new endpoint if behaviour differs
### When You Actually Need Versioning
There are two scenarios where versioning is justified:
1. **Security-critical changes** — You discover a fundamental design flaw that creates a vulnerability
2. **Fundamental semantics changed** — Your `status` field had three values and now it has ten, and clients built switch statements on the original three
In those cases, use **header-based versioning**, not URL-based:typescript
// ❌ URL versioning — pollutes URIs, makes tooling harder
GET /v2/users/123
// ✅ Accept header versioning — clean URIs, explicit opt-in
GET /users/123
Accept: application/json; version=2
// ✅ Custom header — also valid, same principle
GET /users/123
X-API-Version: 2
$
The reason: URL versioning encourages running multiple API versions in the same codebase, which becomes a maintenance nightmare. Header-based versioning makes you think about the migration, and the version is metadata about the request, not the resource.
Sunset Headers: Do Your Clients a Favour
When you deprecate a field or endpoint, tell clients about it in-band:
// Response headers for deprecated fields
HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Deprecation: true
Link: <https://api.rrezvin.com/docs/migration-v2>; rel="deprecation"
{
"id": "123",
"status": "confirmed",
"total": 29900,
"discountApplied": 5000,
// Deprecated field — still present, but clients should migrate
"coupon_code_deprecated": "SUMMER2025"
}
`
// Middleware to set deprecation headers
function deprecateEndpoint(deprecatedAt: Date, sunsetAt: Date) {
return (req: Request, res: Response, next: NextFunction) => {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", sunsetAt.toUTCString());
res.setHeader("Link", '<https://api.rrezvin.com/docs/migration>; rel="deprecation"');
next();
};
}
``$
Give clients at least six months between deprecation and removal. A year is better.
## Pagination: The Decision That Ages
Every API starts with no pagination, adds it at 10,000 records, and regrets the choice at 100,000. The pagination pattern you choose affects client code, database load, and frontend UX for the life of the API.
### Offset Pagination (The Default — But Wrong Choice)typescript
// The temptation — simple to implement, terrible at scale
GET /orders?offset=100&limit=50
$
- Consistency failures — If a record is inserted at position 0 between your two requests, page 3 will show a record you already saw on page 2
- Performance degradation — OFFSET 10000 LIMIT 50
still reads 10,050 rows from the database (PostgreSQL walks past the 10,000 skipped rows) - Cursor breakage — Clients bookmarking URLs get incorrect results after new data arrives
| Page | SQL Generated | Rows Scanned (PostgreSQL) |
|---|---|---|
| 1 | OFFSET 0 LIMIT 50 | 50 |
| 100 | OFFSET 4950 LIMIT 50 | 5,000 |
| 1000 | OFFSET 49950 LIMIT 50 | 50,000 |
| 10000 | OFFSET 499950 LIMIT 50 | 500,000 |
This isn't theoretical. I've seen a GET /transactions?page=8421 query take 14 seconds because PostgreSQL had to scan and sort half a million rows to return 50.
Cursor-Based Pagination (The Right Choice)
// Cursor-based — stable, fast, consistent
GET /orders?cursor=eyJpZCI6IjEyMyJ9&limit=50
// The response
{
"data": [...],
"nextCursor": "eyJpZCI6IjUwMCJ9",
"hasMore": true
}
``
``typescript
// Server-side implementation
async function paginateOrders(
db: Pool,
cursor?: string,
limit: number = 50
): Promise<PaginatedResponse<Order>> {
const decoded = cursor ? decodeCursor(cursor) : null;
const query = decoded
? SELECT id, user_id, total, status, created_at
FROM orders
WHERE (created_at, id) > ($1::TIMESTAMPTZ, $2::UUID)
ORDER BY created_at ASC, id ASC
LIMIT $3
: SELECT id, user_id, total, status, created_at
FROM orders
ORDER BY created_at ASC, id ASC
LIMIT $1;
const params = decoded
? [decoded.createdAt, decoded.id, limit + 1]
: [limit + 1];
const result = await db.query(query, params);
const rows = result.rows.slice(0, limit);
const hasMore = result.rows.length > limit;
return {
data: rows,
nextCursor: hasMore
? encodeCursor({ id: rows[rows.length - 1].id, createdAt: rows[rows.length - 1].created_at })
: null,
hasMore,
};
}
// Cursor encoding — opaque to clients, includes only what we need
function encodeCursor(params: { id: string; createdAt: string }): string {
return Buffer.from(JSON.stringify(params)).toString("base64url");
}
function decodeCursor(cursor: string): { id: string; createdAt: string } {
return JSON.parse(Buffer.from(cursor, "base64url").toString());
}
$
- Stable — New records inserted before the cursor don't affect subsequent pages
- Fast — The WHERE
clause uses the index directly; no wasted row scans - Predictable performance — Each page costs the same regardless of position in the dataset
Consistent Field Ordering
Paired with cursor pagination, you need stable sorting:
// Always order by (created_at DESC, id DESC) — not just created_at
// Why: created_at can have ties (same millisecond). id breaks the tie.
const query = `
SELECT * FROM orders
WHERE (created_at, id) < ($1::TIMESTAMPTZ, $2::UUID)
ORDER BY created_at DESC, id DESC
LIMIT $3
Never order by a single column without a tiebreaker. Even UUID v7 (time-sortable) benefits from an explicit secondary sort.
Idempotency: The Killer Feature Your API Needs
An idempotent API lets clients safely retry requests. Without it, every network error becomes a potential duplicate charge, duplicate order, or duplicate email.
The Idempotency Key Pattern
interface CreateOrderRequest {
items: OrderItem[];
idempotencyKey: string; // Client-generated UUID
}
async function handleCreateOrder(req: Request, res: Response): Promise<void> {
const { idempotencyKey, items } = req.body;
await db.transaction(async (tx) => {
// Check if we've already processed this key
const existing = await tx.query(
"SELECT response_body FROM idempotency_cache WHERE key = $1",
[idempotencyKey]
);
if (existing.rows.length > 0) {
// Return the cached response — no side effects
res.status(201).json(existing.rows[0].response_body);
return;
}
// Process the request — guaranteed only once per key
const order = await createOrder(tx, items);
// Cache the response with a TTL
await tx.query(
`INSERT INTO idempotency_cache (key, response_body, created_at)
VALUES ($1, $2, NOW())`,
[idempotencyKey, JSON.stringify(order)]
);
res.status(201).json(order);
});
}
Idempotency key best practices:
- Client generates it — The server can't predict it, and the client is the one retrying
- TTL of 24 hours — Long enough for retry scenarios, short enough to not bloat the cache
- Store alongside the resource — The idempotency cache in the same database as the resource ensures transactional consistency
- Key per request method — POST
andPATCHneed idempotency;GET,PUT, andDELETEare idempotent by HTTP definition
Rate Limiting: Tell Clients What to Do
Rate limiting that just returns 429 Too Many Requests with no context is hostile. Your rate limit response should tell the client:
- How many requests they can make
- When the window resets
- How long to wait
`typescript
// Rate limit middleware with informative headers
function rateLimiter(opts: { maxRequests: number; windowMs: number }) {
const store = new SlidingWindowStore(opts);
return (req: Request, res: Response, next: NextFunction) => {
const result = store.check(req.ip);
// Informational headers — present on every response
res.setHeader("X-RateLimit-Limit", opts.maxRequests);
res.setHeader("X-RateLimit-Remaining", result.remaining);
if (result.shouldLimit) {
const resetTime = new Date(Date.now() + opts.windowMs);
res.setHeader("Retry-After", Math.ceil(opts.windowMs / 1000));
res.setHeader("X-RateLimit-Reset", resetTime.toISOString());
res.status(429).json({
type: "https://api.rrezvin.com/errors/rate-limited",
title: "Rate Limit Exceeded",
status: 429,
detail: Rate limit of ${opts.maxRequests} requests per ${opts.windowMs / 1000}s exceeded. Retry after ${Math.ceil(opts.windowMs / 1000)} seconds.,
instance: /rate-limited/${req.id},
});
return;
}
next();
};
}
$$
The headers matter because automated clients can read them. A well-behaved client with X-RateLimit-Remaining: 0 can back off before hitting the 429. A client that sees Retry-After: 30 knows exactly when to retry.
The Consistency Checklist
Before shipping any endpoint, run this checklist:
- Does the URL pattern match every other endpoint? If /users
is plural,/ordersmust also be plural. - Does the response format match every other endpoint? Same top-level keys (data
, maybemeta), same error format. - Are all required fields really required? Could this be optional and pushed to a follow-up PATCH
? - Is the error response structured? Every error returns { type, title, status, detail, instance }
. - Is pagination cursor-based? No offset pagination in new endpoints.
- Is there an idempotency key for mutations? Every POST
andPATCHsupports idempotency. - Are the cache headers set? Cache-Control
,ETag, andLast-Modifiedfor GET endpoints. - Are rate limit headers present? Every response includes rate limit metadata.
Final Thought
The API you design today will be consumed by clients you haven't imagined, from devices that don't exist yet, operated by developers who will judge your competence based on your error messages and pagination decisions.
Make it boring. Make it consistent. Make it predictable. And for the love of everything holy, never return { "error": "Something went wrong" }`.
The best API is the one nobody has to think about. It works. It's documented. It tells you what went wrong when something does. And it stays that way for years without breaking your clients.
That's the design that stands the test of time.