gap·map

API design

[ junior depth ]

How to model a REST resource

A REST URL names a thing, not an action. The thing is a noun (a user, an order, a cart), and the HTTP method is the verb. So you never put the verb in the path.

POST   /users            create a user
GET    /users/42         read one user
GET    /users/42/orders  that user's orders
PATCH  /users/42         update some fields
DELETE /users/42         remove the user

wrong "POST /createUser and POST /deleteUser, one endpoint per operation." That is RPC dressed up as HTTP. The method already carries the verb, so createUser repeats it and every endpoint reinvents a naming scheme. Collections are plural nouns, a single item lives under its id, and relationships nest one level (/users/42/orders). Keep nesting shallow. Past two levels the URLs get unreadable, and a query filter like /orders?userId=42 reads better.

Choosing the method for a modeled resource

Which verbs are safe and which are idempotent is HTTP semantics, covered in the http-protocol module. The design question here is which verb fits the operation once you have modeled the resource. Creating a resource the server names is POST /orders; replacing one you already identify is PUT /orders/42; changing a few fields is PATCH /orders/42. The call you defend most often is PUT versus PATCH. PUT means "make the resource equal to this body", so the client has to hold the whole object. PATCH sends only the fields that change. Reach for PATCH when the client holds a diff, PUT when it holds the full record.

Status codes are part of the contract

A client branches on your status line, so the code is a design decision, not decoration. The protocol meanings (401 versus 403, 204, the method catalog) live in http-protocol; the calls that are yours to design:

  • 201 Created for a POST that made a resource, ideally with a Location header pointing at it, rather than a bare 200.
  • 400 for input the server cannot parse, 422 when it parses but fails a business rule. Splitting the two tells the client whether to fix its syntax or its data.
  • 409 Conflict when the request fights current state, like creating something that already exists.

wrong "return 200 {"error": "..."} and put the real status in the body." That throws away the layer proxies, caches, and monitoring read without parsing bodies. Make the status line carry the outcome.

[ middle depth ]

Designing idempotency keys

Which methods are idempotent (GET, PUT, DELETE yes; POST, PATCH no) is protocol semantics, covered in http-protocol. The design problem it hands you: a dropped POST is dangerous, because the client never saw the response, retries, and now you have two orders.

wrong "idempotent means the client gets the identical response every time." It is about server state, not the response body. Two DELETEs return 204 then 404, yet the state after both equals the state after one. That is why you cannot make POST safe by tweaking its response; you make the effect stop stacking, and that needs a key.

The client generates a unique value (a UUID) and sends it on the write:

POST /payments
Idempotency-Key: 4f1a...

The server records the outcome under that key. On the first request it processes and stores the result; on any retry with the same key it returns the stored result without touching the payment gateway again. Stripe does exactly this, with keys expiring after about 24 hours. GET and DELETE do not need keys, they are already idempotent. The hard part is the concurrent and crash cases, which is the senior material; at this level, know that the key belongs to the client, is unique per logical operation, and turns a retry into a replay.

Offset versus cursor pagination

Two ways to page through a list, and the choice is a real design decision.

Offset is ?limit=20&offset=40: skip 40, take 20. Simple, supports jumping to page 5, and it drifts. If a row is inserted or deleted while a user pages, the window shifts and they see a duplicate or miss a record.

Cursor (keyset) pagination passes an opaque pointer to the last row seen, like ?limit=20&after=<cursor>, and the query continues from there on an indexed sort key. It is stable under inserts and cheaper on deep pages, at the cost of no random page access. Use cursors for feeds and large or fast-changing lists; offset is fine for small admin tables where jump-to-page matters.

One error shape, everywhere

Every endpoint should fail in the same envelope, with a machine-readable code and a human message:

{ "error": { "code": "insufficient_funds", "message": "Card was declined." } }

Clients switch on code; they should never regex your message. A consistent envelope plus the right status code is what makes an API predictable to build against.

[ senior depth ]

Idempotency keys under concurrency and crashes

The naive implementation is SELECT by key, and if nothing is found, do the work and INSERT. It fails the moment two retries land at once: both SELECTs run before either INSERT commits, both see nothing, both charge. A check-then-act with no atomicity is a time-of-check/time-of-use gap.

The fix is a unique constraint on the key column and an insert that reserves the key before the side effect runs:

-- reserve first; the loser of a concurrent race gets a violation
INSERT INTO idem (key, status, request_hash) VALUES ($1, 'pending', $2);
-- ... perform the charge, then:
UPDATE idem SET status = 'done', response = $3 WHERE key = $1;

The second request's INSERT raises a unique violation, which you catch and turn into "wait for or replay the original." Store the full outcome including failures, so a first 500 replays as the same 500 rather than silently re-charging. Compare the incoming request_hash to the stored one and reject reuse of a key with different parameters (Stripe returns an error here). And commit the business write with the key record atomically, one transaction or a transactional outbox, or a crash between them either drops the dedup row (retry double-charges) or poisons the key (a charge that never landed looks done).

Say the guarantee precisely: keys give at-most-once effect through dedup, not exactly-once delivery. The network delivers at least once no matter what; you make reprocessing a no-op. "Effectively once" is at-least-once delivery plus idempotent processing, and interviewers listen for that distinction.

Versioning and pagination at scale

OFFSET N still scans and discards N rows, so deep pages are O(N); keyset pagination seeks straight into an indexed sort key. Keyset needs a total order, so sort by the key plus a unique tiebreaker (created_at, id) or ties at page boundaries drop or repeat rows. Keep the cursor opaque (base64 of the sort tuple) so you can change the ordering without breaking clients.

For versioning, the axis that matters is migration cost, not URL aesthetics. Path (/v1/) is simplest to route and cache; header or media-type versioning keeps URLs stable but hides the version from logs and browsers. Stripe pins a date-based version per account and runs old requests through a chain of transformers, shipping breaking changes without a fleet-wide /v2.

Error contracts and the protocol choice

Treat the error body as a versioned surface. RFC 9457 Problem Details (which obsoletes 7807) gives a standard shape: type, title, status, detail, instance. Add a stable code taxonomy, field-level validation entries, and a correlation id; never leak a stack trace. Choosing REST versus gRPC versus GraphQL is choosing who pays: GraphQL kills over-fetching and hands you N+1 and caching, gRPC gives binary speed and streaming but no native browser reach, REST gets HTTP caching and ubiquity and makes clients stitch multiple round trips.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks