A Method for Designing Systems
A repeatable way to approach any design problem, and how to sketch it clearly.
Why you want a method
When you sit down to design a system, the hardest part is knowing where to start. A method fixes that. It is a fixed order of small questions you ask every time, so you never stare at a blank page or reach for a fancy tool before you understand the problem. A design doc — a short written proposal, often called an RFC (Request for Comments) because teammates comment on it before you build — is far easier to write when the thinking underneath it follows a known shape.
The goal is not to memorize architectures. The goal is to choose simple building blocks, evolve them only where the system actually strains, and explain each choice in terms of what you traded away. The failure mode to avoid is over-engineering too early: reaching for sharding, event streams, and multiple regions before you have shown you need any of them.
The stance that keeps you honest:
Start with the simplest thing that works. Find where it breaks. Add the next pattern for that specific break. Name the trade-off.
The six-step method
Every design in this course follows the same six steps, in order. You clarify what you are building, define how it is called and what it stores, draw the smallest architecture that satisfies the core flow, then grow that architecture one bottleneck at a time, narrating trade-offs as you go.
The order matters. Steps 1 through 3 are cheap and reveal how the system is used. Step 4 gives you a baseline everyone can see. Step 5 is where scale enters, but only in response to a named pressure. Step 6 is not a final step so much as a running commentary — you say "the trade-off is..." throughout.
Step 1 — Clarify requirements
Before designing anything, find out what the system must actually do and how it will be used. Guessing here wastes every later step, because the whole architecture bends around read-vs-write balance, scale, and how fresh data must be. Clarifying first is the single highest-leverage habit in this method.
Ask, in plain language:
- What are the core use cases — the two or three things this system exists to do?
- Is it read-heavy or write-heavy? (Are most requests fetching data or changing it?)
- Roughly how many users, or requests per second, at peak?
- What latency target matters? Latency is how long one request takes; teams often quote P95 or P99 — the time under which 95% or 99% of requests complete, so the slow tail is visible, not just the average.
- What consistency is required? Must a user always see their own latest write immediately (read-after-write), or is a short delay acceptable?
- What should happen if the system is briefly unavailable — is a stale answer or an error the lesser harm?
- Any constraints on where data lives (privacy, region) or how tenants are isolated?
You will not get every answer, and that is fine. The point is to surface assumptions out loud so the design rests on stated requirements rather than silent guesses.
Step 2 — Define the core API
The API is the set of operations callers make against your system. Sketching it early is the fastest way to expose access patterns — which reads and writes actually happen, and how often. You are not designing the full surface; three or four operations that cover the core flow are enough.
A minimal sketch for an item service:
POST /items create an item
GET /items/{id} fetch one item
GET /users/{id}/items list a user's items
Notice what the third line already tells you: items are queried by owner. That single fact will shape the data model and, later, how you might partition storage. Do not linger here — the API is a probe for access patterns, not the deliverable.
Step 3 — Define the data model
The data model is the shape of what you store: the entities and their key fields. A rough sketch is enough to reason about size, relationships, and the queries the storage must serve. Over-detailing it now is wasted effort, because the model will shift as requirements sharpen.
User
id
name
Item
id
owner_id
status
created_at
The owner_id field connects directly to the GET /users/{id}/items call from Step 2 — the model exists to serve the access patterns you just found. Keep it this thin until a real query forces more.
Step 4 — Draw the simple architecture
Now draw the smallest architecture that satisfies the core flow, and nothing more. This is your baseline. Every later scaling decision is a departure from this picture, justified by a specific pressure. Starting here — rather than at the "impressive" end — is what keeps a design grounded.
The default baseline: stateless API services behind a load balancer, a durable database as the source of truth. (Stateless means an API server keeps no per-user memory between requests, so any server can handle any request — which is what lets you add more of them freely. A load balancer, or LB, spreads incoming requests across those servers.)
Say what it is out loud: "Stateless API behind a load balancer, one database as the source of truth." That sentence is the seed the rest of the design grows from.
Step 5 — Scale by bottleneck
A bottleneck is the one place a system strains first as load grows — the part that saturates while everything else still has room. The discipline of this step is to name the specific bottleneck, then add the one pattern that relieves it. You do not add caches, queues, and replicas speculatively; each earns its place by answering a pressure you can point to.
The evolution runs strictly by pressure, not by ambition:
Map each pressure to a fix. This table is the heart of the step — read it as "when you see the bottleneck on the left, reach for the thing on the right."
| Bottleneck | What to add |
|---|---|
| API compute maxed out | More stateless API instances behind the LB |
| Repeated, expensive reads | A cache for hot reads |
| Slow or retryable writes | A queue plus a worker pool for background work |
| Database read load | Read replicas (copies that serve reads) |
| Database write load or size | Partitioning / sharding (split data across databases) |
| Slow external dependency | Async processing with retries |
| Bursty, spiky traffic | A queue plus rate limiting |
| Global latency | A CDN or regional deployment |
| Failure recovery | Replication, backups, failover |
A few of these terms, glossed: a cache is a fast store for frequently read data, so you avoid hitting the database every time. A queue holds work to be done later, so the caller gets a quick acknowledgement while a worker processes it in the background. Sharding splits one database into several, each holding a slice of the data. Rate limiting caps how many requests a client may make, protecting the system from overload. Backpressure is the related move of slowing or rejecting incoming work instead of letting queues grow without bound until the system collapses.
The rule underneath the table: delay every one of these until its bottleneck is real. Complexity you add before you need it is pure cost.
Step 6 — Make trade-offs explicit
No architectural choice is free. Each one buys something and gives something up, and a design is only as trustworthy as your honesty about the cost. The habit to build is a single recurring phrase — "the trade-off is..." — attached to every decision. There is rarely one right answer; the quality of a design lives in how well you argue the trade.
A shared vocabulary makes those trade-offs precise. Use these words deliberately:
| Term | Plain meaning |
|---|---|
| Latency | How fast the user gets a response |
| Throughput | How many requests or jobs the system handles per unit time |
| Availability | Whether the system keeps answering during failures |
| Consistency | Whether users see the latest correct state |
| Durability | Whether committed data survives failures |
| Idempotency | Whether repeating an operation is safe (no double effect) |
| Backpressure | Slowing or rejecting work instead of collapsing under it |
| Hot partition | One shard or key taking far more traffic than its peers |
| Fanout | One event expanding into many writes or messages |
| Read-after-write | A user reading their own latest write immediately |
| Eventual consistency | Data becomes correct after a short propagation delay |
Typical sentences the vocabulary lets you say cleanly: "The trade-off is lower latency versus possibly stale reads." "The trade-off is more write capacity versus harder cross-shard queries." "The trade-off is a fast acknowledgement versus eventual delivery." Each names both sides, which is exactly what a reviewer needs to agree or push back.
Observability is part of the design
Observability is your ability to see what the system is doing from the outside — through metrics, logs, and traces. It is not a step you bolt on at the end; a system that scales but cannot be observed is not finished, because you cannot tell where it is straining. Treat it as a first-class part of every design.
Instrument the critical path with the signals that reveal bottlenecks before users do:
- Request latency, including P95/P99, so the slow tail is visible
- Error rate on the main flows
- Saturation — how close each resource is to its limit
- Queue depth, so backed-up async work is caught early
- Cache hit rate, so a failing cache is obvious
- Database latency, so a struggling data tier surfaces
Without these, "where is it slow?" becomes a guess. With them, Step 5's bottleneck table stops being theory and becomes something you can read off a dashboard.
How to sketch architecture diagrams
A good architecture diagram lets a teammate grasp the shape of a system in seconds — in a design doc, an RFC, or a shared diagram canvas. The skill is restraint: few shapes, clear labels, one consistent direction of flow. You are drawing the logical architecture — the boxes and how requests move between them — not every class or function inside them.
Keep a tiny, consistent vocabulary of shapes:
Rectangle a service
Cylinder a database
Small labelled box a cache or a queue
Solid arrow request or event flow
Dashed arrow async work or replication
Then lay it out left-to-right so the reader's eye follows the request:
Read what makes this diagram work: clients enter from the left, storage sits to the right, and the async path (queue then worker) drops below the main request line. The arrows are labelled with the action — read, write, enqueue — so the flow is unambiguous without a legend. That is the whole craft: direction, placement, and labelled arrows.
Good versus bad diagram habits
The difference between a diagram that helps and one that confuses is mostly discipline, not artistry. The good habits keep the reader oriented; the bad ones bury the logical architecture under premature detail. Keep this contrast in view whenever you draw.
| Good habit | Bad habit |
|---|---|
| Left-to-right flow, clients on the left | Arrows crossing in every direction |
| Storage on the right or bottom | Random placement with no reading order |
| Async workers below the main path | Everything on one crowded line |
| Arrows labelled: read, write, publish, consume | Unlabelled arrows |
| A handful of clearly named boxes | Too many boxes, no names |
| Logical components first | Naming specific vendor products before the logic |
| Add infrastructure only once justified | Jumping to clusters, streams, and shards up front |
The single worst habit is drawing specific products — a particular queue, cache, or datastore — before you have explained the logical role they play. Show the role first; the product is an implementation detail you can name later.
Communicating a design
A design is only as good as your ability to explain it in a review or an RFC. The same four habits carry every discussion, and each mirrors a step of the method. They turn a design from a diagram into a shared decision.
- Make your reasoning explicit. Say why, not just what. "I'm adding a cache because reads repeat and the database is the bottleneck" tells a reviewer far more than the cache box alone. Reasoning is what others can agree with or challenge.
- Clarify requirements before designing. Restate what the system must do and how it is used before drawing anything. This is Step 1, and skipping it is the most common way designs go wrong.
- There is rarely one right answer — argue trade-offs. Present the choice and what it costs, not a single decreed solution. "We could shard now for write capacity, but that costs cross-shard query complexity" invites a real decision.
- Start with the simplest thing that works. Open with the baseline, then evolve it. Leading with the most elaborate version hides your reasoning and usually solves problems you do not yet have.
A worked example: a URL shortener
Let us walk one small system end-to-end through the method to see how the steps chain together. A URL shortener takes a long URL and returns a short code; visiting the short code redirects to the original.
Step 1 — Clarify. Core use cases: create a short code for a URL, and redirect from a short code to the long URL. It is overwhelmingly read-heavy — codes are created once and followed many times. Latency on the redirect must be very low. Mappings almost never change after creation, so freshness is easy.
Step 2 — Core API.
POST /urls create a short code for a long URL
GET /{code} redirect to the long URL
Step 3 — Data model.
UrlMapping
code
long_url
created_at
Step 4 — Simple architecture. The baseline is enough to serve both operations:
Step 5 — Scale by bottleneck. The named pressure is read load on the redirect path — the same popular codes are followed constantly. From the table, repeated hot reads call for a cache:
We add nothing else. There is no queue, because there is no slow background work; no sharding, because one database comfortably holds the mappings until proven otherwise.
Step 6 — Trade-offs. The cache trades a small amount of freshness for much lower latency — and here that trade is nearly free, because mappings rarely change, so a stale cache entry is almost never wrong. That is the whole design: a baseline, one bottleneck, one pattern, one named trade-off.
Key takeaways
- A method beats inspiration: clarify, define the API, define the data model, draw the simplest architecture, scale by bottleneck, name trade-offs.
- Clarify requirements and access patterns before drawing anything — the whole design bends around read-vs-write balance, scale, and freshness.
- Start from the smallest architecture that works, and add each pattern only in response to a named bottleneck.
- Every choice has a cost; say "the trade-off is..." out loud, and use precise vocabulary for both sides.
- Observability is part of the design, not an afterthought — you cannot fix a bottleneck you cannot see.
- Draw few shapes, flow left-to-right, put storage on the right and async work below, and label every arrow.
- Show the logical architecture before naming any specific product.