Skip to content
Commitwise
Menu
System Design

API design: REST, GraphQL and gRPC

Design APIs that are easy to use, safe to retry and possible to change without breaking apps already in users' hands, and choose between REST, GraphQL and gRPC for each kind of client, with examples for Swift, Kotlin, Java and TypeScript clients.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A web app can be redeployed in a minute. A mobile app can’t: the version you shipped last year is still on thousands of phones, calling your API with last year’s assumptions. That’s why API design is a system design topic. Every endpoint is a contract you’ll keep for years, retried by flaky networks, called by clients you don’t control, and changed by teams who weren’t there when it was designed.

New to some of these? Open what you need, or skip ahead.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What does idempotent mean?

An operation is idempotent if doing it twice has the same effect as doing it once. “Set the status to shipped” is idempotent; “add 10 EUR to the balance” isn’t: run it twice and the customer gets 20.

It matters whenever something can be retried, which in networked systems is always: a timeout doesn’t tell you whether the first attempt worked.

Part Not started

REST: resources and verbs

A library has a catalog of things (books, members, loans) and a small set of actions you can take on any of them: look it up, add one, change one, remove one. You don’t need a special desk for “renew loan 42”; you change the loan.

A REST APIREST APIA web API where things are resources at URLs, and HTTP methods like GET and POST say what to do with them.Open in glossary → works the same way. URLs name resources (/orders, /orders/42), and the HTTP method says what to do with them. Because every REST API uses the same verbs and status codes, developers can guess how yours works.

REST’s value in practice is uniformity: HTTP method semantics (safe, idempotent), status codes, caching and intermediaries (CDNs, proxies) all work because the API follows them. The design decisions that matter: resource boundaries, idempotency of writes, pagination, error shape, partial updates, and how state transitions that aren’t plain CRUD (“cancel an order”) are modeled. The implementation side is in Building a REST API.

GET    /orders?status=open&limit=20     → 200, a page of orders
GET    /orders/42                       → 200 the order, or 404
POST   /orders                          → 201 Created, Location: /orders/42
PATCH  /orders/42   { "note": "…" }     → 200, partially updated
DELETE /orders/42                       → 204 No Content
POST   /orders/42/cancellation          → 202 Accepted (an action as a resource)
MethodChanges data?Safe to repeat (idempotent)?Example
GETNo, neverYesRead an order
PUTYes, replacesYes: same result every timeReplace the shipping address
DELETEYesYes: deleting twice still leaves it deletedRemove a saved card
PATCHYes, partlyDepends (“set note” yes, “add 1” no)Change one field
POSTYes, createsNo: twice creates twoPlace an order
Spot the bugOrders that cancel themselves

Tap the line with the bug.

# Order screen links
GET /orders/42          → show the order
GET /orders/42/cancel   → cancel it (used by the "Cancel order" link in emails)
GET /orders/42/invoice  → download the invoice PDF

Creating things safely

Phones lose connection in elevators and tunnels. When the app sends POST /orders and the answer never arrives, it can’t know whether the order was created. Retrying might create a second order; not retrying might mean no order. The fix is an idempotency key: the app generates a unique id for this checkout attempt and sends it with every retry. The server remembers keys it has seen and returns the original result instead of creating a duplicate.

let key = UUID().uuidString                         // one key per checkout attempt, reused on retries
var request = URLRequest(url: URL(string: "https://api.shop.example/orders")!)
request.httpMethod = "POST"
request.setValue(key, forHTTPHeaderField: "Idempotency-Key")
request.httpBody = try JSONEncoder().encode(cart)
let (data, _) = try await session.data(for: request) // safe to retry with the same key
val key = UUID.randomUUID().toString()              // one key per checkout attempt, reused on retries
val response = client.post("https://api.shop.example/orders") {
    header("Idempotency-Key", key)
    contentType(ContentType.Application.Json)
    setBody(cart)
}                                                   // safe to retry with the same key
// Server side (Spring): remember the key with the result, in the same transaction as the order
@PostMapping("/orders")
ResponseEntity<OrderDto> create(@RequestHeader("Idempotency-Key") String key, @RequestBody CartDto cart) {
    return idempotency.findResult(key)
            .map(saved -> ResponseEntity.status(201).body(saved))   // a retry: same answer
            .orElseGet(() -> ResponseEntity.status(201).body(orders.placeAndRemember(key, cart)));
}
const key = crypto.randomUUID();                    // one key per checkout attempt, reused on retries
const response = await fetch("https://api.shop.example/orders", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Idempotency-Key": key },
  body: JSON.stringify(cart),
});                                                 // safe to retry with the same key

