Step 01 of 22

How to approach a design

Use a repeatable order — requirements, numbers, API, data, boxes, deep dives — to turn a vague problem into a defensible design.

17 min 10 sections

"Design a pastebin." "Design a ride-sharing backend." The prompt is always vague, and that is the point: system design is the work of turning a fuzzy goal into a small set of decisions you can defend. The most common failure isn't a wrong answer. It's skipping straight to drawing boxes — a load balancer, Kafka, three microservices — before anyone knows what the system must do or how big it is. This lesson gives you an order of work that prevents that, and walks it through on one example.

Design is a sequence of decisions

Every design answers the same questions, and the answers to the early ones constrain the later ones:

  1. What must it do, and for whom? Functional requirements.
  2. How well must it do it? Non-functional requirements: latency, availability, durability, consistency, cost.
  3. How big is it? Requests per second, data volume, growth.
  4. What is the interface? The API the clients call.
  5. What is the data, and how is it accessed? Entities, relationships, read and write patterns.
  6. What is the simplest architecture that satisfies 1–5?
  7. Where will it break, and what do we do about it? Deep dives on the two or three hardest parts.

Work in that order. If you choose a database at step 6 without knowing the access patterns from step 5, you are guessing. If you add a cache without the read-to-write ratio from step 3, you can't say whether it helps.

We'll use one running example: a pastebin, where users paste text and get a short link others can open.

Step 1: Pin down the requirements

Start by listing what users do, then cut ruthlessly. A feature you don't build is one you don't have to scale, secure or operate. See clarifying requirements for the questions worth asking.

QuestionAnswer for our pastebin
Who uses it?Anonymous users; optional accounts later (out of scope)
Core actionsCreate a paste (text up to 1 MB), read it by short link
Expiry?Yes: 1 hour, 1 day, 1 week, or never
Edit or delete?No edits; the creator can delete with a secret token
Syntax highlighting, search, comments?Highlighting in the browser; no search; no comments

Now the non-functional requirements, stated as numbers rather than adjectives. "Fast" and "highly available" can't be checked; "p99 read under 200 ms" can.

  • Reads: p99 under 200 ms from the user's region.
  • Availability: 99.9% for reads (about 43 minutes of downtime a month); writes can be slightly lower.
  • Durability: a paste that was acknowledged must not be lost before it expires.
  • Consistency: after creating a paste, the creator's own link must work immediately. Others seeing it a second later is fine.

That last line is worth noticing. It's a weaker requirement than "every read sees every write instantly", and weaker requirements are cheaper to meet. Ask for the weakest guarantee the product can live with. See non-functional requirements.

Step 2: Put numbers on it

Rough numbers decide whether you need one machine or a hundred. The next lesson covers estimation properly; here is the shape of it.

  • 1 million new pastes a day ≈ 12 writes per second on average; assume peaks of 5× → 60/s.
  • Reads outnumber writes 10:1 → about 120 reads per second, peaking near 600/s.
  • Average paste 10 KB → 10 GB of new text a day → about 3.6 TB a year before expiry deletes anything.

Three conclusions fall out immediately. The request rate is modest: one well-configured database could handle it. Storage, not traffic, is the scaling problem. And because pastes are immutable, they are ideal for caching.

Step 3: Define the API

The API is the contract with every client, so design it before the internals. Keep it small and specific to the requirements.

http
POST /api/pastes
Content-Type: application/json

{"content": "func main() {}", "language": "go", "expires_in": "1d"}

201 Created
{"id": "Xk9fQ2", "url": "https://paste.example/Xk9fQ2",
 "delete_token": "d1c3…", "expires_at": "2026-10-28T10:00:00Z"}
http
GET /api/pastes/Xk9fQ2          → 200 {"content": "...", "language": "go", ...}
                                → 404 if missing, 410 Gone if expired
DELETE /api/pastes/Xk9fQ2
Authorization: Bearer d1c3…     → 204

Small decisions here matter later. Returning 410 Gone for expired pastes tells caches and clients the paste won't come back. Making the creator hold a delete_token avoids building accounts. And the id format — six characters from a 62-letter alphabet, 62⁶ ≈ 56 billion combinations — is a deep-dive topic of its own (see unique ID generation).

Step 4: Model the data and its access patterns

List the entities and, crucially, how each one is read and written. Access patterns, not entities, choose the storage.

EntityFieldsWrittenRead
Paste metadataid, language, created_at, expires_at, delete_token_hash, sizeOnce, at creationBy id, on every view
Paste contentup to 1 MB of textOnceBy id, on every view

Every read is a lookup by primary key. Nothing needs joins, range scans or full-text search. That points to storing metadata in a database keyed by id, and the content — large, immutable, rarely changed — in object storage (S3 or similar), with the database row holding the object's key. Databases are expensive per gigabyte and slow to back up when full of blobs; object storage is cheap, durable and built for exactly this. See object storage.

sql
CREATE TABLE pastes (
  id                text PRIMARY KEY,
  language          text NOT NULL,
  object_key        text NOT NULL,
  size_bytes        integer NOT NULL,
  delete_token_hash bytea NOT NULL,
  created_at        timestamptz NOT NULL DEFAULT now(),
  expires_at        timestamptz          -- NULL means never
);
CREATE INDEX pastes_expires_at ON pastes (expires_at) WHERE expires_at IS NOT NULL;

