Module 2 / One server, one database

Module 2 · lesson 9 of 9 · Lab 18 min

Lab: The first version of the food app

API, schema and numbers for version one, on one server.

Time to put the whole module together. In this lab you'll design version one of Tiffin, from the brief to a design you could start building on Monday.

Each step asks you to decide something first. Try it before opening the answer. Your answer doesn't have to match; what matters is that you can say why you chose what you chose.

The brief

Three founders and two engineers want to launch Tiffin in one city in 10 weeks. Here's what they tell you:

  • At launch they've signed 300 restaurants, aiming for 800 by the end of the first year.
  • They expect 5,000 orders a day at launch, and hope for 50,000 a day within a year.
  • Customers order from one restaurant at a time, pay by card or UPI, and get food delivered by Tiffin's own riders.
  • Each restaurant gets a cheap Android tablet in the kitchen.
  • "Customers must never be charged for an order the restaurant never sees."
  • "We can't afford a big infrastructure bill, or a team to run it."

Step 1: Requirements

Turn the brief into a short list of functional and non-functional requirements. Also decide what is not in version one.

Write the requirements

List 5 or 6 functional requirements, 3 or 4 non-functional ones with numbers, and 3 things you'd leave out.

Show one answer

Functional:

  1. Customers can see open restaurants near them and browse a menu.
  2. Customers can place an order from one restaurant and pay for it.
  3. Customers can see their order's status and their past orders.
  4. Kitchen tablets see new orders, accept or reject them, mark food ready and mark dishes sold out.
  5. Riders see their assigned delivery, and mark it picked up and delivered.
  6. Operations staff can assign a rider to an order (by hand, at first).

Non-functional:

  1. A new order appears on the kitchen tablet within 10 seconds.
  2. A paid order is never lost. Payment and order are recorded together.
  3. Menus and restaurant lists load in under 1 second on a typical 4G phone.
  4. Handle the first year's growth (50,000 orders a day) without a redesign.

Not in version one: live rider tracking on a map, automatic rider assignment, search by dish name, scheduled orders, multiple cities.

The "not in version one" list is as important as the rest. Each item on it is something you'd otherwise half-build. Live tracking, for instance, needs a whole new way for the server to talk to phones (Module 6). Automatic rider assignment is a hard problem of its own. A human with a dashboard can assign 5,000 orders a day across a few shifts, and will learn what the algorithm should do.

Step 2: Numbers

Design for the end of year one, not launch day: 50,000 orders a day and 800 restaurants.

Size it

Using the assumptions from the estimation lesson (150,000 daily sessions, ~20 requests each, 60% in a 3-hour dinner rush, tablets polling every 5 seconds, 1,000 riders at peak), estimate peak requests per second and yearly order data. Then decide: one server, or several?

Show one answer
  • Customers: 3 million requests a day, 1.8 million at dinner, ≈ 170 per second.
  • Tablets: 800 / 5 s = 160 per second.
  • Riders: 1,000 / 10 s = 100 per second.
  • Peak: ~500 requests per second.
  • Orders: ~1 KB each, 50,000 a day ≈ 18 GB a year.

One 8-core server handles roughly 1,600 simple requests a second, so we're at about 30% at the year-one peak, and around 5% at launch. One app server and one PostgreSQL database are enough.

It's worth saying the conclusion out loud, because people find it hard to believe: the whole of Tiffin's first year fits on two machines. One for the app, one for the database (a managed PostgreSQL service, so nobody on a five-person team has to babysit backups). That also answers the founders' cost worry. Two decent cloud machines cost a few hundred dollars a month.

We'll put photos in object storage from day one. It costs nothing extra in effort, and the estimate showed photos are the biggest source of bandwidth.

Step 3: The API

Design the order flow

Write the endpoints for the full life of an order: browse, place, pay, kitchen accepts, rider delivers. For POST /orders, write the request body. Mark which calls would be dangerous to retry.

Show one answer
GET   /v1/restaurants?lat=..&lng=..&cursor=..
GET   /v1/restaurants/{id}/menu
POST  /v1/orders                              (not safe to retry)
POST  /v1/orders/{id}/payment                 (not safe to retry)
GET   /v1/orders/{id}
GET   /v1/me/orders?cursor=..

GET   /v1/restaurants/{id}/orders?status=placed   (tablet polls)
POST  /v1/orders/{id}/accept
POST  /v1/orders/{id}/reject
POST  /v1/orders/{id}/ready
PATCH /v1/menu-items/{id}                     ({"is_available": false})

GET   /v1/me/deliveries
POST  /v1/deliveries/{id}/picked-up
POST  /v1/deliveries/{id}/delivered
{
  "restaurant_id": 42,
  "items": [{ "menu_item_id": 7, "quantity": 2 }],
  "delivery_address_id": 5
}

No prices, totals or customer id in the request: the server decides all of those.

A few decisions to check against yours:

  • Version in the URL from day one (/v1). Old app versions will be around for years.
  • Cursor pagination on the restaurant list and order history.
  • Status changes are named actions. POST /orders/{id}/accept lets the server check the order is still placed and belongs to this tablet's restaurant.
  • One error format with a stable code, so the app can show "this dish just sold out" instead of a generic failure.

The two calls marked "not safe to retry" deserve a note. If a customer taps "Pay" on a weak signal and the app retries, we must not charge twice. The proper fix is an idempotency key (Module 12). For version one, a simple guard covers most of it: the server refuses a payment for an order that already has a successful one.

Here's one order's happy path through those endpoints, from placing it to the kitchen accepting it:

%% caption: One order, placed, paid and accepted. The kitchen learns about it by polling.
sequenceDiagram
  autonumber
  participant C as Customer app
  participant S as App server
  participant D as PostgreSQL
  participant P as Payment provider
  participant K as Kitchen tablet
  C->>S: POST /v1/orders
  S->>D: insert order and items
  S-->>C: the order, with server prices
  C->>S: POST .../payment
  S->>P: charge
  P-->>S: success
  S-->>C: paid
  K->>S: poll placed orders, every 5 s
  S-->>K: the new order
  K->>S: POST .../accept
  S->>D: update only if still placed
  S-->>K: accepted
One order, placed, paid and accepted. The kitchen learns about it by polling.

Step 4: The data model

Draw the tables

List the tables with their key columns, primary keys and foreign keys. Decide which values on an order are copied rather than pointed at.

Show one answer
  • restaurants (id, name, address, lat, lng, is_open)
  • menu_categories (id, restaurant_id, name, position)
  • menu_items (id, restaurant_id, category_id, name, price_paise, is_available, photo_url)
  • customers (id, phone, name), with phone unique
  • customer_addresses (id, customer_id, text, lat, lng)
  • orders (id, customer_id, restaurant_id, status, delivery_address, delivery_lat, delivery_lng, subtotal_paise, delivery_fee_paise, total_paise, created_at, updated_at)
  • order_items (order_id, menu_item_id, name, price_paise, quantity), primary key (order_id, menu_item_id)
  • payments (id, order_id, provider_ref, amount_paise, status, created_at)
  • riders (id, phone, name, is_on_shift)
  • deliveries (order_id, rider_id, picked_up_at, delivered_at)

Copied onto the order: dish names and prices, and the delivery address. A receipt must show what was bought, at what price, delivered where, even after the menu or the customer's saved address changes.

Now the hardest non-functional requirement: "never charge for an order the restaurant never sees." How does the model support it?

The order is saved before the customer is charged, with status pending_payment. Only when the payment provider confirms do we flip it to placed, in the same transaction that records the payment:

BEGIN;
UPDATE payments SET status = 'succeeded', provider_ref = :ref
  WHERE id = :payment_id;
UPDATE orders SET status = 'placed', updated_at = now()
  WHERE id = :order_id AND status = 'pending_payment';
COMMIT;

Kitchen tablets only see placed orders. So there's never a moment where money was taken but no order exists for the kitchen to see. (What if the server crashes between the provider charging the card and this transaction? Payment providers send a confirmation callback, called a webhook, which retries until we acknowledge it. That callback runs the same transaction. Handling all of this properly across services is Module 16.)

So our status list grows one value: pending_payment, before placed.

Step 5: Indexes

Index the hot queries

Name the three or four queries that run most often, and the index each one needs.

Show one answer
Query How often Index
Tablet: placed orders for my restaurant 160 a second, all day orders (restaurant_id, status, created_at)
Menu for a restaurant Most customer screens menu_items (restaurant_id)
My past orders, newest first Often orders (customer_id, created_at, id)
Rider: my current delivery Every rider screen refresh deliveries (rider_id)
Log in by phone On login Unique index on customers (phone)

The tablet query is the one to get right. It runs every 5 seconds from every restaurant, all day, and without its index it would scan the whole orders table, which grows by 50,000 rows a day.

The restaurant list is the awkward one. "Open restaurants near this location" means filtering by distance. With only 800 restaurants, the simplest answer is to load all open restaurants (a few hundred rows) and compute distance in the query. It's fast at this size. Proper geographic indexes are a topic for Module 19.

Step 6: Two people, one order

Here's a race that will happen within the first week. At 8:14pm the customer taps "Cancel". At the same second, the kitchen taps "Accept". Both requests reach the server at once.

Who wins?

How do you make sure the order ends up either accepted or cancelled, never both, and that the loser gets a clear answer?

Show one answer

Let the database decide, with a conditional update. Each action only succeeds if the order is still in the state it expects:

-- Kitchen accepts
UPDATE orders SET status = 'accepted', updated_at = now()
WHERE id = 9481 AND status = 'placed';

-- Customer cancels
UPDATE orders SET status = 'cancelled', updated_at = now()
WHERE id = 9481 AND status = 'placed';

The database applies these one at a time: the first UPDATE locks the row, and the second waits for it to commit, then re-checks its WHERE clause against the new row. Whichever runs first changes one row. The second finds no row with status = 'placed' and changes zero rows. The server checks the row count: 1 means success, 0 means "someone got there first", and it returns 409 Conflict with a code like order_already_accepted. The app shows a clear message instead of an error.

This pattern, "update only if it's still in the state I expect", is one of the most useful in all of system design. It needs no locks in your code (the database's own brief row lock does the work) and works no matter how many requests arrive at once.

The final design

%% caption: Tiffin version one. Two machines and two managed services, sized for the first year.
flowchart TD
  CA(["Customer app"]) --> S
  KT(["Kitchen tablet"]) -- "polls every 5 s" --> S
  RA(["Rider app"]) --> S
  S["App server<br/>API, rules, payments"]:::hot --> PG[("PostgreSQL<br/>orders, menus, everything")]:::store
  S --> PP["Payment provider<br/>card and UPI"]
  S --> OS[("Object storage<br/>dish photos")]:::store
Tiffin version one. Two machines and two managed services, sized for the first year.

And what we deliberately left out, with the trigger that would bring each one in:

Left out Add it when
A cache Menu queries start to dominate database load (Module 3)
More app servers and a load balancer Peak CPU passes ~60%, or one crash is too costly (Module 4)
Read replicas The database is the bottleneck (Module 5)
Push updates to tablets and phones Polling costs too much, or we add live tracking (Module 6)
A job queue Slow work, like emails and payouts, starts blocking requests (Module 7)

That table is a design too. It says you've thought about growth, and chosen not to pay for it until it arrives.

Build it on AWS

Optional on a first read. This section maps the design onto real AWS services and prices, and it uses cloud services the course only introduces later. If you're new to system design, skip it for now: the design above is what an interview asks about. Come back once you've finished Part 1.

The founders said two things that decide the AWS design: "we can't afford a big bill" and "we can't afford a team to run it". The first pushes towards small. The second pushes towards managed services, even where they cost a little more, because five people shouldn't be patching database servers. From here on, Tiffin runs in Mumbai (ap-south-1), and prices are Mumbai on-demand list prices as of late 2026. They differ from other Regions, so check the AWS pricing calculator before quoting them.

The architecture

%% caption: Tiffin version one on AWS. Two small containers, one managed database, photos at the edge.
flowchart TD
  CA(["Customer app"]) --> ALB["Application Load Balancer<br/>TLS, health checks"]
  KT(["Kitchen tablet"]) --> ALB
  RA(["Rider app"]) --> ALB
  CA -- "dish photos" --> CF["CloudFront"] --> S3[("S3<br/>dish photos")]:::store
  ALB --> API["ECS on Fargate<br/>2 tasks, Graviton, 2 zones"]:::hot
  API --> PG[("RDS for PostgreSQL<br/>Multi-AZ")]:::store
  API --> PP["Payment provider"]
Tiffin version one on AWS. Two small containers, one managed database, photos at the edge.
Generic box On AWS The setting that matters
App server One ECS service on Fargate: two small tasks in two Availability Zones Arm (Graviton) images; rolling deploys start the new task before stopping the old one
(new) Load balancer Application Load Balancer A free TLS certificate from ACM, and a /healthz check that doesn't touch the database
PostgreSQL RDS for PostgreSQL, db.m7g.large (2 vCPU, 8 GiB), Multi-AZ Automated backups and point-in-time restore; storage that grows on its own
Object storage S3 behind CloudFront Versioned photo URLs, cached for a year

Argument 1: the lab said one server. Why two tasks and a load balancer?

On Fargate, "the server" is a task, and a task gets a new private IP address every time it restarts or is deployed. Something has to give the apps a stable address and drop dead tasks from rotation, and that's the load balancer's job. Once it's there, a second task is cheap, and it buys two things the founders care about: deploys stop being outages, and one dead task (or a whole Availability Zone) doesn't take Tiffin down. By default, an ECS rolling deploy starts each new task before it stops an old one.

Size the tasks from the lab's own numbers: about 200 simple requests a second per core, and a year-one peak of 500. At launch, two tasks of 1 vCPU and 2 GB are plenty. By the end of the year, two tasks of 2 vCPU and 4 GB keep the peak around 60% of capacity. Changing the size is a task definition change and a rolling deploy, with no downtime.

Two settings are worth getting right on day one:

  • Build Arm images. In Mumbai, Fargate on Graviton costs $0.02383 per vCPU-hour against $0.04256 for x86, about 44% less. At year-end size that's roughly $85 a month instead of $150, for a one-line change to the build.
  • Check the Fargate quota now. The default is 6 on-demand vCPUs per Region, and new accounts can start lower. Two 2-vCPU tasks fit, but a rolling deploy wants to run four at once, and 8 vCPUs is over the quota: only one new task can start at a time, so every deploy crawls and logs launch failures. Request an increase before it bites. It costs nothing.

Argument 2: Single-AZ or Multi-AZ, on day one?

The brief says "a paid order is never lost". Compare the two RDS options against it:

db.m7g.large, Single-AZ:  $0.240/hour = ~$175 a month
db.m7g.large, Multi-AZ:   $0.479/hour = ~$350 a month

Single-AZ already has backups, with transaction logs uploaded every five minutes. But if the instance's storage fails, the restore can lose up to five minutes of commits. At the year-one dinner peak of about 3 orders a second, that's up to 900 paid orders whose records are gone, to be rebuilt by hand from the payment provider's reports.

Multi-AZ keeps a synchronous standby in another zone, so a committed order is already on two machines. If the primary fails, RDS moves the database's DNS name to the standby, typically within 60 to 120 seconds. The app must reconnect, so make sure the database driver retries connections instead of crashing. The standby can't serve reads. It exists only to take over.

For a company that takes money, $175 a month is the cheapest insurance on the bill. Turn Multi-AZ on before launch. Start on db.m7g.large, and move to db.m7g.xlarge when dinner-time CPU passes about 60%. Changing the instance class on a Multi-AZ database triggers a failover, so do it at 4am.

Argument 3: what will actually dominate the bill?

Not the servers. The estimation lesson found photo traffic of about 1.2 MB per session. CloudFront in India charges $0.109 per GB for the first 10 TB, and $0.012 per 10,000 HTTPS requests, after a free 1 TB and 10 million requests a month:

At launch:   15,000 sessions x 1.2 MB = 18 GB a day = 540 GB a month   -> inside the free tier
             about 40 images a session = 18M requests a month          -> ~$10
Year one:    150,000 sessions x 1.2 MB = 180 GB a day = 5.4 TB a month
             data: (5,400 - 1,000 free) GB x $0.109                   = ~$480
             requests: 180M - 10M free = 170M x $0.0000012            = ~$200
                                                                        ~$680 a month

By the end of year one, dish photos cost more than the database. That makes image size a cost decision. Serving small WebP thumbnails on list screens instead of full photos can halve the line, which is worth a day of an engineer's time.

What it costs

Line At launch End of year one
ECS on Fargate, two Graviton tasks ~$42 (1 vCPU each) ~$85 (2 vCPU each)
Application Load Balancer ~$25 ~$30
RDS db.m7g.large, Multi-AZ, 50 GB gp3 ~$365 ~$365
CloudFront and S3 for photos ~$10 ~$680
Total ~$440 a month ~$1,160 a month

At 1.5 million orders a month, the year-one bill is under a tenth of a cent per order. One thing isn't in the table: tasks in private subnets need a NAT gateway to reach the payment provider, and it has its own hourly and per-GB charge in each zone. For a system this small it can cost more than the two tasks, so price it before you add one.

The same design on GCP and Azure

Role AWS Google Cloud Azure
Containers ECS on Fargate Cloud Run Container Apps
Load balancer ALB Cloud Load Balancing Application Gateway or Front Door
Managed PostgreSQL with a standby RDS Multi-AZ Cloud SQL with high availability Azure Database for PostgreSQL, zone-redundant
Photos S3 and CloudFront Cloud Storage and Cloud CDN Blob Storage and Front Door

One real difference at this size: Cloud Run and Container Apps give each service its own HTTPS address, so a tiny system can start without a separate load balancer.

The bill that doubled

Nine months after launch, orders are up 30% but the AWS bill has doubled. Fargate and RDS look normal. Where do you look, and what's the likely fix?

Show one answer

Look at CloudFront's usage report, broken down by path. Photo bytes grow with sessions and with image size, and a new app release that shows bigger images on list screens can double the data line on its own. The likely story: the redesign loads full-size dish photos (500 KB or more) where thumbnails used to be.

Fix it with renditions: generate small and medium versions of each photo when it's uploaded, serve the right one per screen, and keep the full size for the dish detail page. Then add an alarm on CloudFront's bytes transferred, so the next regression shows up in a day, not on the monthly bill.

What to remember

  1. Start from requirements with numbers, and write down what is not in version one.
  2. Size for the end of the first year. For Tiffin that's ~500 requests a second at peak and ~18 GB of orders a year: one app server and one database.
  3. Design the API from the screens: versioned, paginated, with named actions and the server deciding all prices.
  4. Copy prices and addresses onto orders. Save the order before charging, and mark it placed in the same transaction that records the payment.
  5. Index the hottest queries first, especially anything a client polls.
  6. Use conditional updates (WHERE status = 'placed') to settle races without explicit locks.
  7. List what you left out and what would trigger adding it.
  8. On AWS, the "one server" becomes two small Fargate tasks behind a load balancer: deploys and failures stop being outages for about $40 a month. Turn on Multi-AZ before you take money.
  9. At launch scale, the line to watch is CDN data transfer, not servers. Check quotas (like Fargate's vCPU limit) before a deploy finds them for you.