API Design

Foundations · Lesson 02

What makes an API good

If an API is a contract (Lesson 01), then a good API is a contract people enjoy depending on — easy to learn, hard to misuse, and safe to evolve. These qualities aren't taste; they're the exact things interviewers probe when they say "critique this API."

⏱ 10 minDifficulty: introPrereq: Lesson 01

By the end you'll be able to

The qualities of a good API

A useful mental checklist — every interview critique maps onto one of these:

QualityMeansSmells bad when…
Easy to learnPredictable names, consistent patternsevery endpoint invents its own conventions
Hard to misuseThe obvious call is the correct callyou must read three docs to avoid a footgun
ConsistentSame idea expressed the same way everywhereuserId here, user_id there, uid elsewhere
Minimal but completeCovers the needs, nothing redundantfive ways to do the same thing
EvolvableCan add features without breaking callersany change forces every client to update
Well-documentedClear contract, examples, error shapes"the code is the docs"
✅ The golden rule: design for the caller

A good API is designed from the outside in — start from "what does the client want to accomplish?" and work back to the implementation. A bad one is designed inside-out, exposing whatever was easy for the server. Recall Lesson 01: leaking internals into the contract is the cardinal sin because it kills evolvability.

Design-first vs code-first

There are two ways to arrive at a contract, and the order matters:

DESIGN-FIRST write contract agree build CODE-FIRST build code generate contract from it Design-first: clients & servers build in parallel against the agreed spec. Fewer surprises. Code-first: faster to start, but the contract drifts to whatever the code happens to do.
Design-first writes the contract (often in OpenAPI) before code, so teams agree up front. Code-first ships faster but risks a contract shaped by implementation accidents.

Design-first writes the contract — typically an OpenAPI document — and gets everyone to agree before code exists. Front-end and back-end teams then build in parallel against a stable target, and you can mock the API for testing on day one. Code-first writes the server first and derives the contract from it: quicker for a solo prototype, but the public interface ends up shaped by whatever was convenient internally — exactly the inside-out trap.

🎯 Interview angle

Whenever you're asked to "design an API," you're doing design-first by definition — you're writing the contract on the whiteboard before any code. Saying so explicitly ("I'll design the contract first so clients and servers can agree and build in parallel") frames your whole answer and shows you know why the exercise is structured that way.

The API design lifecycle

An API isn't a one-time artifact — it lives, changes, and eventually dies. Keeping the whole arc in mind separates designers from coders:

  1. Requirements — who calls it, what do they need, what scale? (This is where every case study in this course starts.)
  2. Design the contract — resources, operations, request/response shapes, errors.
  3. Review & agree — consumers sanity-check it before code is written.
  4. Build & document — implement to the spec; docs and examples ship with it.
  5. Evolve — add capabilities without breaking existing callers (versioning, additive changes).
  6. Deprecate — sunset old versions gracefully, with notice and migration paths.
⚠️ Common trap

Treating "design the contract" as the finish line. The hardest part is step 5 — evolving without breaking callers. Every design choice you make early (did you leak internals? are responses extensible?) decides how much pain step 5 brings. Good APIs are designed so that they can change.

Under the hood: a concrete BEFORE → AFTER endpoint redesign

Saying "design for the caller" is abstract. Here is a real endpoint redesign — starting from what a code-first developer might ship, mapping each problem to a quality from the table above, and showing the corrected version with the reasoning.

BEFORE — the inside-out version:

# What a junior developer might ship after building the DB schema first

POST /api/doUserStuff
Content-Type: application/json

{
  "usr_tbl_id": 42,           // ❌ DB column name leaked into contract
  "action": "deactivate",    // ❌ verb in body; HTTP method unused
  "ts": 1750000000           // ❌ Unix epoch; not human-readable, not ISO-8601
}

# Response — also broken:
HTTP/1.1 200 OK
{
  "res": "ok",              // ❌ "res"? what does that mean?
  "err": ""               // ❌ empty string to mean "no error" — use the status code
}
ProblemQuality violatedWhy it hurts
doUserStuff — verb in pathConsistency / easy to learnHTTP already has methods (DELETE, PATCH…); duplicating intent in the path breaks every REST client and linter
usr_tbl_id — DB column nameEvolvabilityEvery client now depends on your internal schema; rename the column → breaking change
"action": "deactivate" in bodyHard to misuseCallers must know the magic string; typos silently succeed; the HTTP method is meaningless
"ts": 1750000000 Unix epochEasy to learnNot human-readable; timezone ambiguous; RFC 3339 / ISO-8601 is the universal contract
"res": "ok" and empty "err"Consistent / documentedInvents a bespoke response envelope instead of using HTTP status codes — callers must parse strings to know if it failed

AFTER — the redesigned version:

# Resource-oriented, uses HTTP semantics, hides internals

PATCH /v1/users/42
Content-Type: application/json

{
  "status": "inactive"      // ✓ field name is a concept, not a column
}                             // ✓ PATCH = partial update; the verb is the HTTP method

# Response:
HTTP/1.1 200 OK              // ✓ status code carries success/failure — no body parsing needed
Content-Type: application/json

{
  "id": 42,
  "status": "inactive",
  "updated_at": "2025-06-15T10:30:00Z"  // ✓ ISO-8601 with timezone
}

# Error case — returns a machine-readable problem object:
HTTP/1.1 422 Unprocessable Entity
{
  "error": "invalid_status",
  "message": "status must be 'active' or 'inactive'"
}

The AFTER version satisfies all six qualities: it is easy to learn (standard HTTP semantics), hard to misuse (wrong method returns 405, wrong value returns 422 with a clear message), consistent (every user endpoint follows the same /v1/users/:id + HTTP method pattern), minimal (one field to express intent), evolvable (add fields to the response without breaking callers), and documented (the response shape is self-describing).

How to debug & inspect it — reviewing an API for quality

Reviewing an API for the qualities above is an active skill, not a vibe check. The process below applies equally to a PR review, a design session, or an interview critique question.

Run a linter first. If the API has an OpenAPI spec, Spectral catches the mechanical issues (missing descriptions, undefined response codes, wrong types) before human review. Install and run it against a spec file:

$ npm install -g @stoplight/spectral-cli $ spectral lint openapi.yaml /openapi.yaml 14:9 error operation-operationId Operation must have "operationId". 22:5 error oas3-api-servers OpenAPI "servers" must be present and non-empty array. 31:9 warn operation-description Operation "description" must be present and non-empty string. # Fix the errors; warnings are design smells to address next.

After the linter, walk each endpoint through this structured checklist:

CheckWhat to look forSmell / fix
Method semanticsDoes the HTTP method match what the endpoint does?GET that mutates data → change to POST/PATCH/DELETE; safe operations on POST → move to GET
Path namingAre paths nouns? Is the resource hierarchy logical?/getUser, /doThing/users/:id; verbs in paths → use the HTTP method
Status codesDoes every error case return the right code?200 for errors, 500 for validation errors → use 400/422 for client errors; never return 200 {"error":"…"}
Field namingAre field names stable concepts, not DB internals?usr_tbl_id, col_nm → rename to userId, name; pick one case convention (snake_case or camelCase) and apply it everywhere
TimestampsAre all date/time values in RFC 3339 / ISO-8601?Unix epoch integers → convert to "2025-06-15T10:30:00Z"; include timezone offset always
Error shapeIs there a consistent error response object?Inconsistent shapes → adopt one envelope ({"error": "code", "message": "…"}) across all endpoints
EvolvabilityCan fields be added without breaking callers?Clients that break on unknown fields → document that extras should be ignored; avoid changing field types in-place

Review checklist (use this in a code review or interview):

  1. Does every path use a noun resource, not a verb? (No /doThing or /getUser.)
  2. Does the HTTP method match the operation's semantics (safe = GET, idempotent mutation = PUT, partial update = PATCH)?
  3. Are all status codes semantically correct — 2xx success, 4xx client errors, 5xx server errors?
  4. Does the response body hide all storage/internal details (no column names, no ORM artefacts)?
  5. Are all timestamps ISO-8601 with a timezone? Are all IDs strings (not integers that overflow JSON in some languages)?
  6. Is there a consistent, documented error response shape across every endpoint?
  7. If there's an OpenAPI spec: does spectral lint pass clean? Are all operations described?

🧠 Quick check

1. "Hard to misuse" mainly means:

A well-designed API guides callers into the pit of success — the natural call does the right thing, so mistakes are hard to make in the first place.

2. The biggest risk of a code-first approach is that:

Deriving the contract from code tends to leak whatever was convenient internally — the inside-out design that hurts consistency and evolvability.

3. Which lifecycle stage is usually the hardest, and the one good design optimises for?

Anyone can ship v1. The discipline is changing it later without breaking the clients who depend on it — which earlier choices (no leaked internals, extensible responses) make possible.

✍️ Drill: critique a bad endpoint

An interviewer shows you: GET /getUserData?id=42&action=delete. Name three things wrong with it before reading on.

Model answer: (1) Verb in the pathgetUserData duplicates what the HTTP method already says; the resource should be /users/42. (2) A destructive action behind GETaction=delete makes a safe, cacheable, retryable method delete data; a crawler or prefetch could wipe users. Use DELETE /users/42. (3) Inconsistent style — mixes a "get" prefix with an "action" param instead of letting method + resource express intent. Bonus: it's not evolvable or self-describing.

Rubric: ✓ spots the method/semantics mismatch (the big one) ✓ notes verb-in-path / RPC-flavoured naming ✓ proposes the resource-oriented fix.

Key takeaways

Sources & further reading