Predict the output

The app sends POST /orders without an idempotency key. The server creates order 1001, but the response is lost in a tunnel. The app retries automatically. How many orders exist?

Exerciseeasy

Design a notes API

A notes app needs to: list a user’s notes (newest first, possibly thousands), read one note, create a note, edit a note’s title or text, pin and unpin a note, and delete a note. Write the endpoints (method, path, main status codes), and say which writes need an idempotency key.

›

Pinning is a change to one field of a note. Thousands of notes means pagination: which kind?

SolutionTry it yourself first
GET    /notes?limit=50&cursor=…    → 200 { items: [...], nextCursor: "…" }   newest first
GET    /notes/{id}                 → 200, or 404
POST   /notes                      → 201 Created + Location   (Idempotency-Key)
PATCH  /notes/{id}                 → 200   { "title": "…" } or { "text": "…" }
PATCH  /notes/{id}  { "pinned": true }   → 200   (setting a value: idempotent)
DELETE /notes/{id}                 → 204   (again: 204 or 404, both mean "gone")
  • Cursor pagination (nextCursor), so new notes don’t shift pages and deep pages stay fast.
  • Only POST needs an idempotency key: PATCH with “set pinned to true” and DELETE are already safe to repeat.
  • Pinning isn’t its own endpoint: it’s a field. A separate /pin action would be fine too, as long as it uses PUT/POST, never GET.
  • Edit conflicts (two devices editing the same note) can be caught with ETag + If-Match: the server answers 412 Precondition Failed if the note changed since the client loaded it.

REST details that matter at scale

  • Errors: one shape for all errors (RFC 9457 application/problem+json: type, title, status, detail, plus fields), with machine-readable codes the client can branch on, not English sentences.
  • Idempotency keys: store key → (request hash, response) with a TTL, scoped per client; reject the same key with a different body (422); hold a lock while the first request is in flight so concurrent retries don’t both execute.
  • Concurrency: ETag + If-Match for optimistic updates; 409 for state conflicts (“can’t cancel a shipped order”).
  • Rate limits and retries: 429 with Retry-After; document which errors are retryable.
  • Partial responses: fields= or separate lightweight list representations, so mobile list screens don’t download full detail objects.
  • Method semantics are load-bearing: GET safe (scanners, prefetch, caches, retries rely on it); PUT/DELETE idempotent; POST needs an Idempotency-Key (stored with the response, request-hash checked, locked while in flight).
  • Model actions as resources or POSTs (/cancellation), never as GETs.
  • Cursor pagination, problem+json errors with stable codes, ETag/If-Match for concurrency, 409/412/429 with Retry-After.

Check this part

  1. Why must a GET request never change data?

  2. Which request can create a duplicate if it's retried?

  3. A client reuses an idempotency key with a different request body. What should the server do?

REST models resources (nouns) acted on by HTTP methods (verbs) with meaningful status codes. GET must never change anything; PUT and DELETE are idempotent; POST isn't, so creating things safely under retries needs an idempotency key. Lists use cursor pagination.

Part Not started

GraphQL and gRPC

A REST API is like a set menu: each endpoint returns a fixed dish. Sometimes that’s too much food (the order list screen gets every field of every order), sometimes too little (the screen also needs each order’s restaurant, so it makes more requests).

GraphQL is ordering à la carte: the app writes down exactly which fields it wants, across related data, and gets exactly that in one response. gRPC is a different idea: a precise, typed contract between two programs, sent in a compact binary format, popular between backend services.

GraphQL gives clients a typed schema and lets them select fields across relations in one round trip; it shines with many client types and fast-moving UIs, and costs HTTP caching (one POST endpoint), resolver N+1 problems, and query cost control. gRPC gives schema-first contracts (protobuf) with generated clients, efficient binary encoding, deadlines and bidirectional streaming over HTTP/2; it shines for internal service-to-service calls and streaming, and costs browser support (gRPC-Web proxy) and human readability. REST stays the default for public and mobile APIs because of its simplicity and its fit with HTTP infrastructure.

