V Volkanic
Architecture

API design decisions that age well

The decisions you make on day one of an API design compound over time. Here are the ones I've learned to get right upfront.

10 min de lectura
APIsRESTArchitectureBackendDesign

Building an API that’s pleasant to use three years later requires a different mindset than building one that works today. The design decisions you make on day one compound. Some make the API easier to evolve; others lock you into choices you’ll regret.

Here’s what I’ve learned from maintaining production APIs across multiple products.

Version from the start, even if you don’t need it yet

Put /v1/ in your URLs before you have a v2. Yes, even before you’re sure you’ll ever need a v2.

Why: adding versioning retroactively requires either breaking all existing clients or building a routing layer that maps old URLs to new handlers. Both are painful.

/api/v1/reservations
/api/v1/reservations/{id}
/api/v1/reservations/{id}/cancel

When you have v2, old clients keep working. You can deprecate v1 on your own timeline.

Header-based versioning (Accept: application/vnd.myapi.v2+json) is theoretically elegant but practically harder to work with in browsers, logs, and caches. Stick with URL versioning.

Design for the consumer, not the database

The most common API design mistake is returning your database schema as JSON. Table columns become response fields. Joins become nested objects. The database is the source of truth for the API response shape.

The problem: when your database schema changes, your API breaks. You’ve coupled your storage design to your public interface.

Design resources around the consumer’s use case, not your tables:

// Bad: database-shaped
{
  "id": 42,
  "tour_id": 17,
  "customer_id": 8,
  "status_id": 2,
  "created_at": "2024-03-15T10:30:00Z"
}

// Good: consumer-shaped
{
  "id": "res_abc123",
  "status": "confirmed",
  "tour": {
    "id": "tour_xyz",
    "name": "City Walking Tour",
    "duration_minutes": 120
  },
  "created_at": "2024-03-15T10:30:00Z"
}

Explicit mapping between your internal models and your API resources is extra work. It pays off every time you need to change the database without breaking clients.

Use opaque IDs

Don’t expose sequential integer IDs in public APIs. They leak information (how many orders you have, when a user signed up) and make enumeration attacks trivial.

Use UUIDs, ULIDs, or prefixed random IDs (res_abc123). Stripe’s prefixed IDs are a good model — they tell you the resource type at a glance and are easy to grep in logs.

Also: internal database IDs and API resource identifiers should be different things. A resource’s public ID should be stable even if the internal storage changes.

Be explicit about timestamps and timezones

Always return timestamps in ISO 8601 with timezone: 2024-03-15T10:30:00Z. Never return unix timestamps (hard to read in logs) or local times without a timezone (ambiguous).

{
  "starts_at": "2024-03-15T10:30:00+01:00",
  "created_at": "2024-03-12T14:22:31Z",
  "cancelled_at": null
}

Nullable timestamps communicate state clearly. cancelled_at: null tells you the resource hasn’t been cancelled without needing a separate is_cancelled field.

Error responses deserve as much attention as success responses

Clients need to understand errors programmatically. A 400 Bad Request with no body forces the client to guess what went wrong.

{
  "error": {
    "code": "RESERVATION_CAPACITY_EXCEEDED",
    "message": "The requested tour has no available capacity for this date.",
    "details": {
      "tour_id": "tour_xyz",
      "requested_date": "2024-04-01",
      "available_capacity": 0
    }
  }
}
  • code is machine-readable. The client can branch on it.
  • message is human-readable. Good for debugging.
  • details provides context. Include what you know about the failure.

Define your error codes upfront and document them. They become part of your public API contract.

Pagination defaults and limits

Always paginate lists. An endpoint that returns an unbounded collection becomes a production incident the day you have enough data.

Cursor-based pagination is better than offset for large datasets (offset pagination at page 100,000 is slow). Offset is simpler to implement and fine for most cases.

Always enforce a maximum page size. Clients asking for 10,000 items in one response are either bugs or attacks.

{
  "data": [...],
  "meta": {
    "total": 847,
    "page": 1,
    "per_page": 20,
    "last_page": 43
  },
  "links": {
    "first": "/v1/reservations?page=1",
    "prev": null,
    "next": "/v1/reservations?page=2",
    "last": "/v1/reservations?page=43"
  }
}

The rule I wish I’d followed earlier

Document your API before you build it. Write the request/response shapes in YAML or a spec tool before a single line of implementation.

This forces you to think from the consumer’s perspective. It surfaces design problems cheaply — before they’re baked into the code. It also gives you something to review with stakeholders before you’ve invested in the implementation.

The API contract is the most expensive thing to change after clients are depending on it. Spending a day on design upfront saves weeks of migration later.