Designing the API
Part of the free System Design course on LogicWiz, in the chapter: APIs and data modelling. The interactive whiteboard exercises and simulations run in the app.
Week 4: The order list that showed the same order twice
"A customer scrolled her order history and saw one order three times and another not at all. Nothing was broken. That was the problem."
From a SwiftKart support ticket
Pick the style by who is calling and how
An API is the contract between a caller and your service. Before details, choose the style. Three cover almost every interview.
REST over HTTP. You expose resources (orders, riders, products) at URLs and use HTTP verbs on them: GET /orders/42 reads, POST /orders creates. It is simple, its responses can be cached, and every tool and developer understands it. It is the default for public APIs and for the SwiftKart app talking to its backend.
gRPC. Services call each other with compact, typed messages over a fast binary protocol. The client code is generated for you. It is lighter and quicker than JSON over HTTP. So it suits internal calls between services, where you control both ends. It works less well in browsers and is harder to test by hand.
WebSocket. A connection that stays open so the server can push data to the client whenever it wants. The live rider dot moving on the map is the standard example. Polling means asking again and again. A million phones polling "where is he now?" every second wastes work. A pushed update costs one small message, sent only when something changes.
Rule of thumb:
- For request and answer, use REST.
- For fast internal calls, consider gRPC.
- When the server must speak first, use WebSocket.
Resources are nouns; verbs are the method
Good REST design names things, not actions. Instead of /getOrderStatus or /cancelOrder, write:
POST /orderscreates an orderGET /orders/42reads itGET /orders?status=activelists some of themPATCH /orders/42changes part of it (cancelling is a status change)
The URL is the what, and the method is the do what. Keep nesting shallow: /orders/42/items is fine, five levels is not. Use plural nouns. Return sensible status codes: 201 for created, 404 for missing, 429 for too many requests (last week). Put a version in the path or header from day one, so you can change the shape later without breaking old app builds.
Pagination: how not to show the same order twice
Meera's support ticket is a pagination bug. A customer has hundreds of orders, so GET /orders cannot return them all. It returns a page at a time. There are two ways to say which page:
Offset pagination. GET /orders?limit=20&offset=40 means "skip 40 newest, give me the next 20". It is easy to build and lets a user jump to page 7.
Now the failure, with numbers. The list is newest first. The customer loads page 1: orders 1 to 20 (the 20 newest). While she reads, she places a new order. The list grows at the front: the old order 20 is now at position 21. She taps next, the app asks for offset=20, and the server skips 20 rows, but the first 20 are now different ones. She receives the old order 20 again. If an order is deleted meanwhile, the opposite happens: one order is skipped without any error. Offset also gets slower the deeper you go, because the database still reads past all the skipped rows. At offset 100,000 it reads 100,000 rows to throw them away.
Cursor pagination. The server returns a cursor, a bookmark pointing at the last item seen, such as its time and id. The next request says GET /orders?limit=20&after=<cursor>, meaning "the 20 orders older than this one". New orders at the front do not matter, because the bookmark is tied to a specific item. The database jumps straight to that item using an index (a sorted lookup structure, covered next lesson) instead of counting rows.
The trade-off: you cannot jump to page 7. You can only go next and back. (Going back means keeping the previous cursors, or issuing a reverse one.) For feeds, histories and anything that grows while you read, that is the right choice.
💡 Tip: Offset is fine for small, stable lists such as an admin screen with 200 rows. Choose cursor for anything large, live or infinite-scroll, and say that is why.
Idempotency: the double charge
The second classic failure is money. A customer pays ₹499 via UPI. The app sends POST /payments and the server charges. But the response is lost on a weak 4G network. The app sees no answer, so it does what a good client does: it retries. Without protection, the customer is charged twice.
An idempotent operation gives the same result no matter how many times you perform it. GET and PUT are naturally so. POST is not, so we add an idempotency key. The client generates a unique id for this attempt at paying, such as a UUID (a long random id that will never repeat). It sends the key in a header with every retry of that same attempt. The server stores the key with the result. On the first request it charges and saves the outcome. On a retry with the same key, it does not charge again; it returns the saved outcome.
sequenceDiagram
participant A as App
participant S as Payment API
A->>S: POST /payments (key K1)
S-->>A: response lost
A->>S: retry POST /payments (key K1)
S-->>A: same result, no second charge
The key must be stored durably (it survives a crash) and checked atomically (in one step that cannot be split). Then two retries arriving together cannot both get through. If the same key arrives with a different body, reject it with 422 or 409: it is a bug, not a retry. Keep keys at least as long as clients keep retrying; a day is a common choice.
Every request can arrive twice
Many candidates design only the happy path, where nothing fails. Asked about duplicates, they say, "The client will not retry." It will. Networks drop answers, users double-tap, load balancers retry on timeouts. Anything that moves money, stock or messages must be safe to repeat. Assume every request can arrive twice.
⚠️ Warning: A uniqueness check done in your application code ("look up the key, then insert") has a race. Two requests can both look, both find nothing, and both insert. Let the database enforce it with a unique constraint on the key.
📌 Summary: Use REST by default and WebSocket when the server must push. Page growing lists with a cursor, because offset repeats or skips items. Send an idempotency key so a retried payment charges once.
What Meera learns next
Meera has the calls. Next: what sits behind them. How to find the entities, relationships and keys in a feature. And how to choose between SQL and NoSQL by asking how the data will be read.
Check your understanding
- Order history is newest first, paged with limit=20&offset. A customer loads page 1, places a new order, then taps "next". What does she see?
- A customer pays ₹499. The app sends POST /payments, the charge succeeds, but the response is lost on flaky 4G and the app retries. What stops a double charge?
- The rider's dot must move live on a million customers' maps. Which API style fits best?
- Which REST call best lets a customer cancel order 42?