Skip to content

Senior Engineering Interview Handbook / Chapter 53

API Design

Learn practical API design by carrying one inventory-reservation contract through resource and command modeling, schemas, errors, idempotency, authorization, pagination, limits, and backward-compatible evolution.

The request that succeeded and failed

A checkout service asks an inventory service to reserve three units. The inventory service commits the reservation and sends a response. The connection drops before checkout receives it.

From inventory’s point of view, the request succeeded. From checkout’s point of view, it failed. The client has to decide whether to retry, but a careless retry may reserve six units.

This is where API design begins. A route and a JSON example describe traffic; a contract tells both sides what an ambiguous outcome means. It names the caller, the operation, the state change, the shape of success, the failures a client can act on, and what happens when the same intention arrives twice.

The example in this chapter is an inventory-reservation API used by a checkout service. It needs to:

  • create a temporary reservation for an order;
  • inspect, release, or commit that reservation;
  • list an order’s reservations; and
  • remain usable as warehouses, clients, and policies change.

That is enough surface area to expose the hard parts without designing a general-purpose API framework.

Start with the caller’s job

Suppose we begin with the familiar route:

POST /reservations

It says almost nothing by itself. Before choosing the body, establish the boundary around it.

The caller is the checkout service. It acts for a merchant and an order, using an authenticated service credential. Network timeouts are ordinary, so retries are expected. The operation must not oversell stock, and a reservation expires if checkout never commits or releases it.

Those facts already constrain the design. Merchant scope cannot be trusted from the request alone. Creation needs an idempotency policy. Expiration needs a timestamp in the result. The storage operation must keep the availability check and the reservation write atomic.

Now name the public concepts. A reservation has stable identity and a lifecycle, so it is a resource. Creating, releasing, and committing are commands: each asks the domain to perform a meaningful transition under preconditions. Listing reservations is a query.

POST /reservations
GET  /reservations/{reservation_id}
POST /reservations/{reservation_id}/release
POST /reservations/{reservation_id}/commit
GET  /orders/{order_id}/reservations

The verbs in release and commit are deliberate. This weaker design hides the domain rule:

PATCH /reservations/{reservation_id}
{ "status": "committed" }

It invites the client to treat state as an editable field. Can an expired reservation become committed? May a released reservation become active again? Which side effects accompany the transition? A command gives the server authority to answer those questions. PATCH remains useful when partial resource editing is genuinely the operation; it is a poor disguise for a business transition.

Internal objects do not automatically become public resources. The implementation may contain allocation policies, stock counters, database rows, locks, and repositories. Checkout needs the reservation and its result, not a tour of the server’s object model. A stable boundary exposes the smallest set of concepts a client needs to do its job.

Make the first command complete

The create request can now be precise:

POST /reservations HTTP/1.1
Authorization: Bearer <service-credential>
Idempotency-Key: 9f0f1c7a-...
Content-Type: application/json

{
  "order_id": "ord_456",
  "sku": "sku_99",
  "quantity": 3,
  "expires_in_seconds": 900
}

order_id, sku, and quantity are required. Quantity is a positive integer. expires_in_seconds is optional and bounded; if absent, the server applies a documented default. IDs are opaque strings, not exposed database keys. The API rejects unknown request fields so a misspelling cannot look like an accepted option.

These details are not schema decoration. Each closes off a plausible misunderstanding. If a field carries money, duration, size, or distance, put the unit in its name or use a value with an explicit unit. If timestamps cross the boundary, state the timezone and format. An example value is not a substitute for a rule.

On success, return enough state for checkout to continue:

HTTP/1.1 201 Created
Location: /reservations/rsv_123

{
  "reservation_id": "rsv_123",
  "order_id": "ord_456",
  "sku": "sku_99",
  "quantity": 3,
  "status": "active",
  "expires_at": "2026-07-15T08:00:00Z"
}

An "ok" response would force the client to guess the generated ID and the expiration time or make another call to discover them. A useful command result returns the identity and current state created by the command. For asynchronous work, the same principle applies: return a job ID, its current status, and a place to inspect it.

Boundary validation and domain invariants have different jobs. The API can reject a missing SKU or negative quantity before calling the model. The domain and durable store must still prevent reserved stock from exceeding available stock. A queue consumer or future internal service may reach that invariant without passing through this HTTP handler. Chapter 52 located the rule in the model; the API translates its result without becoming its only guardian.

