33 min read
Platform & APIs7,051 words

API Design Best Practices

A reference for interviews and real-world design. Every practice below is paired with the reason it exists, because in an interview the "why" is what earns points, and in production the "why" is what tells you when to break the rule.

Scope: REST, GraphQL, and gRPC. Foundational principles apply to all three; each style then gets its own section; cross-cutting concerns, a comparison, and an interview Q&A close it out.

How to use this in an interview: State the practice, give the reason in one sentence, then name the trade-off. Interviewers are testing judgment, not memorization — showing you know when a rule stops applying is worth more than reciting the rule.


Part 1 — Foundational principles (apply to every API style)#

These are the ideas underneath the specific rules. If you internalize these, you can derive most of the specific practices on the spot.

1. Design from the consumer's perspective (outside-in / API-first)#

Design the API contract around what client developers need to accomplish, before writing implementation, and never let your database schema or internal object model leak into the interface.

Why: The API is a product whose users are developers. An interface shaped by internal implementation forces every client to understand your internals and re-couples them to your refactors. Designing the contract first (e.g., writing the OpenAPI/GraphQL schema/.proto before code) surfaces usability problems while they're still cheap to fix and lets client and server teams work in parallel against a shared contract.

2. Consistency and the principle of least astonishment#

The same concept should look the same everywhere: naming, casing, pagination, error shape, date formats (use ISO 8601 / RFC 3339 UTC), and auth all follow one convention across every endpoint.

Why: Consistency lets a developer learn your API once and predict the parts they haven't read yet. Every inconsistency is a special case someone must discover, document, and handle — a permanent tax on every integration and a common source of bugs. "Least astonishment" means the API behaves the way an experienced developer would guess it does.

3. Simplicity and minimalism — start small#

Expose the smallest surface that solves the problem. When in doubt, leave it out.

Why: You can always add to an API, but you can rarely remove without breaking someone. Every endpoint, field, and parameter is a lifetime maintenance and backward-compatibility commitment. A smaller surface is easier to secure, document, test, and evolve. This is the single most common regret of mature API teams: too much shipped too early.

4. Explicit over implicit#

Prefer behavior the client can see and control over hidden magic — explicit pagination limits, explicit versions, explicit error codes, no silently-changing defaults.

Why: Implicit behavior can't be discovered from the contract, so clients encode assumptions that break silently when you change them. Explicitness makes the contract self-describing and changes observable.

5. Loose coupling and encapsulation#

The contract should hide implementation details (storage, internal services, algorithms) behind a stable abstraction.

Why: Loose coupling is what lets you re-architect the backend — swap databases, split a monolith, rewrite a service — without forcing clients to change. The whole economic value of an API is that both sides can evolve independently; leaking internals destroys that.

6. Robustness principle — and its limits#

