Module 1 / Thinking in systems

Module 1 · lesson 5 of 5 · Lab 15 min

Lab: Court booking for a sports club

Eight courts, 1,200 members, and every evening slot gone minutes after release.

Time to use this whole module on a real brief. No servers or scaling yet: just finding the requirements, sizing the problem, and making the first decisions with reasons.

Here's the brief, as it arrived in a voice note:

"Hi, I'm Kavya, I run Smash Point, a badminton club. Eight courts, about 1,200 members. Every Sunday at 8pm we release next week's slots in our WhatsApp group, and it's chaos. Evening slots are gone in two minutes, people claim the same court, and some members grab six slots while others get none. Can you build us a booking system?"

Step 1: Find the requirements

Write the requirements

Before reading on, list the functional requirements for members and for Kavya's staff, then the non-functional ones. The voice note hints at at least three non-functional requirements without naming them.

Show one answer

Members: see a week's grid of courts and hours, book a slot, cancel, see their own bookings.

Staff: block courts for coaching or tournaments, see the day's bookings, mark no-shows.

Hidden non-functional requirements in the voice note:

  • "People claim the same court": a slot can never be booked twice.
  • "Some members grab six slots": a per-member limit, enforced strictly.
  • "Gone in two minutes": the system must stay up and fast during the Sunday release.

A follow-up call with Kavya pins down the details:

  • Courts open 6am to 11pm, in 1-hour slots: 8 courts × 17 hours × 7 days = 952 slots a week.
  • Each member may hold at most 3 bookings per week.
  • Cancellation is allowed until 6 hours before. The slot goes back into the pool.
  • Members' phone numbers must not be visible to other members.
  • Out of scope for version one: payments (fees are collected monthly), coaching sessions, guest players.
Requirement Category Target
A slot is never booked twice Consistency Always
No member ever holds more than 3 bookings in a week Consistency Always, even with simultaneous taps
Booking works during the Sunday 8pm release Availability, performance Responses under 1 s for the first 10 minutes
Members' contact details stay private Security Always
Bookings are never lost Durability Daily backups, tested

Step 2: Size it

Most of the week is quiet: maybe 150 bookings and cancellations a day. The interesting moment is Sunday at 8:00:00pm.

How big is the rush?

Say 700 members are online at 8pm. In the first 10 seconds, each loads the grid once and makes about 3 booking attempts (some fail because the slot is gone, so they try another). What's the request rate during those 10 seconds?

Show one answer

700 × (1 grid load + 3 attempts) = 2,800 requests in 10 seconds, or about 280 requests a second. After a minute it falls back to a trickle.

280 requests a second is modest. One ordinary server and one Postgres database can handle it, if each request is cheap. So the numbers give us two decisions:

  • One server, one database. No cache, no queue, no load balancer.
  • Keep the 8pm path lean. A booking request should be one short database transaction. Anything slow, like sending a WhatsApp confirmation, happens after the booking commits, never inside it.

Step 3: High-level design

%% caption: Smash Point's high-level design. The database is highlighted because it enforces both hard rules.
flowchart TD
  MP(["Members' phones"]) --> W
  SD(["Staff dashboard"]) --> W
  W["Web app<br/>grid, bookings, admin"] --> D[("Database<br/>courts, slots, bookings")]:::hot
  W -- "confirmations" --> N["WhatsApp/SMS provider"]
Smash Point's high-level design. The database is highlighted because it enforces both hard rules.

Two decisions are worth stating:

  • The release time is enforced by the server's clock, not the app. If the app simply hides the Book button until 8pm, someone will find a way to send the request at 7:59. The server rejects bookings for next week until 20:00:00 by its own clock.
  • Confirmations go out after the commit. If the messaging provider is slow at 8pm, members still get their slots. They just get the message a few seconds later.

Step 4: The two hard rules

Rule 1: no double bookings

