SwadhyAI Notes

All System Design notes

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

1

Stable nouns clients can address for years.

2

Retries must not invent second side effects.

3

Cursors beat deep offsets under churn.

4

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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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.

SmellBetter modelWhy
POST /doCheckoutPOST /orders from cartCreates an Order with identity and status
GET /getUserByEmailGET /users?email= or lookup resourceKeeps collection conventions; verb is in the method
Exposing user_fk columnsStable public ids + linksSchema 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.

http
# 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 success

Idempotency keys: the money path

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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
    end

Clients 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.

plaintext
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_id

Pagination 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.

StyleStrengthWeakness
OffsetJump to page N; total count easyDrift + deep-page cost
Cursor / keysetStable under inserts; index-friendlyNo cheap “page 50”; opaque tokens
Continuation token (search)Engine-specific resumeShort 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
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.

json
{
  "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 appears

Versioning 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.

MechanismExampleNotes
Path/v2/ordersVery visible; easy to route; URL sprawl
HeaderAccept-Version: 2024-01-15Clean URLs; harder to cache/debug casually
Media typeapplication/vnd.acme.order.v2+jsonPrecise; 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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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 --> GQL

shines 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.

AxisREST-ishRPCGraphQL
External partnersStrong defaultRare without SDKPossible; heavier
Service-to-serviceOK, verboseBest fitUnusual
Mobile flexibilityMany round trips or fat DTOsNeeds BFFStrong
HTTP cachingNaturalWeakHard (POST-ish)
Idempotent createsKeys / PUT idsKeys in metadataKeys on mutations

Worked sketch: marketplace checkout API

plaintext
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

Textbook REST with hypermedia everywhere slows product teams and rarely matches SDK expectations. Pragmatic resource APIs with consistent verbs, status codes, and a few workflow links deliver most of the benefit. Pick purity only where the workflow graph is the product (payment state machines).

Cursors vs offsets

Offsets win admin UIs that need “jump to page 12” and total counts. Cursors win user feeds and high-churn lists. Offering both doubles test surface—document which lists support which mode.

Public GraphQL vs BFF GraphQL

A public graph exposes your entire domain model and authz matrix. A BFF graph for first-party apps can be thinner and versioned with the app. Third parties often still want REST resources and webhooks.

Version early vs evolve additively

Premature /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

One giant GET reduces round trips and couples every client to every field. Fine-grained resources need composition (gateway/BFF) or GraphQL. Choose based on client diversity and change rate—not aesthetics.

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

  1. 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.
  2. Pick pagination. Admin needs page jumps; the consumer app is an infinite feed. Which list APIs use offset vs cursor, and why?
  3. 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

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.