How backend services talk to each other — the design rules that keep those conversations simple, safe, and fast, and the three architectural styles you choose between when you draw the boxes.
An API is a contract between two services — it fixes the request shape, the response shape, and the rules of the exchange. Design it well (resource URLs, right verbs, versioning, idempotency) and then pick a style by the job: REST for public and CRUD, gRPC for fast internal microservice chatter, GraphQL when the client must ask for exactly the fields it needs.
GET /users/123 ✓
GET /getUser?id=123 ✗/users /orders /products. Verb lives in HTTP method./v1 /v2 so old clients don't break.| Verb | Does | Idempotent? |
|---|---|---|
| GET | read | yes |
| POST | create new | no (by default) |
| PUT | replace whole resource | yes |
| PATCH | partial update | depends |
| DELETE | remove | yes |
/users/123?page=2&limit=20
| Class | Means | Examples |
|---|---|---|
| 2xx | success | 200 OK · 201 Created · 204 No Content |
| 4xx | client error | 400 · 401 · 403 · 404 · 429 |
| 5xx | server error | 500 · 503 |
increaseBalance:100 → NOT idempotent (state
grows each call). PATCH name:"John" → idempotent./v2, keep /v1 live, migrate clients, then retire v1.?page=2&limit=20.| REST | gRPC | GraphQL | |
|---|---|---|---|
| Format | JSON | binary (protobuf) | JSON |
| Transport | HTTP | HTTP/2 | HTTP |
| Speed | medium | very high | medium |
| Best for | public API, CRUD | internal microservices | client picks fields |
| By | — |
A customer taps Pay, the screen freezes, and they tap it again. Two payments leave their account. Somewhere a backend service received the same intent twice and treated each as a fresh order. Nothing was hacked — the API just had no way to know the second request was the same as the first. That one gap explains why API design is a discipline and not an afterthought: the rules below exist to stop exactly these quiet, expensive mistakes.
An API is the agreed way for two pieces of software to talk. In a system design, your product is a set of backend services — boxes on a diagram. None of them is useful alone; they have to exchange information. The interface that governs that exchange is the API (its full name, Application Programming Interface, is worth saying once and then forgetting).
The API pins down the parts of the conversation that both sides must agree on: how a request is sent, what the request body looks like, which data format carries it, and how the response comes back. Get the contract right and the two services never have to meet — they just honour the shape.
A restaurant waiter. You (the client) hand over an order; the waiter carries it to the kitchen (the server); the kitchen cooks and the waiter brings back a plate (the response). You never walk into the kitchen. The menu — what you can order and in what form — is the API contract.
Six habits separate an API a stranger can use from one that generates support tickets.
An endpoint should read the way a person expects. The verb belongs to HTTP, and the URL names the thing.
GET /users/123 is right; GET /getUser?id=123 is wrong twice over — it repeats
the verb (GET already means "get") and buries the identifier in a query parameter when it
identifies a specific resource and belongs in the path.
URLs name nouns, not actions. The action is carried by the HTTP method, so the path is
free to describe what you are acting on. Good: /users, /orders,
/products. Avoid /getUser or /createOrder — the verb is redundant
with GET and POST.
Where the API lives — the request path through a typical system-design template.
Scalable means the API is built to handle growth: pagination for large result sets, caching, rate limiting, and load balancing. Secure means HTTPS on the wire plus two distinct checks — authentication ("who are you?") and authorization ("what are you allowed to touch?") — and input validation so a malformed body is rejected at the door, before it reaches business logic. Versioned means you can evolve without breaking anyone; more on that in §2.5.
Authentication vs authorization trips people up. AuthN is identity (login, JWT). AuthZ is
permission (this admin may delete; this user may only read). A request can be authenticated
yet unauthorized — that's the difference between 401 and 403.
Every request the client sends is assembled from four slots, and knowing which slot a value belongs in is half of good API design.
Identifies a specific resource. In GET /users/123, the 123 is the user
id.
Filters, sorts, or paginates. After the ?: /users?page=2&limit=20.
Side information: Authorization: Bearer …,
Content-Type: application/json,
idempotency keys.
The payload the client sends the server, e.g. { "name": "John", "age": 30 }.
A status code is how the server tells the client, in a number, how the request went. The leading digit is the whole story; the rest is nuance.
| Code | Class | Meaning |
|---|---|---|
| 200 | 2xx success | OK — request succeeded |
| 201 | 2xx success | Created — a new resource exists |
| 204 | 2xx success | No Content — succeeded, nothing to return |
| 400 | 4xx client | Bad Request — invalid input, wrong contract |
| 401 | 4xx client | Unauthorized — authentication missing |
| 403 | 4xx client | Forbidden — authenticated but no permission |
| 404 | 4xx client | Not Found — endpoint or resource doesn't exist |
| 429 | 4xx client | Too Many Requests — rate limit hit [+] |
| 500 | 5xx server | Internal Server Error — the server broke |
| 503 | 5xx server | Service Unavailable — temporarily down |
Read the first digit like a mood: 2 = "you did fine," 4 = "you sent me something wrong," 5 = "I broke, not your fault." That single digit tells you which side to debug before you read another word.
Versioning lets an API evolve while existing clients keep working. Suppose clients
already call /v1/users. A new business requirement forces the request or response shape to
change. If you edit /v1 in place, every current caller that sends the old shape starts
failing — a bad experience nobody asked for.
Instead you publish /v2/users alongside /v1, onboard new consumers onto v2,
migrate the old ones on their own schedule, and only then retire v1.
A new edition of a textbook. You don't overwrite everyone's copy overnight — you print the 2nd edition, let classes switch over a term, and keep the 1st edition usable until the last class moves on.
A request is idempotent if repeating it leaves the system in the same final state as doing it
once. GET /users/123 is idempotent — asking ten times returns the same user
and
changes nothing. The danger case is a payment: without protection, clicking Pay twice charges
the customer twice.
A light switch that's already ON. Flip it "on" again and again — the room's state never changes past the first flip. That's idempotent. A cash register that adds $100 every time you press the button is not.
The client generates a unique id — usually a UUID — for the operation and sends it in a header.
The first time the server sees Idempotency-Key: abc-123, it processes the request and
stores the result against that key. If the same key arrives again, the server recognises it and
returns the stored response instead of processing a second time. The "memory" can live in a fast cache
(Redis) or the database, depending on how durable it must be.
POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: abc-123
{ "amount": 500, "currency": "INR" }
You don't memorise a table — you reason from the final state. GET, PUT, DELETE are naturally idempotent (reading, replacing to a fixed value, and deleting all land on the same end state however many times you repeat them). POST is not — each call creates a new resource. PATCH is the "it depends" one.
PATCH /accounts/123 with { "name": "John" } is idempotent — the name is
"John" no matter how often you send it. But { "increaseBalance": 100 } is not:
each call adds 100, so the final state keeps changing (100 → 200 → 300). Same verb, opposite answer
—
the body decides.
If the server processes the payment and only then tries to save the idempotency record, a failed write leaves no memory of the operation. The client retries with the same key, the server sees nothing stored, and charges again. Fix: write the idempotency key before processing, so the record survives even if the operation itself fails.
Pagination returns a large result set in slices instead of one giant payload. The
client asks for a window: GET /users?page=2&limit=20 means "the 20 records on page 2."
Think of an infinite feed — LinkedIn, Instagram. If you've been away for days there may be 500 new items waiting. Sending all 500 at once wastes bandwidth on content you'll never scroll to. Instead the server sends a page, and when you scroll to roughly 80% of it, it pulls the next page. You get a smooth feed; the system avoids paying to move data you'd never read.
A book delivered a chapter at a time. The publisher doesn't ship 500 pages if most readers stop at chapter two — they send the next chapter only once you're near the end of the current one.
REST is a stateless, resource-oriented way for two applications to talk over HTTP. The interview one-liner: "REST is a stateless, resource-oriented API style built on top of HTTP." Data is usually exchanged as JSON.
It replaced SOAP, an older style that leaned on verbose XML — heavier on the wire and harder for a human to read. REST is lighter, simpler, and speaks the web's native vocabulary that every browser already understands.
The server remembers nothing between requests. Each request is independent and must carry everything it needs — token, user id, and so on. That's what makes REST easy to scale horizontally: any server instance can handle any request, because none of them holds session state the others lack.
Because a REST endpoint's response shape is fixed by its contract, you hit two mirror-image problems.
A third, separate cost: JSON payloads are larger than a binary format. These three complaints are exactly what the next two styles were invented to fix — GraphQL for the fetch problems, gRPC for the payload size.
Public APIs, web apps, mobile backends, straightforward CRUD, and anything integrating with a third party — roughly 85% of services still speak REST, so it's the safe common tongue. Avoid it for very high-frequency internal chatter, real-time streaming, or when the client needs highly customised data in one shot.
gRPC lets one service call a method on another service as if it were a local function. It's a high-performance framework from Google that runs on HTTP/2 and serialises data with Protocol Buffers (a compact binary format) instead of JSON. The design goal: smaller payloads and lower latency for large microservice systems where REST-plus-JSON became the bottleneck.
Two colleagues who've agreed on a shorthand and share a phone line they never hang up. They don't spell everything out (binary, not prose), and they don't redial for every sentence (one persistent HTTP/2 connection). Faster than mailing postcards — but only because both learned the same shorthand.
.proto file — this is the contract: service name, methods,
request and response message shapes.stub.add(req)).service Calculator {
rpc Add (AddRequest) returns (AddResponse);
}
message AddRequest {
int32 num1 = 1;
int32 num2 = 2;
}
message AddResponse {
int32 result = 1;
}
gRPC replaces the REST controller layer entirely — the API is defined in the .proto, and
both client and server must speak gRPC. You can't have one service on gRPC talk to a
REST-only third party. That's the main reason public and third-party integrations still use REST:
it's
the shared language 85% of the world already speaks.
Serialising and deserialising costs some CPU on both ends. You accept that overhead because the binary payload is far smaller than JSON, so it moves across the network faster and on less bandwidth — a net win on throughput and latency for chatty internal services (Uber, Netflix, Google-scale).
GraphQL is a query language for APIs where the client specifies exactly which fields it needs. Instead of many fixed endpoints, the application usually exposes one endpoint, and the query itself describes the desired shape. The interview one-liner: "GraphQL lets clients request only the fields they need, eliminating over- and under-fetching." It was created at Facebook to solve precisely the two REST pains.
À la carte ordering. REST is a fixed set menu — you take the whole plate whether or not you wanted the side. GraphQL lets you name each item: "just the name and email, nothing else," and that's exactly what arrives.
query {
user(id: "123") {
name
email
}
}
The server defines a schema (types and fields); the client sends a query; resolvers fetch just those fields from the right databases or services; the response contains only what was asked for — no over-fetch, and no second round trip to under-fetch.
Fetch 100 users, then a separate query for each user's orders, and you've fired 101 queries instead of a sensible few. The fix is batching / a data loader that groups those child lookups. GraphQL moves flexibility to the client but pushes complexity — caching and authorization especially — onto the server.
There's no universally correct answer — the skill is justifying your pick out loud. State the advantages of what you chose; if you chose gRPC or GraphQL, first name the REST disadvantage it removes.
| REST | gRPC | GraphQL | |
|---|---|---|---|
| Data format | JSON | binary (protobuf) | JSON |
| Transport | HTTP | HTTP/2 | HTTP |
| Performance | medium | very high | medium |
| Browser-friendly | yes | limited | yes |
| Streaming | weak | strong | subscriptions |
| Best use | public API, CRUD | internal microservices | flexible client data |
| Origin | — |
Use REST for public APIs and normal CRUD. Use gRPC when internal services talk frequently and latency matters. Use GraphQL when a client needs flexible, customised data from multiple sources in a single request. And note the practical point from the session: services don't only talk via these styles — a message queue like Kafka handles asynchronous, decoupled communication, and truly independent services may not talk at all.
A path parameter identifies a specific resource — /users/123 is "the user
whose id is 123." A query parameter modifies a collection by filtering, sorting, or
paginating — /users?page=2&limit=20. Using the path for identity keeps URLs
predictable and RESTful; stuffing an id into a query string (?id=123) muddies that
distinction.
Make the operation idempotent with a client-generated idempotency key (a UUID) sent in a header. The server stores the result keyed by that value; if the same key arrives again it returns the stored response rather than charging again. Store the key before processing so a mid-flight failure can't erase the record and let a retry slip through.
It depends on the body. PATCH { "name": "John" } is idempotent — the end state is
the
same however many times you send it. PATCH { "increaseBalance": 100 } is not — each
call adds 100, so the final state changes every time. Reason from the resulting state, not the
verb.
Editing /v1 in place changes the contract under clients already using it, so their
existing requests start failing. Publishing /v2 alongside /v1 lets new
clients adopt the change and old clients migrate on their own timeline; you retire v1 only once
nobody depends on it.
The server keeps no memory between requests, so every request carries all the context it needs (token, ids). Because no server instance holds session state the others lack, any instance can serve any request — which is what makes REST horizontally scalable behind a load balancer.
Over-fetching: a fixed endpoint returns more fields than you need. Under-fetching: no single endpoint has everything, so you make several calls and combine them. GraphQL fixes both by letting the client request exactly the fields it wants in one query. (gRPC targets a different REST cost — large JSON payloads — by using a compact binary format.)
Three reasons together: Protocol Buffers serialise data to compact binary (smaller than JSON), HTTP/2 multiplexes many requests over one persistent, header-compressed connection, and the connection stays open rather than being re-established per call. You pay some serialise/deserialise CPU, but win on payload size, bandwidth, and latency.
No. gRPC requires both ends to share the .proto contract and speak gRPC. If one side
only offers REST, you either use REST for that boundary or put a translating layer in between.
This
is why third-party and public integrations default to REST — it's the shared language ~85% of
services already speak.
Resolving a list of 100 users and then firing one query per user for their orders produces 101 queries. A data loader batches those child lookups into far fewer queries. It's the classic reminder that GraphQL's client flexibility shifts caching and query-efficiency burden onto the server.
Reading proves you recognise the ideas. These prove you can use them. Attempt each before opening the walkthrough.
You're designing a payments service. Write the REST endpoint lines (method + path, plus any header) for the four operations below, following every design rule from §2.2 and §2.6.
Expected result: four lines using nouns in the path, correct verbs, the id in the path (not query), pagination in query params, and an idempotency-key header on the create.
Step 1 — nouns, not verbs. The resource is payments. Never write
/getPayment or /createPayment; the HTTP verb already carries the
action.
Step 2 — id in the path, filters in the query. A single payment is identified by
its id, so it goes in the path: /payments/123. Paging is a filter on the
collection, so
it goes in the query string.
Step 3 — make create idempotent. POST is not idempotent by default, so a
double-click could create two payments. Attach a client-generated Idempotency-Key
in
the header so a retry returns the stored result instead of charging twice.
GET /payments/123
GET /payments?page=3&limit=20
POST /payments
Idempotency-Key: 5f2c-uuid
DELETE /payments/123
Why: each line encodes a rule — resource nouns, the right verb, path-vs-query discipline, and the one header that turns an unsafe POST into a safe one.
A teammate added idempotency to the payment handler below. Under a network retry, customers are still occasionally charged twice. Predict what goes wrong, name the root cause, and fix the order of operations.
def handle_payment(key, amount):
result = charge_card(amount) # 1. process first
save_idempotency(key, result) # 2. then remember the key
return result
Expected result: identify that a failure between lines 1 and 2 causes a double charge on retry, then reorder so the key is recorded before the charge.
Root cause: the key is stored after the charge. If the process succeeds
at line 1 but the save_idempotency write on line 2 fails (or the box crashes in
between), there's no record that this key was ever handled.
Predicted failure: the client retries with the same key, the handler looks for
the
key, finds nothing stored, and calls charge_card a second time — the exact double
charge idempotency was meant to prevent.
Step 1 — record intent first. Reserve the key before doing the work, so the memory survives a mid-flight failure.
Step 2 — check the key on entry. If it already exists, return the stored result and skip the charge entirely.
def handle_payment(key, amount):
existing = lookup_idempotency(key) # already seen?
if existing:
return existing.result # replay, don't re-charge
reserve_idempotency(key) # 1. remember BEFORE processing
result = charge_card(amount) # 2. then process
save_idempotency_result(key, result)
return result
Why: idempotency only holds if the server's memory of the operation is durable across failures — storing the key before the side effect is what makes the retry safe.