Module 10 / Designing end to end

Module 10 · lesson 1 of 5 · Read 9 min

A repeatable approach

Requirements, numbers, API, data, design, deep dive.

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.

Requirements
→
Numbers
→
API
Data model
→
High-level design
→
Deep dive
The six steps. Each one uses what the previous ones produced.

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")]:::hot
Ratings feature. Every box traces to a requirement or a number from steps 1 and 2.

Walk 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
  1. 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.
  2. 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.
  3. API: PUT /me/favourites/{restaurantId}, DELETE /me/favourites/{restaurantId}, GET /me/favourites?cursor=. PUT and DELETE are naturally safe to retry.
  4. Data: favourites(customer_id, restaurant_id, created_at) with primary key (customer_id, restaurant_id) and an index on (customer_id, created_at DESC).
  5. Design: the same server and Postgres. No cache needed at these numbers.
  6. 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

  1. Use the same six steps every time: requirements, numbers, API, data, high-level design, deep dive.
  2. Each step feeds the next. Skipping to boxes means drawing without reasons.
  3. Name what's out of scope. It keeps the design small and shows you considered it.
  4. Numbers decide what the design needs. Always write units and sources.
  5. Every box in the design should trace back to a requirement or a number.
  6. Spend the most time on the deep dive: the hardest part, and how it fails.