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.
"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:
- What must it do, and for whom? Functional requirements.
- How well must it do it? Non-functional requirements: latency, availability, durability, consistency, cost.
- How big is it? Requests per second, data volume, growth.
- What is the interface? The API the clients call.
- What is the data, and how is it accessed? Entities, relationships, read and write patterns.
- What is the simplest architecture that satisfies 1–5?
- 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.
| Question | Answer for our pastebin |
|---|---|
| Who uses it? | Anonymous users; optional accounts later (out of scope) |
| Core actions | Create 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.
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"}GET /api/pastes/Xk9fQ2 → 200 {"content": "...", "language": "go", ...}
→ 404 if missing, 410 Gone if expired
DELETE /api/pastes/Xk9fQ2
Authorization: Bearer d1c3… → 204Small 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.
| Entity | Fields | Written | Read |
|---|---|---|---|
| Paste metadata | id, language, created_at, expires_at, delete_token_hash, size | Once, at creation | By id, on every view |
| Paste content | up to 1 MB of text | Once | By 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.
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.
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
GETwith 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.Why define the API and access patterns before choosing a database?
2.In the pastebin, the API writes the content to object storage and then inserts the database row. What does this order buy?
3.Which non-functional requirement is stated well?
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.
- Clarifying requirementsTurn a vague prompt into users, features, scale and constraints before drawing a single box.
- Non-functional requirementsLatency, availability, durability, consistency and cost — written down as numbers.
- Monolith vs microservicesWhat splitting a service buys you, what it costs, and the modular monolith in between.
- When not to add infrastructureThe cheapest, most reliable component is the one you never run.
- REST, gRPC and GraphQLThree API styles and where each one fits: public APIs, service-to-service, and client-shaped reads.
- Conway's lawA system’s structure ends up mirroring the communication structure of the organization that builds it.
Finished reading?
Mark it complete to fill in your roadmap.