# REST: two round trips, and more fields than the screen needs
GET /orders?limit=20
GET /restaurants?ids=7,12,31
# GraphQL: one request, exactly these fields
query OrderList {
  orders(first: 20) {
    edges {
      node {
        id
        total
        status
        restaurant { name logoUrl }
      }
    }
    pageInfo { endCursor hasNextPage }
  }
}
// gRPC: a typed contract; clients and servers are generated from it
service Orders {
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
  rpc WatchOrder(WatchOrderRequest) returns (stream OrderUpdate);   // server streaming
}

message ListOrdersRequest { int32 page_size = 1; string page_token = 2; }
message Order { string id = 1; int64 total_cents = 2; OrderStatus status = 3; }
RESTGraphQLgRPC
Best forPublic APIs, mobile, simple CRUDMany clients with different screensService-to-service, streaming
FetchingFixed responses per endpointClient picks fields across relationsFixed typed messages
HTTP caching / CDNNaturalHard (usually POST, one URL)Not applicable
ContractOpenAPI (optional)Schema (required)Protobuf (required), generated code
Browser supportNativeNativeNeeds gRPC-Web proxy
Debuggingcurl, readable JSONReadable JSON, toolingBinary; needs tools
Exerciseeasy

Pick an API style

A food delivery company has: (1) its own iPhone and Android apps with many screens that change every sprint, (2) partner restaurants integrating their order systems, (3) a pricing service called by the order service 2,000 times a second, (4) live courier location on the order tracking screen. Pick REST, GraphQL or gRPC (or a mix) for each and say why.

›

Think about who controls the client, how often its needs change, whether a browser or a third party is involved, and whether data flows continuously.

SolutionTry it yourself first
  1. Own mobile apps: GraphQL (or REST with screen-shaped endpoints, “backend for frontend”). Many screens, frequent changes, and fetching exactly what a screen needs in one round trip matters on mobile networks.
  2. Partner restaurants: REST with OpenAPI docs and webhooks. Partners use every language and tool; REST is what everyone can integrate, and it’s easy to version and support.
  3. Pricing service: gRPC. Internal, high volume, typed contracts with generated clients, deadlines propagated between services.
  4. Live courier location: a streaming connection: WebSockets or server-sent events to the app (or gRPC server streaming where clients support it), fed by the backend’s internal streams.

Mixing styles is normal; each boundary gets the style that fits its clients.

GraphQL’s hidden N+1

Predict the output

A GraphQL query asks for 50 orders, each with its restaurant. The Order.restaurant resolver loads the restaurant by id from the database. How many database queries run?

  • REST: default for public and mobile; HTTP caching, simplicity. GraphQL: many clients/screens, field selection, one round trip; costs caching, N+1 (DataLoader batching), query cost/depth limits, persisted queries. gRPC: internal RPC, protobuf codegen, deadlines, streaming; browsers need gRPC-Web.
  • Choose per boundary: BFF/GraphQL for own apps, REST + webhooks for partners, gRPC internally, WebSockets/SSE for live updates.

Check this part

  1. What can a GraphQL client do that a REST client usually can't?

  2. Where is gRPC most commonly used?

  3. A GraphQL query for 50 orders with restaurants runs 51 database queries. The fix?

GraphQL lets each client ask for exactly the fields it needs in one request, at the cost of harder caching and the risk of expensive queries. gRPC uses typed contracts and binary messages over HTTP/2, great between services and for streaming. Pick per client, not per company.

Part Not started

Changing an API without breaking apps

A wall socket from 20 years ago still accepts today’s plugs. New appliances got features, but the shape of the plug stayed the same. If the shape changed overnight, every old appliance in every home would stop working.

Your API is the socket, and the apps on people’s phones are old appliances that won’t be updated soon: some users update weeks later, some never. So an API should only change in ways old apps don’t notice.

Backward compatibility is the core constraint of mobile and public APIs: the server must serve every client version still in use, often for years. Safe changes are additive; unsafe ones (renames, removals, type or meaning changes, new required inputs, new enum values for strict clients) need a new version or a negotiated capability. Clients must be tolerant readers: ignore unknown fields, map unknown enum values to an “unknown” case, and never crash on data they don’t understand.

ChangeSafe for old apps?
Add a new endpointYes
Add a new optional field to a responseYes, if clients ignore unknown fields
Add a new optional parameter to a requestYes
Rename or remove a fieldNo
Change a field’s type ("42" → 42) or meaningNo
Make an optional request field requiredNo
Add a new value to an enum the app decodes strictlyNo (see below)

When a breaking change is unavoidable, add a new version next to the old one (/v2/orders, or a version header), move the apps over, and keep the old one running until almost nobody uses it. Measure that with logs that record the app version of each request; don’t guess.

Production puzzles

Spot the bugA new order status that crashes old apps

Tap the line with the bug.

enum OrderStatus: String, Decodable {
    case pending, paid, shipped
}

struct Order: Decodable {
    let id: String
    let status: OrderStatus
}

let orders = try JSONDecoder().decode([Order].self, from: data)
@Serializable
enum class OrderStatus { pending, paid, shipped }

@Serializable
data class Order(val id: String, val status: OrderStatus)

val orders = Json.decodeFromString<List<Order>>(body)
enum OrderStatus { pending, paid, shipped }

record Order(String id, OrderStatus status) {}

List<Order> orders = objectMapper.readValue(body, new TypeReference<List<Order>>() {});
type OrderStatus = "pending" | "paid" | "shipped";

interface Order { id: string; status: OrderStatus }

const orders = (await response.json()) as Order[];
const label = { pending: "Waiting", paid: "Paid", shipped: "On its way" }[orders[0].status];

Exercisemedium

Remove an old endpoint safely

GET /v1/feed returns a feed in an old format; /v2/feed replaced it 14 months ago. The backend team wants to delete v1 because it pins an old database schema. How do you get there without breaking anyone?

›

You need data about who still calls it, a way to move them, and a way to make the remaining calls fail gracefully.

SolutionTry it yourself first
  1. Measure: log every v1 call with app version, platform and client id; build a dashboard of daily active users still on v1, by version.
  2. Announce and mark: add Deprecation and Sunset headers (RFC 8594/9745) to v1 responses with the date; tell partners directly.
  3. Move users: the minimum-supported-version mechanism (a config endpoint the app checks at launch) prompts users of affected versions to update, softly first, then forced for versions below a threshold once their share is small.
  4. Shrink the cost of keeping it: if the old schema is the problem, reimplement v1 as an adapter on top of v2 (translate the response), so the database can move on before v1 dies.
  5. Brownouts: before the sunset, return errors for short scheduled windows to flush out forgotten callers (internal scripts, a partner’s cron job) while it’s still reversible.
  6. Remove, answering 410 Gone with a message the old app can show, rather than a confusing 404 or a crash.
  • Additive changes only: new endpoints, optional fields/params. Renames, removals, type/meaning changes, new required inputs and new enum values (for strict decoders) are breaking.
  • Tolerant readers: ignore unknown fields; unknown enum → unknown(raw) (Swift custom init(from:), kotlinx coerceInputValues + default, Jackson @JsonEnumDefaultValue, runtime validation in TS).
  • Versioning when necessary (path or header); deprecate with telemetry by app version, Deprecation/Sunset headers, minimum-version prompts, adapters, brownouts, then 410 Gone.

Check this part

  1. Which API change is safe for apps already installed on phones?

  2. Why can't a mobile API change at the same time as the app?

  3. The server adds status "refunded". An app decodes an enum without that case inside an array. Result?kotlinx.serialization decodes a list of orders and meets an unknown enum value. Result by default?Jackson meets an unknown enum value in a list. Result by default?A response is cast with as Order[] and contains an unknown status. Result?

Apps in users' hands can't be updated on your schedule, so APIs must only change in backward-compatible ways: add fields, never rename or remove them, and make clients tolerate values they don't know. Version when you must, and remove old behavior only after measuring who still uses it.

Key takeaways

  • REST uses resources and HTTP methods; GET must never change anything.
  • POST isn’t safe to retry: send an idempotency key so a retry doesn’t create a second order.
  • GraphQL lets each screen ask for exactly what it needs; gRPC suits fast, typed calls between services.
  • Apps in users’ hands can’t be updated quickly: only add to an API, never rename or remove.
  • HTTP semantics (safety, idempotency, caching) are the contract intermediaries rely on.
  • Idempotency keys, cursor pagination, uniform errors and optimistic concurrency are table stakes for mobile APIs.
  • Choose REST, GraphQL, gRPC or streaming per boundary; GraphQL needs batching and cost limits.
  • Backward compatibility is the core constraint: tolerant readers, additive changes, measured deprecation.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.