Errors are part of the usable surface

Now reduce available stock to two units and repeat the request for three. A client needs more than a failed status. It needs to know whether to repair the request, authenticate, seek permission, choose a different quantity, or retry later.

Use one bounded error shape:

HTTP/1.1 409 Conflict

{
  "error": {
    "code": "INSUFFICIENT_INVENTORY",
    "message": "Requested quantity is not available.",
    "details": {
      "sku": "sku_99",
      "requested": 3,
      "available": 2
    }
  }
}

The code is stable enough for software to branch on. The message helps a human understand the event. Details provide safe, bounded context. Stack traces, SQL, dependency responses, hostnames, secrets, and authorization internals remain server-side.

Group failures by recovery behavior and apply the grouping consistently. Use 400 for malformed structure, 401 when authentication is missing or invalid, and 403 when a known caller lacks permission. A missing or deliberately concealed resource can be 404. An invalid transition or competing state is a 409. A rate limit is 429, preferably with retry guidance; temporary unavailability is 503. Some APIs distinguish semantically invalid fields as 422; that distinction is useful only if clients can rely on it everywhere.

Consistency matters more than winning an argument about one borderline status. The contract should make these outcomes distinct:

malformed request       -> repair before retrying
unauthenticated         -> obtain valid identity
forbidden               -> identity is valid, permission is not
not found               -> correct identity or scope
state conflict          -> inspect current state or choose another action
rate limited/overloaded -> wait according to server guidance
unexpected failure      -> outcome may be unknown; follow retry contract

This last line returns us to the dropped response.

Give a retry the same meaning

Checkout generates an idempotency key for its intention to create one reservation. Inventory records that key with the authenticated merchant, operation, request fingerprint, and response. If the response disappears, checkout sends the same request with the same key.

If the first call committed, inventory returns the original reservation. If it never committed, one reservation is created. In either case, checkout ends with one intended effect.

The key is scoped to caller and operation. The server also compares a canonical request fingerprint. Reusing the key with a different SKU or quantity is not a second valid command:

HTTP/1.1 409 Conflict

{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "The idempotency key was used with a different request."
  }
}

An in-memory map can demonstrate this behavior in a coding exercise. A production design needs durable uniqueness, a retention window, and a cleanup policy. The idempotency record and the reservation must cross the commit boundary together; otherwise a crash can leave the side effect without the record that makes its retry safe.

Not every operation needs a client key. Reads are naturally repeatable. A release command can return the already-released reservation when repeated. A commit command can return the already-committed reservation, while rejecting a commit after release or expiry. State the final-state policy rather than letting a duplicate command fall into a generic exception.

active    + release -> released
active    + commit  -> committed
released  + release -> released (same final state)
released  + commit  -> RESERVATION_ALREADY_RELEASED
committed + commit  -> committed (same final state)
committed + release -> RESERVATION_ALREADY_COMMITTED
expired   + commit  -> RESERVATION_EXPIRED

This trace does more teaching work than the phrase “the command is idempotent.” It shows exactly which repetition is safe and where a different history creates a conflict.

Authorization changes the lookup

Authentication tells inventory that checkout is calling. Authorization tells it which merchant’s stock and orders checkout may act on. Those are separate decisions.

The request contains order_id, but it should not be allowed to choose its own merchant scope. Inventory derives permitted merchant or tenant IDs from the authenticated principal, then performs a scoped lookup:

find reservation where
  reservation_id = requested_id
  and merchant_id in caller.allowed_merchants

This shape makes cross-tenant access harder to introduce accidentally. A perfectly valid ID from another tenant should not become visible because a developer remembered authentication and forgot ownership.

The contract should also say which service credential is accepted, whether it may act for end users, which commands require stronger permission, and which audit facts are recorded. Returning 404 rather than 403 can conceal the existence of another tenant’s resource, but that is a deliberate policy, not a universal rule.

Let the list grow up

The order page initially has two reservations, so this endpoint appears finished:

GET /orders/ord_456/reservations

Soon there are retries, replacements, split shipments, and years of history. An unbounded list is now a reliability problem. Add a bounded query:

GET /orders/ord_456/reservations?status=active&limit=50&cursor=...