The partial index exists for the cleanup job, which repeatedly asks "which pastes expired before now?".

Step 5: Draw the simplest thing that works

Now — and only now — draw boxes. Start with the minimum that meets the requirements, then add components only where a requirement or a number demands one.

DiagramCreating and reading a paste
ClientAPIDatabaseObject store1POST /api/pastes2PUT objects/7f3e9a…32004INSERT row (id, object_key, …)5committed6201 {id, url, delete_token}7GET /api/pastes/Xk9fQ28SELECT … WHERE id = $19GET objects/7f3e9a…10200 content

Notice the write order: object first, then the row. If the API crashes between the two steps, you're left with an orphaned object, which a periodic sweep can delete. The reverse order would leave a row pointing at nothing, which is a user-visible bug. Thinking through partial failure like this is a habit worth building early.

The API servers keep no state of their own, so you can run several behind a load balancer and add more when needed (see stateless services). With 600 reads per second at peak, that's all the scale the numbers call for.

Step 6: Deep-dive the hard parts

A design review spends most of its time on the two or three parts most likely to go wrong. For the pastebin:

  • Read latency and cost. Pastes never change, so put a CDN in front of GET with a cache lifetime capped at the paste's expiry. Most reads then never reach your servers. Deleting a paste must also purge it from the CDN. See content delivery networks.
  • ID generation. Random 6-character IDs collide rarely, but they do collide. Insert with the primary key constraint and retry with a new ID on conflict, rather than checking first and inserting second — checking first is a race. This is also why the object key is its own random value rather than the paste ID: if the object were stored under the ID, a colliding create would overwrite another paste's content before the insert failed.
  • Expiry. A job runs every minute, deletes up to 1,000 expired rows using the partial index, then deletes their objects. Reads also check expires_at, so a paste is never served late even if the job falls behind.

For each, say the trade-off out loud: "A CDN cuts origin load by roughly 90%, at the cost of deletions taking up to a minute to propagate." A decision without a stated cost isn't a decision yet.

Mistakes that sink designs

  • No numbers. Without estimates, "we'll shard the database" is as defensible as "we won't".
  • Designing for a scale nobody asked for. Ten thousand users don't need microservices and a message bus. Over-built systems are slower to change and have more ways to fail.
  • Only the happy path. Every arrow in a diagram can fail, time out or succeed twice. Ask what happens for each.
  • Vague requirements accepted as given. "Real-time" might mean 100 ms or 10 seconds. The difference is a different architecture.
  • No plan for knowing it works. Name the few metrics you'd alert on: error rate, p99 latency, queue depth, storage growth.

Practice

Apply the seven steps to this prompt: "Design a service that sends each of our 2 million users a personalised daily digest email at 8 a.m. in their own time zone."

Write down requirements (functional and non-functional, with numbers), estimates, the API or interface, the data model with access patterns, a simple architecture, and two deep dives with their trade-offs.

Show solution

Requirements. Functional: once a day, at 08:00 local time, build each opted-in user's digest from their activity and send it; users can unsubscribe; no duplicate sends. Non-functional: emails arrive within 15 minutes of 08:00; at most one digest per user per day; a failed send is retried; cost per email stays low (we pay the email provider per message).

Numbers. 2 million users across roughly 24 time zones → around 85,000 emails per hour on average, but population is uneven, so the busiest hour might hold 400,000. Sending those within 15 minutes means about 450 per second at peak. Building a digest needs a few queries per user, so peaks of a few thousand database reads per second.

Interface. No public API is needed. Internally: enqueue(user_id, digest_date) and an email provider call send(to, subject, html, idempotency_key).

Data. users(id, email, tz, digest_opt_in); digest_sends(user_id, digest_date, status, sent_at) with a unique key on (user_id, digest_date). Access patterns: "which users hit 08:00 in the next 15 minutes?" (an index on tz, or a precomputed per-timezone schedule) and "has this user's digest been sent today?" (the unique key).

Architecture. A scheduler runs every minute, finds time zones where it's now 08:00, and enqueues one message per user onto a queue. A pool of workers consumes the queue, builds each digest, inserts the digest_sends row (the unique key blocks a second send), and calls the email provider with an idempotency key of user_id:date. Workers scale with queue depth.

Deep dives. (1) Duplicates. The queue delivers at least once, so a worker might process a message twice. The unique row plus the provider's idempotency key make the second attempt a no-op. Trade-off: one extra write per email. (2) Peak load. Enqueuing 400,000 messages at 08:00 sharp would spike the database. Spreading each time zone's batch across a 10-minute window, and limiting worker concurrency, keeps it flat. Trade-off: some users get their digest at 08:09 instead of 08:00, which is within the requirement.

Key takeaways

  • Work in order: requirements, numbers, API, data and access patterns, simplest architecture, deep dives.
  • State non-functional requirements as numbers, and ask for the weakest guarantee the product can accept.
  • Access patterns — not the list of entities — decide where data lives.
  • Start with the simplest design that meets the requirements and add components only when a requirement demands one.
  • Think through partial failure for every step, and give every decision a stated cost.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why define the API and access patterns before choosing a database?

  2. 2.In the pastebin, the API writes the content to object storage and then inserts the database row. What does this order buy?

  3. 3.Which non-functional requirement is stated well?

  4. 4.A teammate proposes adding Kafka to the pastebin 'for scalability'. What is the right first question?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.