Two members tap the same 7pm court-3 slot in the same millisecond. Checking "is it free?" in application code and then inserting isn't safe: both requests can see "free" and both insert. Let the database enforce it, where it can't be raced:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (court_id, starts_at)
  WHERE status = 'booked';

The second insert fails with a unique-violation error, and the app shows "Just taken. Here are the nearest free slots." The WHERE status = 'booked' part lets a cancelled booking stay in the table for history without blocking the slot.

Rule 2: at most 3 per member per week

This one is sneakier. A unique index can't count. And a member with two phones (or one fast thumb) can send two bookings for different courts at the same time.

Close the race

Member 512 already has 2 bookings this week. Two requests arrive together, for court 1 at 7pm and court 5 at 8pm. Each request runs "count my bookings this week, and insert if the count is below 3". What goes wrong, and how do you fix it?

Show one answer

Both requests count 2, both decide "under the limit", and both insert. The member now has 4.

The fix is to give the two requests one thing they must both change, so the database orders them. Keep a small counter row per member per week, and claim a unit from it with a conditional update in the same transaction as the booking (shown below). The second request waits for the first to commit, then finds the count at 3 and updates zero rows.

BEGIN;
-- claim one of the member's 3 weekly bookings
UPDATE weekly_quota
   SET used = used + 1
 WHERE member_id = :member_id AND week = :week AND used < 3;
-- 0 rows updated? limit reached: ROLLBACK and tell the member

INSERT INTO bookings (court_id, starts_at, member_id, status)
VALUES (:court_id, :starts_at, :member_id, 'booked');
-- unique violation? slot taken: ROLLBACK (this also returns the quota unit)
COMMIT;

Because both steps are in one transaction, a failure in either undoes both. A member never loses a quota unit to a slot they didn't get. (The quota rows for next week are created when the week is released. Cancelling a booking decrements used in the same transaction that cancels it.)

Here's one booking at 8pm as a request flow, with both rules checked inside one short transaction:

%% caption: One booking at 8pm. The confirmation goes out only after the transaction commits.
sequenceDiagram
  autonumber
  participant P as Member's phone
  participant W as Web app
  participant D as Database
  participant N as Message provider
  P->>W: book court 3 at 7pm
  W->>D: BEGIN, claim quota
  D-->>W: 1 row updated
  W->>D: insert the booking
  alt slot still free
    D-->>W: inserted, COMMIT
    W-->>P: booked
    W-)N: send the confirmation
  else slot already taken
    D-->>W: unique violation, ROLLBACK
    W-->>P: taken, here are nearby slots
  end
One booking at 8pm. The confirmation goes out only after the transaction commits.

Step 5: Is first-come-first-served fair?

The design so far makes 8pm a race, and the race favours fast phones and good connections. Kavya might be fine with that. Or she might not.

An alternative for the prime 6pm-10pm slots is a ballot. Members submit up to 3 ranked choices between Sunday 8pm and Monday 8pm. A job then allocates the slots randomly among the people who asked, honouring rankings where it can. Everything else stays first-come-first-served.

First come, first served Ballot for prime slots
Fairness Rewards speed Equal chance for everyone
Sunday 8pm load A sharp spike Spread over 24 hours
Member experience Instant answer Wait a day for results
Build effort What we have An allocation job and a results screen

This isn't a technical decision. It's a product decision with technical consequences, so it goes back to Kavya with the trade-offs laid out. Version one ships first-come-first-served, and the ballot is written down as an option for version two.

Step 6: State the trade-offs

  • One server, no standby. If it dies at 8pm on a Sunday, the release is delayed until it's back. To limit the damage, the release is moved to a scheduled job that can be re-run at a new announced time, and the server is restarted by its host automatically.
  • Daily backups only. Up to a day of bookings could be lost in a disaster. Members would have to rebook, but no money is involved, so Kavya accepts this.
  • No waitlist. When someone cancels, the slot simply reappears. Whoever looks first gets it.