The contract permits known status filters, sets a default and maximum limit, and rejects unsupported filters. Results are ordered by created_at DESC, reservation_id DESC. The second field is a tie-breaker, so two reservations created at the same time still have a stable order. The opaque cursor represents the last pair seen.

{
  "items": [
    {
      "reservation_id": "rsv_123",
      "sku": "sku_99",
      "quantity": 3,
      "status": "active",
      "created_at": "2026-07-15T07:45:00Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Offset pagination is reasonable for a small, stable administrative list. On a changing list, insertions and deletions can make offsets skip or repeat rows. A cursor tied to a stable ordering usually transfers better. Neither choice creates a snapshot by itself, so say whether results may drift while the client paginates.

Filtering and sorting are promises too. Do not accept arbitrary field names, operators, or a query=anything escape hatch unless the system can bound their cost and preserve their meaning. Default order, supported filters, maximum page size, and empty-list shape are all observable contract behavior. Changing the default order later can break a client without changing a single field.

Rate limits set another boundary around growth. A public API may limit by user or API key; this service-to-service operation may combine merchant and endpoint so one integration cannot consume all reservation capacity. A denial should tell the caller when it may try again:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Reservation creation is temporarily limited.",
    "details": { "retry_after_seconds": 30 }
  }
}

In a coding exercise, naming a token bucket, its key, clock, capacity, and caller-visible decision is enough. A Boolean allowed throws away the very information a client needs under pressure. The same lesson applies to a non-HTTP library method: allow(key, now) -> Decision(allowed, remaining, retry_after) is a more useful contract than allow(key) -> bool.

Evolve the promise, not just the version number

Six months later, reservations may be fulfilled from a specific warehouse. Adding an optional warehouse_preference to requests can be compatible if the old omission keeps its old meaning. Adding warehouse_id to responses is usually compatible when clients are required to ignore unknown fields. Adding a new endpoint to transfer an active reservation can also leave old clients untouched.

By contrast, changing quantity from units to cases, requiring a warehouse in every request, renaming expires_at, or changing repeated commit behavior breaks existing assumptions. So does changing list order or reusing an error code for a different condition.

An API version is a place to keep a promise, not permission to change it at will. Within a major contract, prefer additive changes:

  • new optional request fields with stable defaults;
  • response fields old clients can ignore;
  • new resources, queries, or commands; and
  • new failure detail that preserves the old actionable category.

Be cautious with new enum values: generated clients often treat enums as closed even when designers hoped they were open. Compatibility tests should exercise old requests and old client assumptions against the new server.

When meaning truly changes, create a new contract and a migration path. That might be /v2, a versioned media type, or a new command whose name makes the new semantics explicit. Run old and new behavior long enough for clients to migrate, measure remaining use, announce deprecation, and remove the old contract only after its consumers are accounted for.

The storage model has to migrate with the API. If the response gains currency, the database needs an honest source for that value, a backfill or default for old rows, and constraints that preserve it. An additive JSON field backed by fictional data is not a compatible change; it is a new ambiguity.

A compact design pass

For a practical-coding prompt, you do not need to recite every category before writing code. Spend the opening minutes making the consequential promises visible:

caller and job
  checkout reserves merchant inventory for an order

public model
  reservation resource; create, release, commit commands; order query

command contract
  required inputs and units; useful success state; bounded errors

failure boundary
  atomic stock check and reservation write; safe retry by idempotency key

trust and load
  merchant scope from authenticated identity; bounded lists and rate limit

change rule
  additive evolution inside v1; new contract plus migration for changed meaning

Then implement the smallest vertical slice that proves the contract: create a reservation, repeat the request after a simulated lost response, reuse the key with a different body, attempt a cross-merchant lookup, and race two requests for the last unit. A route collection is not convincing if none of its hardest promises can be observed.

To practice, redesign one of these boundaries: meeting-room booking, payment capture, file upload, notification preferences, or a job queue. Write one state-changing command and one list query. Define the caller, trust scope, request and response, recovery-oriented errors, retry behavior, ordering and pagination, limit policy, and one likely future change. Then alter a requirement so that copying the first answer is insufficient: introduce partial success, multiple tenants, asynchronous completion, or a unit change. Decide whether the old contract can grow additively or needs a migration.

An API is ready when another engineer can build a client without discovering the important rules by accident. They know what they may ask, what the server guarantees, how to interpret failure, whether a retry is safe, which identity controls access, and how today’s promise can survive tomorrow’s implementation.