Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 028 / 100

API design principles I follow: best practices explained

Consistent APIs save every client team time.

Expona Admin8 min read

We've all felt the friction of working with inconsistent APIs. One team returns nested objects, another returns IDs only. One endpoint returns 50 results by default, another returns 500. After years of building backend systems, we've learned that API design principles I follow stem from one core belief: consistency saves engineering time across every client team. This article covers the five practices that eliminate surprise and keep your API predictable.

Resources as nouns — /orders/123/items, not /getOrderItems

REST means Representational State Transfer. The word "state" matters. Your API should expose resources—things—not actions. When we see /getOrderItems or /fetchUserProfile, we're looking at verbs masquerading as endpoints. Those are function calls dressed up as URLs.

The pattern is simple: use nouns to name resources, and HTTP verbs to describe what you do to them. An order is a resource. An item inside an order is a sub-resource. So you reach it like this:

typescript
GET /orders/123/items
GET /orders/123/items/456
POST /orders/123/items
DELETE /orders/123/items/456

This works because the resource path tells you what, and the HTTP method tells you how. A client seeing POST /orders/123/items knows immediately: we're creating a new item in this order. No ambiguity. No hidden side effects waiting to surprise you.

We've applied this to shipping systems, payment webhooks, and user management. In every case, the moment we moved from verb-based endpoints to noun-based resources, our documentation got shorter and our client teams asked fewer questions. They could predict the path before opening the spec.

One common trap: mixing resources with query parameters for filtering. Don't do this:

typescript
GET /orders?status=pending&customer=123

Do this:

typescript
GET /orders?status=pending&customer_id=123

The difference is small—consistent naming across your API. The second form pairs with your database fields and your TypeScript types without translation.

Predictable errors — One error format with codes clients can handle

Errors are part of your API contract. A client making a request will sometimes fail. Your job is to make that failure clear and actionable.

We standardize on a single error shape. Every error—whether it's a validation failure, a missing resource, or a server crash—comes back the same way:

typescript
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The order quantity must be at least 1.",
    "details": {
      "field": "quantity",
      "value": -5
    }
  }
}

The HTTP status code tells a proxy or load balancer what happened. The code field tells your client code how to handle it. The message is human-readable. The details object is optional but holds context that makes debugging real.

Here's a pattern we use across NestJS, Express, and .NET services:

  • VALIDATION_FAILED (400) — Input doesn't meet constraints.
  • NOT_FOUND (404) — Resource doesn't exist.
  • CONFLICT (409) — State conflict (duplicate, locked, etc.).
  • RATE_LIMITED (429) — Caller has exceeded quota.
  • INTERNAL_ERROR (500) — We failed, not you.

Client code can then do this:

typescript
try {
  const response = await fetch('/orders', { method: 'POST', body });
  if (!response.ok) {
    const { error } = await response.json();
    if (error.code === 'VALIDATION_FAILED') {
      // Show form error to user
      displayFieldError(error.details.field, error.message);
    } else if (error.code === 'RATE_LIMITED') {
      // Back off and retry
      scheduleRetry(5000);
    } else {
      // Log and alert
      logError(error);
    }
  }
} catch (e) {
  // Network failure
}

Predictable errors mean client teams don't have to parse error messages as strings. They can write error handling that survives code changes. We've seen this cut support tickets by half when rolling out new features.

Pagination by default — Every list endpoint is paginated from day one

A list endpoint without pagination is a liability. It starts innocent: 50 orders today, 500 next month, 50,000 six months later. Then a mobile client hits your endpoint and times out. Then a data scientist runs a bulk job and crashes your database.

We paginate from day one. Every list endpoint returns a bounded response:

typescript
{
  "data": [
    { "id": 1, "status": "pending" },
    { "id": 2, "status": "shipped" }
  ],
  "pagination": {
    "total": 1250,
    "page": 1,
    "limit": 50,
    "pages": 25
  }
}

The client knows how many results exist, where they are in the list, and how many calls they need to make. This is cursor-based pagination for offset-based pagination. For large datasets or real-time feeds, we shift to cursors:

typescript
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6IDEwMH0=",
    "has_more": true
  }
}

Cursors are opaque tokens—the client doesn't construct them, we do. This lets us change the underlying data structure without breaking clients. A cursor might encode an ID or a timestamp. The client doesn't need to know.

We always set sensible defaults and hard limits:

typescript
GET /orders?page=2&limit=50  // client chooses page
GET /orders?cursor=abc123&limit=50  // or cursor-based

// Maximum limit is 100 even if client asks for more
if (requestedLimit > 100) {
  limit = 100;
}

This protects your database from accidental full-table scans while letting clients optimize their own use cases. We've found 50 as a default works well—small enough to fetch in milliseconds from any database, large enough to feel natural in UI pagination controls.

Version on breaking changes — Additive changes don't need a new version

API versioning done wrong creates technical debt. Every version doubles your support surface. So we version only when we break things.

Adding a field to a response? Not a breaking change. Removing a field? Breaking. Renaming a field? Breaking. Changing what an endpoint returns? Breaking. Changing what it accepts? Breaking.

Our versioning strategy:

  • New fields are always additive. Clients ignore what they don't recognize.
  • Old fields never disappear. Mark them deprecated for a season, then remove.
  • Version lives in the URL: /v1/orders, /v2/orders.
  • Clients pick a version and stay there until they're ready to upgrade.

Here's a real example. Our /v1/orders returns:

typescript
{
  "id": 123,
  "status": "pending",
  "created_at": "2024-01-15T10:30:00Z"
}

Six months later, we want to add rich customer data. We add it:

typescript
{
  "id": 123,
  "status": "pending",
  "created_at": "2024-01-15T10:30:00Z",
  "customer": {
    "id": 456,
    "name": "Acme Corp",
    "email": "contact@acme.com"
  }
}

Still v1. Clients that don't need customer data don't pay the cost of fetching it. Clients that do can use it immediately. No version bump needed.

Two years in, we redesign the order model completely. Status becomes a richer enum. Timestamps become a nested object. Now we ship v2:

typescript
GET /v2/orders

V1 endpoints keep working. Old clients keep working. New projects default to v2. The migration happens gradually, team by team. We've run three major versions this way for five years without chaos.

Document with OpenAPI — Generated clients prevent drift between frontend and backend

OpenAPI (Swagger) isn't glamorous, but it's one of the highest-ROI investments we make. A proper OpenAPI spec becomes truth. Everything else—your code, your client libraries, your tests—flows from that spec.

We maintain the spec in YAML, versioned in Git alongside our API code:

typescript
openapi: 3.0.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /orders:
    get:
      summary: List orders
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, shipped, delivered]
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
    post:
      summary: Create order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
components:
  schemas:
    Order:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
        created_at:
          type: string
          format: date-time
      required: [id, status, created_at]

From this one file, we generate TypeScript clients using tools like OpenAPI Generator. The frontend team uses the generated client. The backend team maintains the spec as they change the API. When the spec changes, the client regenerates. Type mismatches surface immediately in the frontend build.

This prevents the worst form of drift: a backend team ships a change, forgets to tell the frontend team, and clients silently fail in production. With a generated client, you can't forget—the build breaks.

We've also found that writing the OpenAPI spec first, before code, leads to better designs. You think about your contract before you implement it. By the time you write the NestJS controller or Express route, the shape is already clear.

Common mistakes

  • Mixing versioning strategies. Some endpoints in v1, some in v2. This confuses clients and makes your routing logic a mess. Pick one strategy and stick to it.
  • Ignoring empty lists. Returning null instead of [] for an empty result forces clients to handle two types. Always return an array, even if empty.
  • Timestamps without timezone. "2024-01-15 10:30:00" is ambiguous. Use ISO 8601 with Z for UTC: "2024-01-15T10:30:00Z". Your client code will thank you.
  • Not testing error paths. Write unit tests for every error code your API can return. Document what causes each one. Clients need to know when they'll see RATE_LIMITED versus CONFLICT.
  • Skipping the OpenAPI spec. "We'll document it later" means you'll never document it. The spec becomes law the moment you write it. Update it as you update the code.

Key takeaways

  • Use noun-based resource paths and HTTP verbs. This makes your API readable and predictable.
  • Return errors in a consistent format with machine-readable codes so client code can handle failures programmatically.
  • Paginate all list endpoints from day one to protect your database and scale gracefully.
  • Version only on breaking changes. Additive changes keep v1 stable while new work flows through.
  • Write and maintain an OpenAPI spec so generated clients and backends stay in sync.

Consistency in API design compounds over time. Each decision we make today reduces friction for the next team that touches this service. After a few years, that friction saving adds up to months of engineering time saved across the company.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles