System Design · Interfaces
API Design Principles
Interviewers rarely ask you to draft OpenAPI. They ask whether a checkout retry double-charges, whether page=50 is safe under inserts, and whether your error body lets a mobile app recover. That is plus a few protocol contracts—not swagger cosmetics.
This note covers modeling, , pagination, , light , and when , , or fit. Gateways, authn/authz, and consumer-contract tooling live in sibling notes—use them together, do not clone them here.
Design journey
Stable nouns clients can address for years.
Retries must not invent second side effects.
Cursors beat deep offsets under churn.
REST, RPC, GraphQL—each has a cost curve.
What an API design interview is really testing
API design sits between product language and service implementation. A bad design forces every app rewrite when you rename a table. A good design lets you swap payment providers behind /payment-intents while mobile builds from last year still settle carts.
You are not being graded on whether every URL is “RESTful enough.” You are graded on consistency: how creates work, how lists page, how failures look, and which style you chose for which audience (public HTTP vs internal gRPC vs BFF GraphQL).
Resource modeling: nouns first
Read the diagram definition
flowchart TD
Intent["Business intent: place order"] --> Nouns["Nouns clients address"]
Nouns --> Cart["/carts/{id}"]
Nouns --> Order["/orders/{id}"]
Nouns --> Pay["/payment-intents/{id}"]
Cart -->|"POST checkout action or create Order"| Order
Order -->|"creates"| Pay
Pay -->|"status links"| Order
Note["Avoid: POST /doCheckoutAndCharge"]A like /orders lists and creates. A member /orders/{id} is the durable identity. Sub-resources (/orders/{id}/events) are fine when the child’s lifetime is owned by the parent. If a payment has its own lifecycle and support tooling, give it its own root: /payment-intents/{id}.
Nesting depth is a smell when it mirrors joins: /users/u/carts/c/items/i/discounts/d teaches clients your schema. Prefer flat identities and filters: GET /line-items?order_id=….
Actions that are not CRUD still deserve a resource when they have state. Prefer POST /orders/{id}/cancellation (creates a Cancellation object) over POST /cancelOrder if clients must poll status, attach reasons, and audit. Pure fire-and-forget RPCs are fine internally; public APIs usually need something addressable.
| Smell | Better model | Why |
|---|---|---|
POST /doCheckout | POST /orders from cart | Creates an Order with identity and status |
GET /getUserByEmail | GET /users?email= or lookup resource | Keeps collection conventions; verb is in the method |
Exposing user_fk columns | Stable public ids + links | Schema renames stay internal |
HTTP semantics that interviewers expect
(GET, HEAD) must not mutate. Caches, CDNs, and browsers assume that. Putting “mark as read” behind a GET is a classic failure story.
PUT replaces a resource (or creates at a known id). merges fields. DELETE removes or soft-deletes—document which. POST creates or triggers non-idempotent work. Status codes matter: 201 with Location for creates, 202 for accepted async jobs, 409 for conflict, 429 for rate limits.
Conditional requests (If-Match / ETags) prevent lost updates when two tabs edit the same profile. Mention them when the design has concurrent writers; skip them for append-only event feeds.
# Partial update with optimistic concurrency
PATCH /profiles/me
If-Match: "v12"
Content-Type: application/json
{ "display_name": "Ada" }
# 412 Precondition Failed if ETag stale
# 200 with new ETag "v13" on successIdempotency keys: the money path
Read the diagram definition
sequenceDiagram
autonumber
participant C as Client
participant API as Orders API
participant DB as Idempotency store
participant Pay as Payments
C->>API: POST /orders Idempotency-Key: k1
API->>DB: lookup k1
alt first time
DB-->>API: miss
API->>Pay: charge
Pay-->>API: ok payment_id
API->>DB: store k1 → 201 body
API-->>C: 201 Order
else retry after timeout
C->>API: POST /orders Idempotency-Key: k1
API->>DB: lookup k1
DB-->>API: hit → cached response
API-->>C: same 201 Order
Note over Pay: charge not repeated
endClients send Idempotency-Key: <uuid> (or a body field) on risky POSTs. The server records key → response (or in-flight lock) for a TTL—often 24h for payments. A duplicate key with a different body is a 422/409: the key binds to one payload.
Implementation sketch: unique index on (scope, key), store status started | completed | failed, and return the stored body for completed. In-flight duplicates wait or get 409 Conflict with Retry-After. Scope the key per merchant or user so two tenants cannot collide.
Naturally idempotent methods (PUT /resources/{id} with client-chosen ids) need less ceremony. Still document “create-once” semantics: first writer wins, later PUTs are updates.
Worked example — double-submit checkout
Client timeout at 2.1s after Pay succeeded
Without key: second POST creates order #2, second charge
With key k1: second POST returns order #1 body, charge once
Design checklist
- Require key on POST /orders and POST /payment-intents
- TTL ≥ max client retry window
- Hash request body; mismatch → error
- Include key in audit log next to payment_idPagination that does not reshuffle
Offset pagination (?page=3&limit=20) is easy to teach and fragile: a new insert at the top shifts every later page; deep offsets scan and skip many rows. Prefer for feeds, activity logs, and any list under concurrent writes.
A cursor encodes the last sort key (and id tie-breaker), usually opaque and signed so clients cannot invent arbitrary SQL. Response shape: items[], next_cursor, optionally has_more. Seek with (created_at, id) < (:c_ts, :c_id) under a consistent order.
| Style | Strength | Weakness |
|---|---|---|
| Offset | Jump to page N; total count easy | Drift + deep-page cost |
| Cursor / keyset | Stable under inserts; index-friendly | No cheap “page 50”; opaque tokens |
| Continuation token (search) | Engine-specific resume | Short TTL; not a user bookmark |
Caps matter: hard-max limit (e.g. 100), default smaller, and reject unbounded “export everything” unless you offer async jobs. Total counts are expensive—return them only when product truly needs “Page 3 of 400.”
Error envelopes clients can trust
HTTP/1.1 409 Conflict
Content-Type: application/json
X-Request-Id: req_01HZX…
{
"error": {
"code": "inventory_insufficient",
"message": "SKU tee-blue has only 2 left.",
"request_id": "req_01HZX…",
"details": [
{ "field": "items[0].sku", "code": "qty_exceeds_stock" }
]
}
}Pick a closed vocabulary of codes and version it carefully. Do not invent a new string per engineer. Map domain failures to HTTP classes honestly: validation → 400, authz → 403, missing → 404, conflict → 409, upstream → 502/503 with retry hints.
Never leak stack traces or SQL to clients. Put those in logs keyed by request_id. For multi-field forms, return all field errors in one envelope so the UI can highlight every input at once.
HATEOAS lightly: links for stateful workflows
Full —“clients know only the entry URL and discover everything via links”—is rare in commercial APIs. What shows up in strong designs is selective affordances: a PaymentIntent response includes links.confirm, links.cancel, and links.self that match the current status machine.
That helps when statuses are many and URLs would otherwise be hard-coded wrong (“always POST /confirm even after capture”). It does not replace OpenAPI for day-one integration; treat links as runtime guidance, not as the only contract.
{
"id": "pi_9f2",
"status": "requires_confirmation",
"amount": 2499,
"links": {
"self": "/payment-intents/pi_9f2",
"confirm": "/payment-intents/pi_9f2/confirm",
"cancel": "/payment-intents/pi_9f2/cancel"
}
}
# After capture, confirm link disappears; refund link appearsVersioning without a version zoo
is a last resort for breaking changes. Prefer additive fields, optional query params, and expand-contract migrations (covered deeply in API Compatibility & Consumer Contracts). When you must break, choose one mechanism and stick to it.
| Mechanism | Example | Notes |
|---|---|---|
| Path | /v2/orders | Very visible; easy to route; URL sprawl |
| Header | Accept-Version: 2024-01-15 | Clean URLs; harder to cache/debug casually |
| Media type | application/vnd.acme.order.v2+json | Precise; uncommon outside platforms |
Calendar or dated versions (Stripe-style) help when many additive changes ship continuously. Major v2 bumps fit rare semantic rewrites. Always publish a sunset window; never delete v1 on the same day you invent v2.
REST vs RPC vs GraphQL for system design
Read the diagram definition
flowchart LR
subgraph REST["REST-ish HTTP"]
R1["GET /orders/42"]
R2["POST /orders"]
end
subgraph RPC["RPC / gRPC"]
G1["CreateOrder(req)"]
G2["GetOrder(id)"]
end
subgraph GQL["GraphQL"]
Q1["query { order(id) { ... } }"]
M1["mutation createOrder"]
end
Client --> REST
Client --> RPC
Client --> GQLshines with caching, CDNs, and browser tooling. Resource URLs are shareable and easy to reason about in gateways. It struggles when one user gesture needs a custom transactional verb that is not create/update.
(gRPC especially) gives typed contracts, streaming, and efficient binary payloads between services. Identity and HTTP cache semantics are weaker; you usually terminate TLS and translate at the edge for external partners. Do not expose raw internal Protobuf as your partner API without a façade.
collapses over-fetch/under-fetch for mobile. Cost: N+1 resolver risks, complex authz per field, and caching that is not “put a CDN in front of GET /orders/1.” Mutations still need idempotency keys and clear error extensions. Prefer GraphQL at a BFF boundary, not as the only protocol between every microservice.
| Axis | REST-ish | RPC | GraphQL |
|---|---|---|---|
| External partners | Strong default | Rare without SDK | Possible; heavier |
| Service-to-service | OK, verbose | Best fit | Unusual |
| Mobile flexibility | Many round trips or fat DTOs | Needs BFF | Strong |
| HTTP caching | Natural | Weak | Hard (POST-ish) |
| Idempotent creates | Keys / PUT ids | Keys in metadata | Keys on mutations |
Worked sketch: marketplace checkout API
Resources
POST /carts/{id}/checkout-sessions → creates CheckoutSession
GET /checkout-sessions/{id}
POST /checkout-sessions/{id}/confirm Idempotency-Key required
GET /orders/{id}
GET /orders?cursor=&limit=
Errors
inventory_insufficient (409)
payment_declined (402 or 409 + code)
idempotency_key_reuse_mismatch (422)
Why not GraphQL for confirm?
Confirm is one critical mutation with strict side effects;
a thin REST/RPC endpoint is easier to rate-limit, audit, and key.
Why not only RPC publicly?
Partners and browsers expect HTTP resources, webhooks, and curlable URLs.
Internal ConfirmCheckout gRPC is fine behind the public session resource.Trade-offs to weigh
Pure REST vs pragmatic resources
Cursors vs offsets
Public GraphQL vs BFF GraphQL
Version early vs evolve additively
/v1//v2//v3 multiplies support cost. Additive evolution plus deprecation windows is cheaper until a true semantic break forces a bump.Fat DTO vs chatty resources
Choosing and explaining API design principles
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
Model stable resources clients can address; map verbs to HTTP or RPC methods without leaking internal function names.
Make unsafe creates retry-safe with idempotency keys; page high-churn lists with cursors; return one error envelope with machine codes.
Choose REST-ish HTTP, RPC, or GraphQL for the audience and caching needs—not as a religious default.
When it belongs
Designing public or partner APIs for a marketplace, payments, or feed system.
Explaining how mobile retries and webhooks stay correct under partial failure.
Comparing edge protocols versus internal service contracts in a larger architecture.
How it fails
POST without idempotency keys that double-charge on timeout retry.
Offset pagination that duplicates or skips items under concurrent inserts.
Inconsistent error strings that force clients to parse English messages.
Trade-offs to weigh
Resource purity versus product velocity and SDK friendliness.
Cursor stability versus offset jump-to-page UX.
GraphQL flexibility versus caching, authz complexity, and resolver cost.
Common misunderstandings
Treating OpenAPI as the design instead of the documentation of the design.
Exposing internal gRPC as the only partner surface.
Confusing this topic with gateway routing, OAuth details, or contract-testing tooling covered elsewhere.
Wrap-up
Exercises
- Model a ticket hold. Seat holds expire in 10 minutes. Sketch resources, status transitions, idempotent confirm, and the error codes for expiry and double-book.
- Pick pagination. Admin needs page jumps; the consumer app is an infinite feed. Which list APIs use offset vs cursor, and why?
- Choose a style. Internal inventory sync at 50k msg/s vs a partner catalog API. Where do gRPC and REST each win?
Topic-specific references
- RFC 9110: HTTP Semantics — methods, safety, and idempotency definitions.
- Stripe: Idempotent requests — production pattern for keys and replay.
- Use The Index, Luke: No Offset — why keyset/cursor pagination scales.
Key takeaway
Design APIs as durable client contracts: clear resources, retry-safe writes, stable list iteration, predictable errors, and a protocol style chosen for the audience. Leave gateways, security controls, and contract-test mechanics to their own notes.