← Lessons Designing good APIs
0 / 5 units
中文
Lesson 2

How to design an API people love to use

An API is a menu that one program offers to another. A good menu is easy to read, says clearly what went wrong, handles long lists well and doesn't suddenly change under you. In this lesson you fix badly designed requests and see why the good versions work better.

API design is about the contract you'll have to live with for years: resource modelling, error semantics, pagination that holds up under concurrent writes, and evolving the contract without breaking deployed clients. Each unit below is hands-on.

Unit 1

Name things well: the API doctor

Each "patient" below is a request from a badly designed API. The red squiggles mark the problems. Pick the healthier version and watch it get fixed.

Each case is a real-world design smell. Choose the better contract. Advanced mode adds two harder cases where "pure REST" is not the right answer.

Before
Which version is better?
The rules in one place
  • The URL is a noun: the address of a thing (/users/42). The method is the verb (GET, POST, DELETE…).
  • Use plural names for lists: /orders, /orders/17.
  • GET only ever reads. It must never change anything.
  • Use the path for "belongs to" (/users/42/orders), and query parameters for filters (?sort=price).
  • Pick one naming style (lowercase) and stick to it everywhere.
Design checklist
  • Model resources and collections; let HTTP methods carry the semantics (safe, idempotent, cacheable).
  • Give every resource with a unique ID a canonical top-level URL. Nest only for listing (/teams/12/members).
  • State transitions with side effects can be explicit actions: POST /orders/42/cancel, returning 409 on an illegal transition.
  • Composable query parameters: filter, sort=-created_at, limit, cursor. Reject unknown parameters with 400.
  • Write the contract first (OpenAPI) and lint it in CI (e.g. Spectral) so the conventions are enforced, not just remembered.
Unit 2

Speak clearly: status codes and errors

Every response starts with a three-digit status code. Programs read it before anything else, so it has to be right. The first digit already tells you a lot:

Clients, retries, caches, monitoring and alerting all branch on the status code. Choose the most specific correct code, and return a machine-readable error body (RFC 9457 problem details).

2xx it worked 4xx the client made a mistake 5xx the server broke
Client
Server
Response
(pick a status code)

Bad error vs good error

✗ Hard to use
HTTP/1.1 200 OK

{ "success": false, "msg": "Error" }
  • Says 200 OK, so programs think it worked.
  • "Error" doesn't say what went wrong or how to fix it.
  • 2xx on failure defeats retries, caches, metrics and alerting.
  • No stable machine-readable type, so clients end up parsing strings.
  • No field-level detail and no correlation ID for support.
✓ Easy to use
HTTP/1.1 400 Bad Request

{
  "error": "email is required",
  "field": "email"
}
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "1 field is invalid",
  "errors": [{ "pointer": "/email", "detail": "is required" }],
  "request_id": "req_7Hk2"
}
  • The status code alone tells the program it failed and why.
  • The message says exactly which field to fix.
  • A stable type URI lets clients branch on the error kind.
  • JSON pointers map errors to form fields; request_id links to your logs.
  • Never leak stack traces or SQL in detail.
Unit 3

Long lists: pagination

A feed can have millions of posts, so an API sends them a few at a time ("pages"). There are two common ways to ask for the next page. Watch what happens when a new post arrives while you're scrolling.

Offset pagination versus cursor (keyset) pagination on a feed sorted newest-first, with writes happening between page requests. Press Play the classic bug, or drive it yourself.

Page number (offset) means "skip the first 6 posts, give me the next 3". Cursor works like a bookmark: "give me the 3 posts that come after #7". The coloured arrows on the server data show where each method's next page will start.
Offset: LIMIT 3 OFFSET 6. The database scans and discards 6 rows (O(offset)), and the window shifts whenever rows are inserted or deleted above it.
Cursor / keyset: WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 3. This uses the index and stays stable under writes. Return the cursor opaquely, e.g. base64.
Server data · newest first

Page numbers (offset)Offset pagination

You have received

Bookmark (cursor)Cursor pagination

You have received
Unit 4

Change safely: breaking or not?

Once apps use your API, you can't just change it, because their code expects the old shape. Decide whether each change is safe to ship or would break apps that already exist.

Assume clients are already deployed and you can't force them to upgrade. Classify each change against that contract. Advanced mode adds the subtle cases that cause real incidents.

✓ Safe to ship

    ⚠ Breaking

      Golden rule: you can add things, but don't rename, remove or change them. If you really must change something, keep the old version working for a while, tell people well in advance, and only remove it later.
      Evolving a contract
      • Expand, then contract: add the new field, migrate clients, then remove the old one after a Deprecation header and a Sunset date (RFC 8594).
      • Versioning: a URL prefix (/v2) is simple and visible; header or date-based versions (API-Version: 2026-10-01) allow finer-grained changes per endpoint.
      • Tolerant reader: tell clients to ignore unknown fields and handle unknown enum values. Then "add" really is safe.
      • Run contract tests (consumer-driven, e.g. Pact) or an OpenAPI diff in CI to catch breaking changes before release.
      Unit 5

      Check yourself