# 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.

System Design roadmap · Foundations · 17 min read · https://levelcode.org/roadmaps/system-design/approach-a-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:

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](https://levelcode.org/concepts/requirements-clarification) 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](https://levelcode.org/concepts/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](https://levelcode.org/concepts/unique-ids)).

## 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](https://levelcode.org/concepts/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.

```sequence
title: Creating and reading a paste
participants: Client, API, Database, Object store
Client -> API: POST /api/pastes
API -> Object store: PUT objects/7f3e9a…
> Content goes to object storage first, under a random key. If this fails, nothing points at a missing object.
Object store --> API: 200
API -> Database: INSERT row (id, object_key, …)
Database --> API: committed
API --> Client: 201 {id, url, delete_token}
Client -> API: GET /api/pastes/Xk9fQ2
API -> Database: SELECT … WHERE id = $1
API -> Object store: GET objects/7f3e9a…
API --> Client: 200 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](https://levelcode.org/concepts/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](https://levelcode.org/concepts/cdn).
- **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.

> [!TIP]
> When you're unsure whether to add a component, ask which requirement fails without it. If you can't name one, leave it out. See [when not to add infrastructure](https://levelcode.org/concepts/when-not-to-add-infrastructure).

## 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](https://levelcode.org/concepts/monolith-vs-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.

**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.

## Quiz

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

   A. Databases can only be chosen after the API is deployed
   B. Access patterns — lookups by key, range scans, joins, search — determine which storage fits; without them the choice is a guess
   C. API design has no effect on storage, so the order doesn't matter
   D. So the database can generate the API automatically

   **Answer: B.** A store that's excellent for key lookups can be poor at range scans or joins. Knowing how data is read and written is what lets you choose. The order is a way of avoiding decisions made without information.

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

   A. It is faster because object storage is quicker than databases
   B. It guarantees both steps succeed together
   C. A crash between the steps leaves an orphaned object that a sweep can delete, rather than a row pointing at missing content
   D. It lets the CDN cache the paste before it exists

   **Answer: C.** The two writes aren't atomic. Ordering them so the failure case is harmless — an unreferenced object — is cheaper than making them atomic. The opposite order produces a user-visible broken link.

3. Which non-functional requirement is stated well?

   A. The system should be fast and scalable
   B. Reads must be highly available
   C. p99 read latency under 200 ms, and 99.9% monthly availability for reads
   D. Use the best database for performance

   **Answer: C.** Requirements need to be checkable. Percentile latency and an availability target can be measured and alerted on; adjectives can't. The last option is a solution, not a requirement.

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

   A. Which requirement fails without it?
   B. How many partitions should the topic have?
   C. Should we use Kafka or RabbitMQ?
   D. Can Kafka store the paste content?

   **Answer: A.** Every component has an operating cost and new failure modes. At 60 writes per second there's no requirement a log solves here, so it shouldn't be added. Choosing partitions or vendors comes after deciding it's needed.
