You've now seen every part of Part 1 one at a time: APIs, data models, caches, more servers, replicas, live updates, queues, rate limits and growth. Real design work doesn't arrive one topic at a time. It arrives as a blank page and a sentence like "design a ratings feature" or "design a ride-hailing app".
This lesson gives you a fixed order of steps to follow every time. Not because design is a checklist, but because a fixed order stops you skipping the parts that matter and lets you spend your thinking on the problem instead of on "what do I do next?"
The six steps
| Step | Question | You're done when you have... |
|---|---|---|
| 1. Requirements | What must it do, and how well? | 3-5 functional needs, 3-5 non-functional ones with numbers |
| 2. Numbers | How big is it? | Requests per second, data per year, read/write ratio |
| 3. API | What do clients ask for? | The main endpoints with inputs and outputs |
| 4. Data | What do we store, and how is it read? | Tables or documents, keys, the main queries |
| 5. High-level design | Where does each job happen? | A diagram where every box traces to a requirement |
| 6. Deep dive | What's the hardest part, and how does it fail? | One or two parts worked out in detail |
Each step feeds the next. The numbers come from the requirements. The API comes from the functional requirements. The data model comes from the API. The design comes from the data and the numbers. If you jump straight to step 5, you're drawing boxes with nothing underneath them.
To make it concrete, we'll run all six on a small Tiffin feature: ratings and reviews. After each delivery, a customer can rate the restaurant from 1 to 5 stars and leave a short review. The restaurant page shows its average rating and recent reviews.
Step 1: Requirements
Start by asking, not assuming. Who uses this, and what do they need?
Functional:
- A customer can rate a delivered order once, 1 to 5 stars, with an optional text review.
- The restaurant page shows the average rating, the number of ratings and the 20 most recent reviews.
- Restaurants can see their reviews in the kitchen tablet app.
Non-functional:
- The restaurant page loads in under 200 ms at p99, since it's viewed constantly.
- A new rating shows up in the average within a few minutes. It doesn't need to be instant.
- One rating per order, even if the app retries the request.
Also say what's out of scope: replies from restaurants, photos and fake-review detection. Naming them shows you thought about them, and keeps the design small.
Step 2: Numbers
Use what you know. Tiffin does 250,000 orders a day. Say 30% of customers rate their order.
Ratings written: 250,000 x 30% = 75,000 a day ≈ 1 a second on average, ~4 at dinner peak
Restaurant page views: say 10 per order placed = 2.5 million a day ≈ 30/s avg, ~140/s at peak
Storage: 75,000 x ~500 bytes = ~37 MB a day ≈ 14 GB a year
Two things fall out of this. Reads outnumber writes about 30 to 1. And the data is small. So this feature is about fast reads of the average, not about storage or write load.
Always write units, and always say where a number came from. "10 page views per order, my guess" is fine. A bare "100" with no unit and no source is not.
Step 3: API
Two or three endpoints cover it:
POST /orders/{orderId}/rating
body: { "stars": 4, "text": "Great biryani, a bit late" }
201 Created, or 409 Conflict if this order is already rated
GET /restaurants/{id}/ratings/summary
200 { "average": 4.3, "count": 18234 }
GET /restaurants/{id}/reviews?cursor=...&limit=20
200 { "reviews": [...], "next_cursor": "..." }
Notice the decisions hiding here: the rating hangs off the order, so the server can check that the order belongs to this customer and was delivered ("only customers who ordered can rate"), the 409 handles retries, and reviews use cursor pagination from Module 2.
Step 4: Data
CREATE TABLE ratings (
order_id bigint PRIMARY KEY, -- one rating per order, enforced by the database
restaurant_id bigint NOT NULL,
customer_id bigint NOT NULL,
stars smallint NOT NULL CHECK (stars BETWEEN 1 AND 5),
review_text text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ratings_restaurant_recent ON ratings (restaurant_id, created_at DESC);
CREATE TABLE restaurant_rating_summary (
restaurant_id bigint PRIMARY KEY,
rating_sum bigint NOT NULL,
rating_count integer NOT NULL
);
The main queries are: insert a rating, read 20 recent reviews for a restaurant (served by the index), and read the summary. The summary table exists because computing AVG(stars) over 18,000 rows on every page view, over 100 times a second, is wasteful when the answer changes once every few minutes.
Step 5: High-level design
%% caption: Ratings feature. Every box traces to a requirement or a number from steps 1 and 2.
flowchart TD
CA([Customer app]) -- "rate an order" --> S[Server]
RP([Restaurant page]) -- "read the summary" --> S
S -- "rating and its job, one transaction" --> PG[("Postgres<br/>ratings, summary, jobs")]:::store
PG -- "jobs table" --> W["Worker<br/>updates summary"]
W --> PG
S --> C[("Cache<br/>summary, 5 min TTL")]:::hotWalk through it, tying each part to a reason:
- Postgres stores ratings, because the primary key enforces one rating per order.
- The jobs table and worker update the summary in the background, because "within a few minutes" allows it (Module 7). The job is inserted in the same transaction as the rating, so a rating can never be saved without its job, or the other way round.
- The cache serves the summary, because reads outnumber writes 30 to 1 and the page must load in 200 ms (Module 3).
No new database, no search engine, no service. The numbers didn't ask for them.
Step 6: Deep dive
Pick the part most likely to go wrong. Here, it's keeping the summary correct.
The worker adds the new rating to rating_sum and increments rating_count. But the queue delivers at least once, so the same job can run twice and count a rating twice (Module 7). The fix: in one transaction, mark the rating as counted and update the summary only if it wasn't already.
BEGIN;
UPDATE ratings SET counted = true WHERE order_id = $1 AND counted = false;
-- only if that updated 1 row (otherwise this job already ran):
INSERT INTO restaurant_rating_summary (restaurant_id, rating_sum, rating_count)
VALUES ($3, $2, 1)
ON CONFLICT (restaurant_id) DO UPDATE
SET rating_sum = restaurant_rating_summary.rating_sum + $2,
rating_count = restaurant_rating_summary.rating_count + 1;
COMMIT;
The insert-or-update matters for a restaurant's very first rating: a plain UPDATE would match no summary row and silently drop it.
That needs a counted boolean NOT NULL DEFAULT false column on ratings, which is a nice example of the loop from Module 1: the deep dive sent us back to step 4.
Then ask the failure questions. What if the worker is down for an hour? The summary is an hour stale, which is acceptable, and it catches up from the queue. What if the summary drifts because of a bug? A nightly job recomputes it from ratings, the source of truth.
Run the six steps on a new feature
Tiffin wants a "favourite restaurants" feature: a customer taps a heart on a restaurant page, and a Favourites tab lists them. Write one line for each of the six steps.
Show one answer
- Requirements: add/remove a favourite, list favourites newest first, tab loads in under 300 ms. Out of scope: notifications when a favourite has an offer.
- Numbers: assume about 750,000 daily users (roughly 3 per order placed), and say 5% tap a heart: under 40,000 writes a day, well under 1/s. Favourites tab views maybe 200,000 a day, about 2/s. Each row is ~24 bytes of data, closer to 100 with row overhead and both indexes, so even 100 million favourites is ~10 GB.
- API:
PUT /me/favourites/{restaurantId},DELETE /me/favourites/{restaurantId},GET /me/favourites?cursor=. PUT and DELETE are naturally safe to retry. - Data:
favourites(customer_id, restaurant_id, created_at)with primary key(customer_id, restaurant_id)and an index on(customer_id, created_at DESC). - Design: the same server and Postgres. No cache needed at these numbers.
- Deep dive: a restaurant that closes permanently. Keep the row and show it as closed, or filter it out on read. Don't cascade-delete customers' data silently.
Spend your time where it matters
In a 45-minute design discussion, a rough budget:
| Step | Time |
|---|---|
| Requirements | 5 min |
| Numbers | 5 min |
| API and data | 10 min |
| High-level design | 10 min |
| Deep dives | 15 min |
The deep dive gets the most time because it's where real design happens. Everyone can draw a server and a database. Not everyone can explain what happens when a job runs twice.
Be flexible about the order inside a session. If the hard part is obvious from the start (live location for a ride-hailing app, say), mention it early and come back to it. The steps are a spine, not a cage.
What to remember
- Use the same six steps every time: requirements, numbers, API, data, high-level design, deep dive.
- Each step feeds the next. Skipping to boxes means drawing without reasons.
- Name what's out of scope. It keeps the design small and shows you considered it.
- Numbers decide what the design needs. Always write units and sources.
- Every box in the design should trace back to a requirement or a number.
- Spend the most time on the deep dive: the hardest part, and how it fails.