Back to Blog
EngineeringJuly 10, 2026·14 min

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-designrestgraphqlgrpcpaginationerror-handlingversioningbackendsoftware-architecture

# 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:

  1. 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.
  2. Errors are part of the contract. Clients don't just need successful responses — they need to understand failures programmatically without parsing human-readable strings.
  3. 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:
PatternExampleProblem
✅ Plural collectionsGET /users/123Consistent: /users returns a list, /users/123 returns one
✅ Plural collectionsGET /users/123/ordersNested collections follow the same rule
❌ MixedGET /user/123 + GET /ordersWhy is user singular but orders plural?
❌ Verb-basedGET /getUserYou'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:

FormatBest ForWhy
snake_caseREST APIs consumed by multiple languagesWorks cleanly in Python, Ruby, Go, SQL. A pain in JavaScript (needs transformation)
camelCaseAPIs consumed primarily by frontend JS/TSJavaScript-native. Requires server-side transformation in most backends
PascalCaseGraphQL schemas (by convention)GraphQL convention, but don't use in JSON APIs
kebab-caseURI paths onlyFine for URLs, terrible for JSON keys (needs quoting in most languages)
If you're building a general-purpose REST API, use 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

$

Problems with offset pagination:
  1. 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
  2. Performance degradationOFFSET 10000 LIMIT 50 still reads 10,050 rows from the database (PostgreSQL walks past the 10,000 skipped rows)
  3. Cursor breakage — Clients bookmarking URLs get incorrect results after new data arrives

PageSQL GeneratedRows Scanned (PostgreSQL)
1OFFSET 0 LIMIT 5050
100OFFSET 4950 LIMIT 505,000
1000OFFSET 49950 LIMIT 5050,000
10000OFFSET 499950 LIMIT 50500,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());

}

$

Why cursor pagination wins:
  • 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

The trade-off: No random access ("go to page 50"). For most APIs this is acceptable — users want "next page" and "load more," not "page 50." If you absolutely need numbered pages for a UI, accept the trade-off or use a hybrid: keyset pagination for API clients and a separate count query for the UI page selector.

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 methodPOST and PATCH need idempotency; GET, PUT, and DELETE are 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:

  1. How many requests they can make
  2. When the window resets
  3. 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:

  1. Does the URL pattern match every other endpoint? If /users is plural, /orders must also be plural.
  2. Does the response format match every other endpoint? Same top-level keys (data, maybe meta), same error format.
  3. Are all required fields really required? Could this be optional and pushed to a follow-up PATCH?
  4. Is the error response structured? Every error returns { type, title, status, detail, instance }.
  5. Is pagination cursor-based? No offset pagination in new endpoints.
  6. Is there an idempotency key for mutations? Every POST and PATCH supports idempotency.
  7. Are the cache headers set? Cache-Control, ETag, and Last-Modified for GET endpoints.
  8. 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.

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch