Most parts of a system can be rewritten. You can swap the database, rename the tables, restructure the services, and as long as the behavior on the outside stays the same, nobody outside your team ever knows. An API is the one part where that stops being true. The moment the first external client integrates against it, every field name, every status code, every error shape, and every pagination style is frozen. Other people have written code against your decisions, and then they have stopped touching that code, which means your mistake is now their production dependency for years.
That is what makes API design a distinct discipline from the architecture behind it. A schema is private and you migrate it on your own schedule. A public API surface is a promise you signed on behalf of your future self. So the interesting questions are almost never about raw throughput. They are about how you model resources so they still make sense in three years, how you evolve the contract without breaking existing callers, how you make a retried request safe to run twice, and how you tell a client the difference between bad input, insufficient permission, and a genuine server fault.
This is the long version. We will start with the idea of the API as a frozen contract, then work through the three dominant styles and when each fits, the gateway that carries the cross-cutting concerns, versioning, pagination, idempotency, and the error format that decides whether an API is one people love or one they dread. Any latency or size figures I give are industry-typical ranges for illustration, not measured numbers from a specific vendor.

An API is a frozen contract others build on: internal code can change freely, the surface cannot.
The contract is the product
Here is the mental shift that separates people who wire up endpoints from people who design APIs. Internally, your data model is yours. You can normalize it, denormalize it, shard it, and move it between stores, and none of that is anyone else's business. The API is the translation layer between that private world and the public one, and its entire job is to stay stable while the private world churns underneath.
This is why the single most important habit in API design is separating the external contract from the internal model. When a service serializes a response, it is not dumping its database rows onto the wire. It is producing the agreed representation, deliberately, so that when you refactor storage next quarter the callers never notice. Every time an internal detail leaks through the contract, a database column name that becomes a JSON field, an enum value that mirrors an internal state machine, you have quietly handed your integrators a dependency on something you thought was private, and you will discover it the day you try to change it.
Good API design leans hard toward backward compatibility, predictability, and least surprise, for one blunt reason. The cost of a breaking change is paid by every integrator at once, and it cannot be undone unilaterally. That asymmetry should sit behind every decision that follows.
Three styles, three different questions
People frame REST, GraphQL, and gRPC as competitors. They are better understood as answers to different questions, and a strong design picks by looking at the consumer, not at fashion.
REST models the domain as resources addressed by URLs, uses HTTP methods to state intent and status codes to state the outcome, and in exchange rides the entire HTTP ecosystem for free: caching, proxies, CDNs, and universal client support in every language and tool. It is the right default for public, resource-oriented APIs and anything that benefits from HTTP caching. Its weaknesses are the mirror of its simplicity. An endpoint over-fetches when it returns more than a given screen needs, and a client under-fetches when one screen needs three resources and therefore makes three round trips.
GraphQL answers the wish to let the client ask for exactly the fields it wants. One endpoint, a typed schema, and a query language, which is genuinely powerful for mobile apps and rich frontends where different screens need wildly different slices of data. The cost is real and often understated. HTTP caching no longer applies cleanly because most requests are POSTs to a single URL, you have to defend the server against expensive deeply nested queries with depth and complexity limits, and the N+1 problem in resolvers has to be solved with batching or you will hammer your database.
gRPC is contract-first binary RPC over HTTP/2 using protobuf: compact, fast, strongly typed, with streaming and code generation across many languages. It shines for internal service-to-service communication where both ends are yours and you want speed and a strict contract. It is awkward for browsers, which need a proxy, it is not human-readable on the wire, and it is not something you usually expose as a public API.

REST vs GraphQL vs gRPC: pick by consumer, not by fashion.
The staff-level answer is not to crown a winner. Public and cache-friendly leans REST. Diverse client-driven data needs lean GraphQL. Internal high-performance calls lean gRPC. And plenty of real systems run gRPC between their own services with a REST or GraphQL edge facing the outside world, which is a design, not a contradiction.
The gateway carries the cross-cutting concerns
A request does not go straight to the service that owns the data. It first reaches an edge tier, typically a load balancer in front of an API gateway, and the gateway is where the shared parts of the contract live. It terminates TLS, authenticates the caller by validating an API key or a bearer token or a request signature, enforces rate limits and quotas per caller, and routes to the service that owns the resource.
Putting authentication, throttling, and routing at the gateway is not just tidy. It keeps those policies consistent across every backend, so you are not reimplementing rate limiting in five services and getting it subtly different in each. It also keeps the backend services focused on business logic instead of auth and throttling boilerplate. The gateway is the natural place to attach a request ID when the client did not supply one, so that every downstream log line and error response can be tied back to a single request.

The client hits a gateway that does auth and rate limiting before routing to the owning services.
Behind the gateway sit the services that implement the operations. A service validates the request body against its schema, checks authorization (this caller may read this resource but not write it), executes the operation, and serializes the response in the agreed format. There is a clean division worth stating plainly. Authentication, who you are, usually resolves at the gateway. Authorization, whether this specific caller may touch this specific resource, often has to happen in the service, because only the service knows ownership. And authorization must always be enforced server-side, never inferred from the client, because anything the client controls, an attacker controls too.
The gateway also leans on supporting infrastructure: a cache tier and CDN for safe reads driven by ETags and Cache-Control, a fast rate-limit store such as Redis holding per-caller counters, and an async subsystem for long-running work. The design goal across all of it is that the common path, an authenticated read or a simple write, stays cheap, while the harder guarantees are handled by dedicated mechanisms rather than bolted onto every handler.
Resources, methods, and status codes that mean what they say
The backbone of a usable REST API is consistent resource modeling, and the rule is to model nouns, not verbs. It is /orders and /orders/{id}/refunds, never /createOrder or /getOrderRefund. Use plural collection names, express containment through hierarchy such as /customers/{id}/invoices, and keep case and pluralization consistent across the whole surface, because inconsistency is exactly what makes an API feel untrustworthy even when it works.
Map methods to intent honestly. GET is safe and never mutates. POST creates or triggers actions. PUT replaces a resource wholesale. PATCH applies a partial update. DELETE removes. Idempotency is part of the method contract too: GET, PUT, and DELETE are defined as idempotent, and POST is not, which is precisely why creating with POST needs an idempotency key while PUT does not.
Status codes have to carry real meaning rather than decoration. Use 200 for success with a body, 201 with a Location header for a created resource, 202 for accepted but not yet done, and 204 for success with no body. On the error side, 400 for malformed input, 401 for missing or bad authentication, 403 for authenticated but not permitted, 404 for not found, 409 for a conflict such as a version mismatch, 422 for well-formed but semantically invalid input, 429 for rate limited, and 5xx reserved for genuine server faults. The classic mistake, and it is genuinely destructive, is returning 200 with an error buried inside the body. It breaks every client, proxy, and cache that trusts the status line, which is all of them.
Versioning: the discipline of not needing it
Versioning exists so you can make breaking changes without breaking existing clients, and the first rule is to avoid needing it. Make changes additively whenever you possibly can. Add a new optional field, a new endpoint, a new optional parameter, and build every client to ignore unknown fields, so that adding one never breaks anyone.
When a breaking change is genuinely unavoidable, there are three places to put the version. URI versioning, /v1/orders and /v2/orders, is the most common and most visible. It is trivial to route, trivial to test in a browser, and unambiguous, at the cost of arguably conflating the identity of a resource with its representation, and of proliferating paths. Header versioning, a custom header or Accept-Version, keeps URLs clean and lets the version travel as metadata, but it is invisible in a browser address bar, easy to forget, and harder to cache and debug. Media-type versioning through content negotiation, Accept: application/vnd.company.v2+json, is the most RESTful in spirit because it treats the version as one representation of the same resource, but it is the least approachable and least widely understood by consumers.

URI, header, and media-type versioning: the same choice placed in three different parts of the request.
Most large public APIs choose URI versioning for its bluntness and clarity. Some, Stripe being the widely cited example, use a date-based version pinned per account, so a client keeps its exact behavior until it explicitly upgrades. Whatever you pick, the disciplines are identical: never change the meaning of an existing field, add rather than mutate, and publish a real deprecation policy. When a version or field is retired, announce it, emit Deprecation and Sunset headers on the old surface, warn active integrators, and give a generous window, commonly six to twenty-four months, because forcing a faster cutover breaks integrations that nobody is actively maintaining anymore. The single most damaging move in API evolution is the silent semantic change, where a field keeps its name but quietly starts meaning something different. It passes every schema check and breaks clients in production with no warning at all.
Pagination: offset is a trap at scale
Any endpoint that returns a collection must be paginated, full stop. An unbounded list is a latency hazard, a memory hazard, and a denial-of-service vector all at once, which is why collection endpoints need a sane default page size, commonly twenty to fifty, and a hard maximum, commonly one hundred to a thousand, so that no client can pull an entire table by setting the limit parameter to a large number.
Offset pagination, using limit and offset or page numbers, is simple and lets a client jump to an arbitrary page. It is genuinely fine for small, mostly static datasets. At scale it has two real problems. Deep offsets are slow because the database still has to scan and discard every skipped row to reach page 500. And the results drift when items are inserted or deleted while a client is paging, so the client sees duplicates or silently skips records, which is the kind of bug that surfaces as a furious support ticket months later.

Offset pagination drifts and slows down at depth; cursor pagination stays indexed and stable.
Cursor pagination, also called keyset pagination, fixes both. It encodes a stable position, typically the sort key of the last item seen, into an opaque cursor token that the client passes back to fetch the next page. The query becomes give me the next twenty items after this key, which uses an index directly and stays correct even as rows are inserted and deleted underneath it. The trade is that you cannot jump to an arbitrary page and the cursor is opaque to the client, which is a fair price for correctness and speed on large or live datasets. As a rule: offset for small admin-style lists where users expect page numbers, cursor for large, live, high-throughput collections. Filtering and sorting deserve the same discipline. Expose a defined set of filterable and sortable fields, validate against that list, and reject anything not on it, because an open query language lets one client write an unindexed query that degrades the service for everyone.
Idempotency: making a retry safe
This is the deep-dive that separates a good answer from a great one in an interview, and it is a genuinely load-bearing production concern. Networks fail after the server has already done the work but before the client receives the response. The client, seeing a timeout, retries. Without protection, that retry performs the action a second time, and for a payment or an order, charging a customer twice is not a bug, it is an incident.
Idempotency is the property that performing an operation more than once has the same effect as performing it once. GET, PUT, and DELETE are naturally idempotent by their contract. The dangerous case is POST. The standard solution is the idempotency key: the client generates a unique key, usually a UUID, for the logical operation and sends it in a header. On first receipt the server executes the operation and stores the key together with the result. On any retry carrying the same key, it skips execution and returns the stored result.

An idempotency key lets the server replay the first result instead of running a retried write twice.
Two subtleties matter. First, store a fingerprint of the request, a hash of method, path, and body, alongside the key, and reject a reused key that arrives with a different payload, because that combination means a client bug, not a legitimate retry. Second, handle the race where two requests with the same key arrive concurrently, typically by inserting the key under a uniqueness constraint so the second request detects the in-flight first one and either waits or returns a conflict. Keys are retained for a bounded window, and twenty-four hours is a common choice because retries happen within minutes, not weeks. Done well, this turns retry-on-timeout from a dangerous gamble into a safe default, which is the whole point.
Errors are part of the contract
An error is as much a part of the API contract as a success, and clients need to program against it, not parse prose. The standard is RFC 7807, Problem Details for HTTP APIs, now updated by RFC 9457, which defines the application/problem+json media type and a small set of fields: type, a URI identifying the problem class; title, a short human summary; status, the HTTP code; detail, a human-readable explanation of this specific occurrence; and instance, a URI for this occurrence, plus room for extension members. The value is consistency. Every error across the API has the same shape, so a client writes one handler instead of a special case per endpoint.

RFC 9457 problem+json with a stable error code, a request ID, and status codes that tell the truth.
In practice you add one thing the standard does not mandate: a stable, machine-readable error code, a string like rate_limit_exceeded or card_declined, that clients switch on. The human-readable message will change over time and must never be parsed. Crucially, the error body should carry the request ID, and that same ID should appear in your structured logs and traces. This is the observability payoff. A caller reports a failure and quotes the request ID, and an operator finds the exact log line, trace, and downstream calls in seconds instead of guessing. Errors should also distinguish clearly between a client fault, 4xx, do not retry without changing the request, and a server fault, 5xx, safe to retry with backoff, and for retryable errors a Retry-After header tells the client when to come back. Vague 500s with no code and no request ID are the precise difference between an API teams love and one they dread.
The rest of the surface
A few more decisions round out a serious design. Rate limiting caps request rate per caller over a window; a token bucket allows short bursts while bounding the sustained rate, and when a caller exceeds the limit you return 429 with Retry-After and, ideally, RateLimit headers advertising the limit, remaining, and reset so a well-behaved client self-throttles before it gets rejected. Quotas are the longer-horizon cousin, capping usage per day or month and often tied to a billing tier.
Long-running work should not block a request. For a report, a video transcode, or an infrastructure provision, accept the request, return 202 with an operation resource that has its own URL and a status field, run the work on a background queue, and let the client either poll that operation until it reaches succeeded or failed or register a webhook to be called back. Polling is simple and needs no inbound endpoint on the client; webhooks avoid polling overhead but require the client to expose and secure a receiver and require the server to retry deliveries and sign them.
Caching is the lever for read-heavy APIs. Cache-Control tells clients and intermediaries how long a response may be reused, and ETags enable conditional requests, where the client sends If-None-Match with the ETag it holds and the server replies 304 Not Modified with no body when nothing changed. The same ETags power optimistic concurrency on writes through If-Match, so a client can update a resource only if it has not changed since the version it read, which the server enforces by returning 412 Precondition Failed on a mismatch.
The through-line
Notice what unites all of it. Model nouns, separate the external contract from the internal model, prefer additive change, paginate with a stable cursor, make writes idempotent, and return errors a machine can act on. Every one of these is the same instinct applied in a different place: reduce the surprise you hand to the person on the other end of the contract, because you will not get to take it back. The best API designers I have worked with are the ones who have been the integrator on the receiving end of a bad API, and design as if that person is watching.
The honest interview answer, and the honest design, separates the timeless principles, resources, safe and idempotent methods, backward-compatible evolution, standard errors, from the pragmatic choices where reasonable APIs differ, URI versus header versioning, offset versus cursor pagination, and never claims a single approach is universally correct.
I teach API design and the rest of system design this way, from first principles with real diagrams and the trade-offs that only show up once real clients depend on you, as an interactive course at systemdesign.academy. The foundation lessons are free and need no signup: https://systemdesign.academy
Top comments (0)