The final design

%% caption: Version one. Sized for a 280 requests/second spike once a week, with both hard rules enforced by the database.
flowchart TD
  MP(["Members' phones"]) --> W
  SD(["Staff dashboard"]) --> W
  W["Web app<br/>one small server"] --> D[("Database<br/>unique slot index, quota rows")]:::hot
  W -. "after commit" .-> N["WhatsApp/SMS provider"]
Version one. Sized for a 280 requests/second spike once a week, with both hard rules enforced by the database.

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.

Smash Point is the smallest system in this course, and that's exactly why it's worth putting on AWS. Cloud consoles make it easy to click together a load balancer, containers, a Multi-AZ database and a queue, and every one of them would be a mistake here. The skill this lab teaches on AWS is right-sizing: buying what the requirements ask for and saying no to the rest. Prices are Mumbai (ap-south-1) 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: Smash Point on AWS. One small instance, one small managed database, nothing else.
flowchart TD
  MP(["Members' phones"]) --> APP
  SD(["Staff dashboard"]) --> APP
  APP["One EC2 instance<br/>t4g.small, app and TLS"]:::hot --> DB[("RDS for PostgreSQL<br/>db.t4g.micro, Single-AZ")]:::store
  APP -. "after commit" .-> WA["WhatsApp messages<br/>End User Messaging Social"]
Smash Point on AWS. One small instance, one small managed database, nothing else.
Generic box On AWS The setting that matters
Web app, one small server One EC2 t4g.small (2 vCPU, 2 GiB) running the app and a TLS proxy An Elastic IP, and automatic recovery so a failed host brings the instance back
Database RDS for PostgreSQL, db.t4g.micro, Single-AZ Automated backups on, with a week or two of retention
The server's clock Amazon Time Sync Service, reachable from every EC2 instance Time rules checked by the database's clock, in one place
WhatsApp/SMS provider AWS End User Messaging Social, which sends WhatsApp messages and runs in Mumbai Sent after the booking commits, never inside the transaction
Weekly release job A cron job on the instance Safe to re-run at a new announced time

Argument 1: do we need a load balancer, containers or Multi-AZ?

The worst moment is 280 requests a second for about ten seconds, once a week. Everything else is a trickle. So start from the smallest setup that survives that spike, and price what each extra box would buy:

EC2 t4g.small:              $0.0112/hour x 730       = ~$8 a month
RDS db.t4g.micro, 1 AZ:     $0.021/hour  x 730       = ~$15
20 GB of gp3 storage:       20 x $0.131               = ~$3
Public IPv4 address:        $0.005/hour  x 730       = ~$4
                                                       ~$30 a month

The spike fits because of how T4g instances work. They launch in unlimited mode by default: they can run above their baseline CPU whenever they need to, and you only pay extra if the 24-hour average stays above the baseline. Ten seconds a week never will. Load-test the instance at 300 requests a second before the first Sunday anyway, since that's cheaper than finding out from Kavya.

Now the boxes you'd be tempted to add:

Extra Roughly What it buys Smash Point
A load balancer +$17 a month before traffic Nothing until there are two app servers
A second app instance +$8, plus the load balancer Sunday survives one instance dying, but the database is still a single point
Multi-AZ database +$18 Failover in a minute or two instead of a restore
Containers and an orchestrator Time, mostly Nothing a club needs

Each line could double the bill, and the brief already settled the question: Kavya accepted "if the server dies at 8pm, the release is delayed". That acceptance is a requirement. It's what makes the $30 design correct rather than cheap.

Argument 2: daily backups were the requirement. What does RDS actually give you?

The lab accepted losing up to a day of bookings. RDS does better for free. Automated backups take a daily snapshot and upload the transaction logs to S3 every five minutes, so you can restore to any second inside the retention period, losing at most about five minutes of bookings. A database this small stays within the free backup allocation.

Two things to know before you need it. A point-in-time restore creates a new database instance with a new endpoint, so the app's configuration has to change. And the app instance holds nothing worth backing up: rebuild it from a launch template or a setup script. Do one practice restore before the club opens bookings. It turns "we have backups" into "we know it takes this long to get back".

Argument 3: whose clock says 20:00:00?

The lab said the server's clock enforces the release, not the app's. On AWS there's a subtlety: cloud servers usually run on UTC, and Sunday 8pm in India is 14:30 UTC. A release rule written as "hour >= 20" on a UTC machine opens bookings five and a half hours late, or, worse, someone "fixes" it on the app server only.

Put the rule in one place, the database, and compare against an explicit time zone:

INSERT INTO bookings (court_id, starts_at, member_id, status)
SELECT :court_id, :starts_at, :member_id, 'booked'
WHERE now() >= (date_trunc('week', :starts_at AT TIME ZONE 'Asia/Kolkata')
                - interval '1 day' + interval '20 hours') AT TIME ZONE 'Asia/Kolkata';

Zero rows inserted means "not open yet": roll back, so the quota unit claimed earlier in the transaction is returned. Every check then uses one clock, synced to Amazon Time Sync Service, and the rule reads in the club's own time zone. Even if the club later runs two app instances, they can't disagree.

What it costs

Line Monthly, roughly
EC2 t4g.small ~$8
RDS db.t4g.micro, Single-AZ, with backups ~$15
Storage, 20 GB gp3 ~$3
Public IPv4 address ~$4
Total ~$30, plus Meta's per-message WhatsApp fees

At about 4,500 bookings a month, that's under 1 cent a booking.

The same design on GCP and Azure

Role AWS Google Cloud Azure
One small server EC2 t4g.small Compute Engine e2-small A B-series VM
Managed PostgreSQL RDS for PostgreSQL Cloud SQL for PostgreSQL Azure Database for PostgreSQL
WhatsApp End User Messaging Social A WhatsApp Business provider Azure Communication Services or a provider

The shape doesn't change. On any cloud, the right answer for a weekly ten-second spike is one small machine and the smallest managed database.

Kavya changes her mind

After a Sunday where the instance died at 7:58pm and the release slipped by forty minutes, Kavya says it must never happen again. What's the cheapest change that delivers that, and what does it cost?

Show one answer

Look at what actually broke: the app instance, which holds no data. So protect that first. Put the instance in an Auto Scaling group with a size of exactly one, behind a load balancer. A dead instance is replaced automatically from its launch template, and the load balancer sends traffic to the new one without any DNS change. That adds about $17 a month for the load balancer, plus a few dollars of traffic.

The database is the harder part to bring back, and it's the next question to put to Kavya: a Multi-AZ database adds about $18 a month and turns a database failure into a minute or two of errors instead of a restore. Together the bill roughly doubles to $65 or so. Lay out both steps and let her choose. "Never" is a business decision, and now she knows its price.

What to remember

  1. Voice notes and one-line briefs hide most of the requirements. "People claim the same court" is a consistency requirement in disguise.
  2. Size the worst moment, not the average day. A weekly 10-second spike can set the design.
  3. Keep the hot path to one short transaction, and do slow work like notifications after the commit.
  4. Let the database enforce rules that can be raced: a unique index for "one per slot", a conditional update on a counter row for "at most N".
  5. Put related steps in one transaction so a failure undoes all of them.
  6. Some questions, like fairness, are product decisions with technical consequences. Lay out the trade-offs and let the owner choose.
  7. On a cloud, right-size from the requirements: a weekly ten-second spike and an owner who accepts a delayed release is one small instance and the smallest managed database, about $30 a month. Every extra box must buy something the brief asked for.
  8. Managed databases often beat a modest backup requirement for free (point-in-time restore to within minutes). Keep time rules in one clock, with an explicit time zone.