"Be conservative in what you send, liberal in what you accept" (Postel's Law): send strictly correct output, tolerate reasonable input variation (e.g., ignore unknown fields rather than rejecting them).

Why (and the caveat): Tolerance of unknown fields is what makes forward/backward compatibility possible — an old client can ignore new fields a newer server sends. But modern practice tempers this: being too liberal accumulates undefined behavior that clients start depending on, which becomes impossible to tighten later. The refined rule: ignore unknown fields for compatibility, but validate strictly on the fields you do define.

7. Statelessness (where possible)#

Each request should carry everything the server needs to process it; the server keeps no client session state between requests.

Why: Stateless requests can be routed to any server instance, which makes horizontal scaling, load balancing, and failover trivial. Server-side session state ties a client to a specific node and becomes a scaling and reliability bottleneck. (This is a core REST constraint but benefits every style.)

8. Secure and private by default#

Authentication, authorization, transport encryption, and input validation are designed in from the start, not bolted on. Default to the least data and least access that works.

Why: APIs are the primary attack surface of modern systems, and authorization flaws are the #1 category in the OWASP API Security Top 10. Retrofitting security is expensive and error-prone; a breach is far costlier than the design effort. "Least privilege by default" limits blast radius when something does go wrong.


Part 2 — REST / HTTP API design#

REST is the most common interview focus. The core idea: model your system as resources (nouns) identified by URLs, and act on them with the uniform interface of HTTP methods and status codes. Done well, you get to reuse the entire HTTP ecosystem — caches, proxies, status semantics, conditional requests — for free.

Resource modeling: nouns, not verbs#

Model endpoints as resources (/orders, /orders/42/items), not actions (/getOrder, /createOrder). The HTTP method supplies the verb.

Why: A small, fixed set of verbs (GET/POST/PUT/PATCH/DELETE) applied to an open-ended set of nouns keeps the API uniform and predictable — clients, caches, and proxies all understand what GET and DELETE mean without reading your docs. Verb-in-URL designs multiply endpoints (/getOrder, /updateOrder, /cancelOrder…) and lose HTTP's built-in semantics like cacheability and idempotency. (Interview red flag: verbs in the path.)

URI conventions#

Use plural nouns for collections (/users, /users/123), lowercase with hyphens for multi-word paths (/shipping-addresses), and express hierarchy through nesting only when the child truly belongs to the parent (/users/123/orders). Keep nesting shallow (rarely more than one level).

Why: Plurals read naturally for both the collection and its members and keep the pattern uniform. Shallow nesting avoids brittle, ultra-long URLs that break when a resource needs a second parent; past one level, prefer linking by ID or a top-level resource with a filter (/orders?userId=123). Consistent casing prevents the class of bugs where /shippingAddresses and /shipping-addresses both exist.

Actions that don't fit CRUD#

Some operations aren't naturally a noun (/carts/42/checkout, /articles/9/publish). Model these as a sub-resource or a controller-style action, and prefer POST.

Why: Forcing every operation into pure CRUD produces awkward designs (representing "send email" as creating an email resource can be fine, but "restart server" is not a resource). A pragmatic, clearly-named action endpoint is more honest and more usable than a contorted noun. State the trade-off in interviews: purists dislike it, but real APIs (including major public ones) use action sub-resources deliberately.

HTTP methods and their semantics#

Use each method for its defined meaning. Two properties matter enormously:

  • Safe = read-only, no observable state change (server may still log).
  • Idempotent = making the same call N times has the same effect as making it once.
MethodPurposeSafeIdempotentBody
GETRead a resource/collectionYesYesNo
HEADLike GET, headers onlyYesYesNo
OPTIONSDiscover allowed methods/CORSYesYesNo
POSTCreate, or non-idempotent actionNoNoYes
PUTCreate/replace at a known URINoYesYes
PATCHPartial updateNoNot requiredYes
DELETERemove a resourceNoYesNo

Why it matters: Safety and idempotency are contracts that the whole ecosystem relies on. Browsers, proxies, and CDNs cache and pre-fetch GET because it's safe. Clients, load balancers, and service meshes automatically retry idempotent methods after a timeout because a retry can't cause harm — this is fundamental to building reliable distributed systems. Put a side effect behind a GET and a crawler will trigger it; make PUT non-idempotent and automatic retries will corrupt data.

  • PUT vs PATCH vs POST: PUT replaces the entire resource (send the whole representation) and is idempotent — sending it twice leaves the same final state. PATCH sends only a delta and is not required to be idempotent (e.g., "increment balance"), so design PATCH payloads carefully. POST is for creation where the server assigns the ID, and for operations that aren't idempotent.

Idempotency keys for unsafe operations#

For POST operations that must not double-execute (payments, orders), accept a client-generated Idempotency-Key header; the server stores the result for that key and returns the same response on retries.

Why: Networks fail after the server has acted but before the client gets the response, so the client can't know whether to retry. Without an idempotency key, retrying risks charging the customer twice; not retrying risks losing the order. The key lets clients retry safely and is the standard pattern for reliable payment APIs. (Strong interview signal — brings up exactly-once vs at-least-once delivery.)

HTTP status codes — use the standard ones correctly#

Return status codes that accurately reflect the outcome. The essential set:

  • 2xx success: 200 OK (general success with body), 201 Created (new resource — include a Location header), 202 Accepted (async work started, not yet done), 204 No Content (success, empty body — common for DELETE/PUT).
  • 3xx redirection: 301/308 permanent, 302/307 temporary, 304 Not Modified (conditional GET — client's cached copy is still valid).
  • 4xx client errors: 400 (malformed), 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict (state conflict, e.g., duplicate or edit collision), 410 Gone, 422 Unprocessable Content (well-formed but semantically invalid), 428 Precondition Required, 429 Too Many Requests (rate limited).
  • 5xx server errors: 500 (unexpected), 502/503/504 (upstream/overloaded/timeout).

Why: Status codes are a machine-readable contract. Clients, caches, monitoring, and retry logic branch on them: a 429 or 503 should trigger back-off-and-retry, a 400 should not (retrying won't help). The classic anti-pattern — returning 200 OK with {"error": ...} in the body — breaks every generic tool, hides failures from monitoring, and forces every client to parse the body to learn if the call worked. Distinguishing 401 (who are you?) from 403 (I know you, you can't do this) and 400 (your request is broken) from 422 (your request is valid but the data isn't) gives clients enough to react correctly.

Consistent, informative error bodies#

Return a single, consistent error format across the whole API. Prefer the standard Problem Details for HTTP APIs (RFC 9457, formerly RFC 7807): a JSON object with type, title, status, detail, and instance, extended with a machine-readable error code and field-level validation errors.

Why: A stable error schema means clients write error handling once instead of per-endpoint. A machine-readable code (e.g., "code": "insufficient_funds") lets clients branch reliably without string-matching human text you'll later reword for translation. Field-level details turn a form full of errors into one round trip. Never leak stack traces or internal identifiers — that's an information-disclosure vulnerability. Include a request/correlation ID so a user can quote it and support can find the exact log line.

Filtering, sorting, and field selection#

Use query parameters for these on collection endpoints: ?status=active&sort=-createdAt&fields=id,name. Leading - for descending is a common convention.

Why: These are refinements of a read, so they belong on GET as parameters rather than as new endpoints — one flexible /orders beats /activeOrders, /ordersByDate, etc. Field selection (sparse fieldsets) lets clients cut payload size and is a lightweight answer to REST's over-fetching problem. Keep it as query params (not body) so the request stays a cacheable, bookmarkable GET.

Pagination — and why cursor beats offset at scale#

Never return an unbounded collection; always paginate with a sane default and a max page size. Two main styles:

  • Offset/limit (?offset=100&limit=20 or page numbers): simple, allows jumping to arbitrary pages.
  • Cursor/keyset (?limit=20&cursor=eyJpZCI6...): instead of a numeric position, the client sends back an opaque token that the server handed out with the previous page. That token encodes the sort key of the last row you received — often the tuple that makes ordering unique, such as (created_at, id) — so fetching the next page means "return the rows that sort after this key" rather than "skip N rows." The cursor is usually Base64-encoded JSON precisely so clients treat it as opaque: they store and echo it without parsing, which lets you later change what's inside it (add a tie-breaker column, switch the sort key, encrypt it) without breaking any client. Each response returns the next cursor plus a flag like hasNextPage; the client just loops — pass the latest cursor back, get the next slice — until no cursor comes back, which signals the end. Note there's no concept of "page 7": you can only walk forward (or backward, with a matching before/endCursor) from where you are.

Why paginate at all: An endpoint that returns "all rows" is a latency, memory, and denial-of-service problem waiting for the table to grow — and a guaranteed rewrite later. A max page size caps the damage a single request can do.

Why cursor for large/changing data: Offset pagination degrades — OFFSET 1,000,000 forces the database to scan and discard a million rows (slow), and if items are inserted/deleted while paging, offsets shift so clients skip or duplicate rows. Keyset pagination uses an indexed WHERE id > last_seen that stays fast at any depth and is stable under concurrent writes. The trade-off: cursors can't jump to "page 500" and are harder to implement — say this in an interview; offset is fine for small, admin-style datasets.

Versioning — and why you need a strategy before launch#

Have an explicit versioning strategy from day one. Common approaches: URI path (/v1/orders), custom header (Api-Version: 2), or media-type / Accept header (Accept: application/vnd.acme.v2+json). Whichever you pick, apply it consistently.

Why: Once external clients depend on you, you can't change the contract in place without breaking them — and you don't control their release schedule. Versioning lets you ship breaking changes as a new version while old clients keep working on the old one. The reason to prefer URI versioning is pragmatic: it's the most visible, easiest to route, test, and cache, and trivial to hit from a browser or curl — which is why most large public APIs use it. Header/media-type versioning is "purer" (the URL identifies the resource, not its representation) and enables finer-grained negotiation, but it's invisible in a URL, harder to test, and easy to get wrong with caches. The deeper best practice: version as rarely as possible by making backward-compatible changes (add, don't remove or repurpose) so most changes never need a new version at all.

Backward compatibility rules#

Additive changes are safe; removals and semantic changes are breaking. Safe: adding a new endpoint, a new optional field, a new optional parameter, a new enum value if clients are told to tolerate unknowns. Breaking: removing/renaming a field, making an optional field required, changing a type or the meaning of a value, changing error codes clients depend on.

Why: Clients you don't control have hard-coded assumptions about the current shape. An additive change leaves those assumptions intact (they ignore what they don't know about); a subtractive or semantic change violates them and breaks integrations in production, often silently. Knowing exactly which changes are safe is what lets you evolve for years without a /v2.

Caching — reuse HTTP's built-in machinery#

Make responses cacheable where appropriate using Cache-Control (e.g., max-age, no-store, private/public) and validators — ETag (a content fingerprint) with If-None-Match, or Last-Modified with If-Modified-Since. Return 304 Not Modified when the client's copy is still fresh.

How an ETag round-trip works: An ETag is a compact validator the server derives from the response — typically a hash of the body or a version/revision number — and returns in the ETag response header (e.g., ETag: "a1b2c3"). The client stores the response along with that tag; on its next request for the same URL it sends If-None-Match: "a1b2c3". The server recomputes the resource's current ETag and compares them: if they match, nothing changed, so it answers 304 Not Modified with an empty body and the client reuses its cached copy; if they differ, it returns the full new representation with a fresh ETag. That's where the savings come from — the server still does the work to check freshness, but skips serializing and transmitting an unchanged payload, and the client skips re-downloading and re-rendering it. ETags come in two flavors: strong ("abc" — guarantees byte-for-byte identical content) and weak (W/"abc" — only semantically equivalent, useful when something trivial like a timestamp differs but the meaningful content doesn't). The same tag doubles as the version token for optimistic concurrency via If-Match on writes (see below), which is why one mechanism gives you both cheap caching and lost-update protection.

In Spring Boot, the idiomatic way is WebRequest.checkNotModified(etag). It compares your tag against the incoming If-None-Match and, on a match, writes the 304 status and ETag header for you so you can short-circuit before doing the expensive work:

java
@GetMapping("/products/{id}") public ResponseEntity<Product> getProduct(@PathVariable Long id, WebRequest request) { String etag = "\"" + productService.getVersion(id) + "\""; // cheap lookup: a @Version or updated_at if (request.checkNotModified(etag)) { return null; // Spring already wrote 304 + the ETag header; no body is sent } Product product = productService.findById(id); // only load/serialize on a real change return ResponseEntity.ok() .header(HttpHeaders.ETAG, etag) // send the same tag back with the 200 .body(product); }

For zero-code, per-endpoint bandwidth savings you can instead register Spring's ShallowEtagHeaderFilter, which hashes the rendered response body and returns 304 on a match:

java
@Bean FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() { var reg = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); reg.addUrlPatterns("/api/*"); return reg; }

The choice maps exactly onto the point above: the checkNotModified version is a strong/deep ETag — a cheap version check lets you skip loading and serializing the entity entirely — whereas the filter is shallow, still building the full response and hashing it, so it saves bandwidth but not server compute.

And Last-Modified / If-Modified-Since: This is the older, date-based version of the same idea. The server returns a Last-Modified header carrying the resource's last-change timestamp (e.g., Last-Modified: Wed, 01 Jul 2026 10:00:00 GMT); the client stores it and echoes it back as If-Modified-Since on its next request. The server compares that date to the resource's current modification time and replies 304 Not Modified if it hasn't changed since. It's cheap to produce when you already track a "last updated" time (no hashing of the body needed), but it's a weaker validator for two reasons: its resolution is only one second, so two changes within the same second are indistinguishable, and a plain timestamp can't notice content that changed and then reverted. Prefer ETag when you need precision; Last-Modified is a fine low-cost fallback, and the two can be sent together — if a client has both, If-None-Match (the ETag) takes priority and If-Modified-Since is used only as a backup.

Why: Caching is the highest-leverage performance and cost lever in HTTP. A conditional request that ends in 304 skips regenerating and re-sending the payload — saving bandwidth and server work — while still guaranteeing freshness. Cache-Control lets CDNs and browsers serve repeat reads without hitting your origin at all. This machinery is free if you use correct methods and status codes, which is a big part of why REST-over-HTTP won. The same ETag also powers optimistic concurrency (below).

Optimistic concurrency control#

For updates, let clients send If-Match: "<etag>"; the server rejects with 412 Precondition Failed if the resource changed since the client last read it.

Why: It prevents the "lost update" problem — two clients read the same record, both write, and the second silently overwrites the first. Compared to server-side locking, optimistic concurrency doesn't hold locks across the network (which would kill throughput and invite deadlocks); it only fails the rare case where a real conflict occurred, and pushes the retry decision to the client that has the context to resolve it.

Rate limiting and quotas#

Protect the API with per-client limits. Return 429 Too Many Requests when exceeded, include a Retry-After header, and ideally expose current limit state (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset).

Why: Without limits, one buggy or abusive client can exhaust capacity and cause an outage for everyone (a "noisy neighbor" and a DoS vector — "Unrestricted Resource Consumption" is in the OWASP API Top 10). Rate limiting enforces fair use, protects capacity, and enables tiered commercial plans. Returning 429 + Retry-After (instead of just dropping requests) tells well-behaved clients exactly how to back off, so they recover gracefully instead of hammering you.

Long-running / asynchronous operations#

For work that can't finish within a request, return 202 Accepted with a status resource URL the client can poll, and/or deliver the result via a webhook (a callback POST to a client-registered URL). Consider the async request-reply pattern for anything beyond a few seconds.

Why: Holding an HTTP connection open for a long job ties up server resources, hits proxy/load-balancer timeouts, and gives the client no way to recover if the connection drops. Decoupling submission from completion lets each scale independently and gives clients a reliable way to track progress. Webhooks avoid wasteful polling but add complexity (clients need a public endpoint, and you need retries + signatures) — name that trade-off.

Security essentials for REST#

Encrypt everything in transit (TLS/HTTPS only). Authenticate with a proven scheme — OAuth 2.0 / OpenID Connect for delegated user access, API keys for server-to-server, short-lived tokens over long-lived ones. Authorize every request at the object level — check that this caller may access this specific resource, not just that they're logged in. Validate and sanitize all input; never put secrets or sensitive data in URLs.

Why: TLS prevents eavesdropping and tampering on the wire. The object-level check is the single most important one: Broken Object Level Authorization (BOLA/IDOR) is the #1 API vulnerability — an app that returns /accounts/{id} without verifying ownership lets anyone read others' data by changing the ID. Input validation blocks injection attacks. Secrets in URLs leak into logs, browser history, and referrer headers, so they belong in headers or the body. Short-lived tokens limit the damage window if one is stolen.

Documentation and discoverability#

Ship machine-readable, always-current documentation — an OpenAPI (Swagger) specification for REST — ideally generated from or validated against the code, with examples for every endpoint.

Why: An API is only as good as its docs; undocumented behavior effectively doesn't exist to the developers integrating with you. A machine-readable spec is doubly valuable: it drives interactive docs, generates client SDKs and server stubs, and powers contract tests — turning the documentation into an enforceable, executable contract rather than prose that drifts out of date. Adopting an "API-first" workflow (write the spec, then implement) is how teams keep docs and reality in sync.

HATEOAS (hypermedia) — the aspirational ceiling#

The strict REST model says responses should include links to related actions/resources (_links), so clients navigate by following links rather than hard-coding URLs.

Why (and why it's often skipped): In theory, hypermedia decouples clients from your URL structure and lets the server evolve endpoints without breaking clients — the client just follows links. In practice, most teams skip full HATEOAS because the tooling and client discipline aren't there, and hard-coded URL templates are simpler. Worth knowing for interviews as the "purest" level of REST (Richardson Maturity Model Level 3) — mention it, then give the pragmatic take that few APIs implement it fully.


Part 3 — GraphQL API design#

GraphQL exposes a single endpoint and a strongly-typed schema; clients ask for exactly the fields they want in one query. This solves REST's over-/under-fetching, but moves complexity from URL design to schema design, and shifts several concerns (versioning, caching, rate limiting) onto you because HTTP's built-in machinery no longer applies per-resource.

Design the schema around the domain graph, not your database#

Model types and their relationships as clients think about the domain (a User has posts, a Post has an author), not as a mirror of your SQL tables.

Why: GraphQL's whole value is letting clients traverse relationships naturally in one request. A schema that mirrors tables re-introduces the coupling GraphQL is supposed to remove and produces clumsy queries full of joins the client has to assemble. Designing around the graph makes the common client journeys a single, natural query.

Schema-first, and treat the schema as the contract#

Define the schema (SDL) deliberately and, ideally, before implementation; the type system is your API documentation and validation layer.

Why: The schema is strongly typed and introspectable, so tools give clients autocomplete, validation, and generated types for free — but only if the schema is designed, not accidentally derived. Schema-first keeps the contract front-and-center and lets frontend and backend teams work against it in parallel.

Use nullability deliberately#

GraphQL fields are nullable by default; mark a field non-null (String!) only when it truly can never be null. Be especially cautious making list elements and nested objects non-null.

Why: Non-null is a promise. In GraphQL, if a non-null field resolves to null (e.g., a downstream service fails), the error propagates up to the nearest nullable parent, potentially nulling out a whole chunk of an otherwise-successful response. Judicious nullability means one flaky field degrades gracefully instead of wiping out the query. Over-using ! makes the API brittle to partial failures.

Standardize pagination with cursor connections#

Use the Relay Connection pattern for lists: edges { node, cursor } plus pageInfo { hasNextPage, endCursor }.

Why: Same reasons cursors beat offsets in REST (stable, scalable pagination), plus GraphQL benefits from one agreed pagination shape across every list in the graph — consistency that tooling and client caches (like Relay/Apollo) can rely on automatically. Rolling your own per-field pagination throws that away.

Solve the N+1 problem with batching (DataLoader)#

Because resolvers run per-field, a query over a list naively fires one downstream call per item (fetch 50 posts → 50 separate author lookups). Batch and cache these within a request using the DataLoader pattern.

Why: N+1 is the classic GraphQL performance failure — it turns one client query into hundreds of database round trips and falls over under load. Batching collapses them into a single WHERE id IN (...) per tick; per-request caching de-duplicates repeated lookups. Any serious GraphQL interview will probe this.

Design mutations with a single input type and a payload type#

Give each mutation one structured input argument and return a dedicated payload type that includes the affected object(s) and a typed errors field: createOrder(input: CreateOrderInput!): CreateOrderPayload.

Why: A single input object keeps signatures stable — you can add optional fields without changing the mutation's shape. Returning the mutated entity lets clients update their cache from the response without a second query. A typed errors field in the payload models expected, recoverable business errors (validation, "out of stock") as data the client can branch on, rather than throwing them into the transport-level errors array meant for exceptional failures.

Distinguish the two kinds of errors#

Use the top-level errors array for exceptional/system failures (auth, unexpected exceptions), and model expected business outcomes as part of the schema — often a union result type (OrderResult = Order | OutOfStockError | PaymentDeclinedError).

Why: GraphQL always returns HTTP 200, and partial success is normal — some fields resolve, others error. Overloading the generic errors array for predictable business cases forces clients into string-matching and loses type safety. Modeling expected errors in the schema makes them discoverable, typed, and impossible for the client to forget to handle.

Evolve continuously; deprecate instead of versioning#

GraphQL is designed not to be versioned. Add new fields/types freely, mark obsolete ones @deprecated(reason: "..."), and remove them only after usage drops to zero (measured with field-level analytics).

Why: Because clients request specific fields, adding a field can't break anyone who didn't ask for it — so most changes are non-breaking by construction, and a global /v2 would be pointless. @deprecated guides clients off old fields with tooling-visible warnings while you watch real usage, letting you retire fields safely. This continuous-evolution model is a genuine advantage over REST versioning — say so, then note the catch: you must measure per-field usage, or you can never safely remove anything.

Protect against expensive queries (this is on you, not HTTP)#

Enforce query depth limiting, complexity/cost analysis, timeouts, and (in production) persisted queries — an allow-list of known query hashes.

Why: A single flexible endpoint means a client can craft a deeply nested or hugely expansive query that costs enormous work — an easy denial-of-service vector that REST's per-endpoint limits would have contained. Cost analysis rejects too-expensive queries before executing them; persisted queries restrict production traffic to vetted queries and shrink request size. This is the security concern interviewers most want to hear for GraphQL.

Global object identification#

Give objects a globally unique id (the Relay Node interface with node(id:)).

Why: A standard way to refetch any object by ID is what lets client caches (Apollo/Relay) normalize and update entities automatically, and lets clients re-fetch a single object after a mutation. It's the GraphQL analogue of a REST resource URL.

Caching in GraphQL is harder — plan for it#

Because everything is a POST to one URL, HTTP caching doesn't apply out of the box. Compensate with client-side normalized caches, persisted queries served over GET (so CDNs can cache), and server-side per-resolver caching.

Why: This is the flip side of GraphQL's flexibility: you lose the free HTTP/CDN caching REST enjoys. Knowing that trade-off — and the mitigations — is exactly what separates a hype answer from an engineering answer in an interview.


Part 4 — gRPC API design#

gRPC is a high-performance, contract-first RPC framework: you define services and messages in Protocol Buffers (.proto), which generate strongly-typed client/server code in many languages, transported as compact binary over HTTP/2. It shines for internal service-to-service communication and streaming; it's a weaker fit for public/browser-facing APIs.

Define the contract in .proto and treat it as the source of truth#

Write the service and message definitions first; code is generated from them for every language.

Why: The .proto is a single, strongly-typed, language-neutral contract that both sides compile against, eliminating a whole class of client/server mismatch bugs and hand-written serialization. Codegen means the compiler — not a human reading docs — enforces the contract.

Never reuse or renumber fields; reserve removed ones#

Each field has a number that is its identity on the wire. When you remove a field, reserve its number and name; never change an existing field's number or type.

Why: Protobuf encodes field numbers, not names, into the binary payload — that's what makes it compact and fast. Backward/forward compatibility depends entirely on numbers staying stable: reuse a number for a new meaning and old binaries will silently misread new data (data corruption, not an error). reserved prevents a future developer from accidentally recycling a retired number. This is the gRPC equivalent of REST's compatibility rules, and it's a favorite interview question.

Design messages for evolution#

Add new fields as optional with new numbers; don't make required assumptions. Use well-known types (Timestamp, Duration), enums with a zero "UNSPECIFIED" default, and wrapper types when you must distinguish "unset" from "zero."

Why: proto3 fields are optional-by-default and unknown fields are preserved on pass-through, so additive changes are non-breaking — old and new services interoperate. The zero-value default is a trap: an int field defaulting to 0 or an enum to its first value can be indistinguishable from "not set," so you make the first enum value UNSPECIFIED to reserve the ambiguous default and catch un-set fields.

Choose the right streaming mode#

gRPC offers four call types over HTTP/2: unary (1 request → 1 response), server-streaming (1 → many), client-streaming (many → 1), and bidirectional (many ↔ many). Use streaming for large datasets, live feeds, or long-lived exchanges; use unary for ordinary request/response.

Why: HTTP/2 multiplexes many streams over one connection, so gRPC can stream efficiently without opening new connections. Streaming lets you process data incrementally (start handling results before the full set is ready) and push updates in real time — things REST does awkwardly. But streaming adds flow-control and error-handling complexity, so don't reach for it when a unary call suffices.

Always set deadlines/timeouts (and propagate them)#

Clients should specify a deadline on every call, and servers should propagate the remaining deadline to downstream calls.

Why: Without a deadline, a hung dependency ties up the caller indefinitely; under load these pile up and cascade into a system-wide outage. A propagated deadline means once the client has given up, every downstream service can stop wasting work on a doomed request — essential for resilient microservices. "Always set a deadline" is a standard gRPC production rule worth stating explicitly.

Use the standard status codes and rich error details#

Return the appropriate gRPC status code (INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, UNAUTHENTICATED, FAILED_PRECONDITION, RESOURCE_EXHAUSTED, UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL, etc.) and attach structured details via google.rpc.Status.

Why: Like HTTP status codes, gRPC codes are a machine-readable contract that clients and middleware use to decide retries — UNAVAILABLE and DEADLINE_EXCEEDED are typically retriable, INVALID_ARGUMENT and NOT_FOUND are not. Structured error details let you return field-level validation info without stuffing it into a human string. Pick codes precisely, because retry and alerting behavior depends on them.

Distinguish retriable from non-retriable, and keep operations idempotent#

Design mutating RPCs to be safely retriable (idempotency keys / request IDs), and only mark transient failures with retriable codes.

Why: gRPC clients and service meshes retry automatically on certain codes; if a non-idempotent mutation returns a retriable code, automatic retries can duplicate the effect. Explicit idempotency and correct code choice are what make automatic retries safe rather than dangerous — the same reliability logic as REST idempotency keys.

Version via the package name#

Put the version in the protobuf package (package acme.orders.v1;); introduce breaking changes as a new package/version (v2) run alongside v1.

Why: Package-level versioning namespaces the generated code so v1 and v2 can coexist in the same binary and be served simultaneously during migration. Combined with the never-break-a-field rules, most changes stay within a version; only genuine breaks force a new package.

Know when not to use gRPC#

Prefer REST/GraphQL at the public edge and for browser clients; use gRPC between internal services.

Why: gRPC's binary format isn't human-readable, browsers can't speak raw gRPC without a proxy (gRPC-Web), and its tooling assumes you control both ends. Its strengths — compactness, codegen, streaming, low latency — pay off most in internal microservice meshes where both sides share the .proto and you value performance over curl-ability. Naming this boundary is a strong "senior" signal.


Part 5 — Cross-cutting concerns (all styles)#

These apply regardless of REST/GraphQL/gRPC and are where many real systems succeed or fail.

Security in depth#

Beyond authentication, enforce authorization on every request at the object and field level, apply least privilege to tokens and scopes, validate all input against a schema, rate-limit, and keep an inventory of your APIs and versions.

Why: The OWASP API Security Top 10 is dominated by authorization failures (BOLA, broken function-level and property-level authorization) and by resource abuse — not exotic exploits. Checking "is this user allowed to touch this object/field?" on every call closes the biggest hole. Least privilege limits blast radius; input validation stops injection; an API inventory prevents forgotten "shadow" and deprecated endpoints from becoming unguarded back doors (a leading real-world breach cause).

Observability — logging, metrics, tracing, correlation IDs#

Emit structured logs, metrics (latency, error rate, throughput per endpoint), and distributed traces, and propagate a correlation/request ID across services (accept an inbound X-Request-Id/traceparent or generate one).

Why: You cannot operate or debug an API you can't see. In a distributed system a single client call fans out across services; a correlation ID is what lets you stitch those logs back into one story and answer "what happened to request X?" Per-endpoint metrics are how you catch regressions, set SLOs, and know which change hurt. Returning the request ID to clients turns a vague bug report into a precise lookup.

Deprecation policy#

Deprecate before you remove: announce, mark it (HTTP Deprecation and Sunset headers, GraphQL @deprecated, proto reserved/comments), give a timeline, and monitor usage until it's safe.

Why: Breaking changes without warning erode trust and break production integrations you can't see. A clear, signaled deprecation window lets clients migrate on a schedule and lets you retire code with data (usage → zero) rather than hope. Predictability is a feature of a professional API.

Idempotency and reliability#

Make writes safe to retry (idempotency keys, request IDs, conditional requests), because at-least-once delivery is the norm in distributed systems.

Why: Networks drop responses after the work is done, so clients will retry; without idempotency, retries double-charge, double-order, and corrupt state. Designing for it converts an unreliable network into reliable behavior — the same principle recurs in REST (Idempotency-Key), gRPC (request IDs), and messaging.

Consistent conventions across the surface#

Pick one convention for naming, casing (e.g., snake_case or camelCase for JSON — just be consistent), timestamps (ISO 8601 / RFC 3339, UTC), IDs, pagination, and errors, and apply it everywhere.

Why: As in the foundational principles, consistency is compounding: every convention a developer can assume is one less thing to look up, and one less place for bugs to hide. Inconsistency is a tax paid on every future endpoint and every integration.


Part 6 — Choosing between REST, GraphQL, and gRPC#

There's no universally "best" style — the skill is matching the style to the constraints. A compact comparison:

DimensionRESTGraphQLgRPC
ContractOpenAPI (optional)Schema (SDL), requiredProtobuf .proto, required
PayloadJSON (text)JSON (text)Protobuf (binary)
TransportHTTP/1.1+Usually HTTP POSTHTTP/2
Data fetchingFixed per endpoint; over/under-fetchClient picks exact fieldsFixed per method
CachingExcellent (native HTTP)Hard (custom)Custom
StreamingLimited (SSE/long-poll)SubscriptionsFirst-class (4 modes)
Browser-friendlyYesYesNo (needs gRPC-Web proxy)
Best fitPublic APIs, CRUD, broad reachAggregating many sources; varied client needs (mobile)Internal microservices, low latency, streaming
Main riskOver/under-fetching, endpoint sprawlQuery-cost DoS, caching, N+1Not human/browser-friendly, tooling lock-in

How to reason about it in an interview: Start from the constraints, not the technology. Public API with many unknown third-party consumers? REST — ubiquity, caching, and low barrier to entry win. One backend feeding diverse mobile/web clients that each need different slices of a highly connected dataset? GraphQL — clients fetch exactly what they need in one round trip. High-throughput, low-latency calls between internal services you control on both ends, or streaming? gRPC — binary efficiency, codegen, and HTTP/2 streaming win. Many mature systems use all three: gRPC internally, a GraphQL or REST gateway at the edge. Saying that — and explaining why each fits its layer — is the strongest possible answer.


Part 7 — Interview angle: likely questions, talking points, and red flags#

Questions you should be ready for#

  • "Design a REST API for [URL shortener / e-commerce cart / file storage]." Drive it: identify resources → URIs → methods → status codes → auth → pagination → versioning → errors. Narrate trade-offs as you go.
  • "REST vs GraphQL vs gRPC — when would you use each?" Use the constraints-first framing above; mention using them together in layers.
  • "How do you version an API without breaking clients?" Distinguish additive (safe) vs breaking changes; prefer backward-compatible evolution; version (URI/header/package) only for real breaks; deprecate with a signaled window.
  • "How do you handle a payment endpoint that might be retried?" Idempotency keys; store result per key; return the same response on replay; tie to at-least-once delivery.
  • "How do you paginate a billion-row table?" Cursor/keyset over offset, with reasons (offset scans and discards; cursors use indexed range scans and are stable under writes).
  • "How would you secure this API?" TLS; OAuth2/OIDC; short-lived least-privilege tokens; object-level authorization on every request (BOLA); input validation; rate limiting; no secrets in URLs.
  • "What happens if a request times out midway?" Idempotency + retries on idempotent methods; correlation IDs to trace; deadlines that propagate.
  • "How do you prevent one client from taking down the API?" Rate limiting + quotas (429, Retry-After); (GraphQL) query cost limits; (gRPC) deadlines and load shedding.

Talking points that signal seniority#

State the practice, then the reason, then the trade-off — that trio is the whole game. Anchor claims in HTTP semantics (safe/idempotent), the OWASP API Top 10 (BOLA as #1), backward-compatibility rules, and the "you can add but not remove" principle. Mention standards by name where relevant (OpenAPI, RFC 9457 Problem Details, OAuth 2.0/OIDC, Relay connections, Protocol Buffers) — precision reads as experience.

Red flags interviewers listen for#

Verbs in REST URLs (/getUser); returning 200 OK with an error in the body; using POST for everything; no pagination (or offset pagination assumed to scale); no versioning story; "authentication" treated as equal to "authorization" (missing the per-object check); ignoring idempotency on writes; leaking stack traces or internal IDs in errors; and recommending GraphQL or gRPC as universally "better" without naming their caching, DoS, or browser trade-offs.


One-paragraph summary#

Treat the API as a product for developers: design the contract first and from the consumer's side; keep it small, consistent, and explicit so it's predictable and can grow without breaking. In REST, model resources with correct HTTP methods and status codes so you inherit HTTP's caching, retries, and idempotency for free; paginate with cursors; version sparingly and evolve additively; and put object-level authorization, input validation, rate limiting, and TLS in from the start. GraphQL trades REST's endpoint sprawl and free HTTP caching for client-chosen fields and continuous schema evolution — at the cost of owning caching, N+1 batching, and query-cost protection yourself. gRPC trades human-readability and browser reach for binary speed, codegen, and first-class streaming, making it ideal between internal services. Across all three, the durable skills are the same: preserve backward compatibility, design for retries and failure, make everything observable, and — in every answer — pair each practice with the reason it exists and the trade-off it carries.