Module 1: How to Approach a System Design Problem
4. Scoping: cut the problem down to size
Description
You already know how to recognize requirements and pull them out of the prompt with questions. Now comes one of the most underrated decisions in design: choosing what you will NOT build yet. An open prompt —"design a URL shortener"— fits infinite features: analytics, user accounts, custom URLs, expiration, rate limits, spam detection, a statistics dashboard, a public API, integrations. If you try to design them all, you finish none of them well, and —worse— you lose the heart of the problem in a sea of accessories. Scoping is the act of drawing a line: this goes into the first design (the core, v1), that gets consciously deferred (v2, "for later"). It's not laziness or a cut in ambition; it's the discipline of solving what matters first, with the door open to the rest.
And there's a technical reason, not just a management one, to scope carefully: some features that sound innocent change the entire class of the system. Enlace's star example —which you'll see computed in this lesson— is click analytics. It sounds like "one more little counter", but counting every click means one write per each read, and since Enlace has ~4000 reads/s, that turns a ~40-writes/s system into a ~4000-writes/s one: a hundred times more write load, overnight, from a single scoping decision. Scoping well isn't just choosing where to spend your design time; it's avoiding dragging into the core a feature that makes it a hundred times harder.
Connection to the module: this lesson closes the understand phase and prepares the design one. Lesson 3 gave you the "what NOT" question family; this one turns it into a method —the in/out scope table—. What stays inside the scope is what lesson 5 will estimate and lesson 6 will draw in the single box. What stays outside is what, later in the guide, we'll decide whether to reincorporate once the system can handle it (analytics, for example, fits naturally when queues and events arrive —but that's from the sibling event-driven architecture guide, not here—). Scoping well here is what keeps the rest of the guide focused on the fundamentals.
The weekend suitcase
Think of it this way. You're off to the beach for three days and you have a carry-on. You can't bring your whole closet: what fits, fits. So you do something that, however you look at it, is scoping: you separate the essential —clothes for three days, swimsuit, charger, toothbrush— from the skippable-for-now —the winter coat, the dress shoes, the third pair of jeans "just in case"—. You don't pack the coat not because it's bad, but because for this trip you don't need it and it would take up the space of something you do. And notice the classic trap: the object that seems small but changes everything. You decide to bring "a pair of hiking boots, in case we go for a walk" —and suddenly the suitcase won't close, you have to take out half of it, and now you're carrying enormous weight for a hike you may not even take—. A decision that sounded minor reorganized the entire luggage.
Packing for a weekend isn't packing for a move. If you treated every short trip like a move —"just in case I bring everything"—, you'd never leave home, and you'd carry ten times the necessary weight. The good traveler's skill isn't bringing a lot; it's choosing just enough for this trip, knowing that what they left at home is still there for the next one. Leave the coat on purpose, not by oversight: they know they left it and why.
A system design is that suitcase. The prompt tempts you with a whole closet of features. Your suitcase —the first design, the v1— has limited space, not of fabric but of manageable complexity and time. Scoping is separating the essential (shorten, resolve, redirect) from the skippable-for-now (analytics, custom URLs), packing the first, and leaving the second at home knowingly, with the door open to bring it on the next trip. And above all: detecting the "hiking boots" —the feature that looks like a minor extra but that, if you pack it, forces you to redo the whole suitcase—. In Enlace, those boots are called click analytics.
It's worth spelling it out in full:
Scoping is choosing, on purpose, what goes into the first design and what gets deferred. You don't pack everything "just in case": you choose just enough for this system, and you watch for features that look small but change the class of the problem.
Enlace's core and its accessories
Let's start by separating, for Enlace, the heart from the accessories. The core is what, if missing, makes Enlace stop being a URL shortener:
- Shorten (
shorten): without this, there's no service. - Resolve and redirect (
resolve): without this, the codes lead nowhere. - 404 for nonexistent codes: the minimum robustness to not break on bad input.
That's the whole core: two operations and one error case. Everything else is an accessory —valuable, maybe, but an accessory—:
- Click analytics (counting visits): useful for the business, but it's not shortening.
- Custom URLs (
enla.ce/my-brandinstead ofenla.ce/aX9kR2q): nice, but optional. - Expiration (
expires_at): sometimes needed, sometimes not. - User accounts, rate limits, statistics dashboard, public API, spam detection: all of that lives in the closet, not in the v1 suitcase.
The question for each accessory is always the same: does the core need it to work, or is it an extra I can defer without breaking anything? If it's deferrable, it gets deferred —and it gets noted that it was deferred, so as not to confuse "I left it out on purpose" with "I forgot"—.
The in/out scope table
The concrete tool for scoping is a two-column table, explicit, that you deliver as part of the design. For Enlace:
| In scope (v1) | Out of scope (deferred) | Why it's deferred |
|---|---|---|
shorten(long_url) -> short_code | Click analytics | Multiplies write load ×100 (we compute it below) |
resolve(short_code) -> redirect | Custom URLs | Nice, not essential; adds uniqueness and validation separately |
| 404 for nonexistent code | Expiration (expires_at) | Simple to add later; doesn't change the core |
| — | User accounts, rate limits | Another system (auth); outside the shortener's heart |
| — | Spam/phishing detection | Big, independent problem; its own guide |
Notice the value of having this in writing. Anyone who reads your design sees, at a glance, what you solved and what you decided to postpone —and why—. Nobody will think "they forgot analytics"; they'll see "they deferred it on purpose, and here's the reason". That clarity is the difference between a design that looks incomplete and one that looks deliberately focused. The "why it's deferred" column is the most important: it turns an omission into a decision.
Worked example: the feature that multiplies the system by a hundred
Let's see why click analytics is Enlace's "hiking boots" —the feature that sounds minor but reorganizes the whole suitcase—. Analytics, in its simplest form, means: every time someone visits a link, increment its clicks counter. It sounds harmless. But "every time someone visits" is every read, and Enlace has ~4000 reads per second. Each of those reads becomes, with analytics, a write to the counter. Let's compute what that does to the write load:
# what does click analytics do to Enlace's write load?
seconds_per_month = 30 * 24 * 3600
writes_creation = 100_000_000 / seconds_per_month # creating links (shorten)
reads = writes_creation * 100 # reads (resolve), 100:1 ratio
# WITHOUT analytics: you only write when creating a link
writes_without_analytics = writes_creation
# WITH analytics: each read also increments the counter -> +1 write
writes_with_analytics = writes_creation + reads
print(f"writes/s WITHOUT analytics = {writes_without_analytics:7.1f} (~40)")
print(f"writes/s WITH analytics = {writes_with_analytics:7.1f} (~4040)")
print(f"increase factor = {writes_with_analytics / writes_without_analytics:.0f}x")
What to expect. Running this with Python 3.14.0:
writes/s WITHOUT analytics = 38.6 (~40)
writes/s WITH analytics = 3896.6 (~4040)
increase factor = 101x
There it is, measured: adding naive analytics multiplies Enlace's write load by a hundred, from ~40 to ~4000 writes per second. And that changes the class of the system. Without analytics, the writes were so few (~40/s) that a single database absorbed them without a thought, and all the scaling effort went to the read path (caching). With naive analytics, the writes equal the reads, and now you have a write-heavy problem in addition to the read one —you'd need to buffer the increments, batch them, maybe an event queue, maybe a separate database for the analytics—. A scoping decision that looked like "one more little counter" just doubled the difficulty of the system.
The design lesson is twofold. First: some features change the class of the problem, not just make it a bit bigger —and you have to detect them before putting them into the core—. Second: precisely for that reason, analytics is the perfect candidate to defer. Enlace v1 doesn't carry it; the core (shorten, resolve) stays clean at ~40 writes/s, and analytics waits until the system has the infrastructure to absorb it well (queues, events, separate storage —topics of the sibling guides—). Deferring it isn't giving it up: it's not letting the hiking boots keep the suitcase from closing.
And in case you doubt that analytics is "big", look at its data footprint: 4000 reads/s are 10 billion click events a month; at ~50–100 bytes per event, between 0.5 and 1 TB a month of analytics data alone —more than the link catalog itself—. Analytics isn't an accessory of the shortener; it's practically another system stuck on the side. All the more reason to defer it.
How to decide what goes in and what goes out
There's no magic formula, but there are three criteria that, applied in order, resolve almost all cases:
- Is it part of the core? If the system stops fulfilling its essential function without this feature, it goes in.
shortenandresolvego in because without them Enlace isn't a shortener. Analytics doesn't: Enlace keeps shortening and redirecting without it. - Does it change the class of the system? If a feature multiplies the scale or adds a new kind of load (like analytics ×100 on writes), it's a strong candidate to defer —unless it's core—. Adding something that reorganizes the whole architecture "for free" at the start is an expensive mistake.
- Can it be added later without redoing the core? If a feature can be bolted on later without touching the heart of the design, it's safe to defer. Expiration (
expires_at) is like that: you add a field and a check, without rewriting anything. Deferring it costs nothing; including it from day one adds nothing either.
Applying the three to Enlace: shorten/resolve/404 pass criterion 1 (they're core) → they go in. Analytics fails 1 (it's not core) and triggers 2 (×100 on writes) → deferred strongly. Custom URLs and expiration fail 1 but pass 3 (they're added later without redoing anything) → deferred comfortably. User accounts and anti-spam are whole systems apart → out, not even discussed for v1.
graph TD
F[Candidate feature?] --> N{Is it core?}
N -->|Yes| IN[INSIDE v1]
N -->|No| C{Does it change the<br/>class of the system?}
C -->|Yes| OUT1[OUT: defer strongly<br/>e.g. click analytics]
C -->|No| A{Added later<br/>without redoing the core?}
A -->|Yes| OUT2[OUT: defer calmly<br/>e.g. expiration]
A -->|No| OUT3[OUT: another system<br/>e.g. user accounts]
Common mistakes
Trying to design everything (the moving-house syndrome). What happens: someone treats the prompt as a list of mandatory features and sets out to design analytics, accounts, custom URLs, expiration, and anti-spam, all in v1. They drown, don't finish the core, and the design ends up half-done in everything and complete in nothing. Why it happens: leaving things out feels like failing, as if scoping were admitting incapacity. It's the reverse: scoping is the sign of judgment. How to detect it: if your v1 has more than a handful of features, you probably packed the move into the suitcase. How to fix it: apply the three criteria, keep the core, and defer the rest with its reason noted. A focused design that solves the essentials well is worth more than a huge one that solves nothing well.
Adding a feature that changes the class of the system without noticing. What happens: someone includes click analytics in v1 "because it's easy, just a counter", without computing that it turns ~40 writes/s into ~4000. They design the rest of the system for 40 writes/s, and all of that part ends up mis-sized. Why it happens: the feature's impact isn't estimated before adding it; it's judged by how it sounds, not by what it does to the numbers. How to detect it: for each candidate feature, ask yourself "does this multiply any load?". If you didn't compute it, you don't know. How to fix it: apply criterion 2 (does it change the class?) with arithmetic, as we did with analytics. The hiking boots are detected by weighing them, not by looking at them.
Deferring without a record (omitting by oversight, not by decision). What happens: someone leaves out analytics and expiration, but doesn't write it anywhere. Whoever reviews the design doesn't know whether they were decisions or slips, and distrusts: "did they think about expiration or did it slip?". Why it happens: scoping mentally is easy; documenting it requires an extra step that gets skipped. How to detect it: if you don't have an explicit "out of scope, and why" column, your omissions look like holes. How to fix it: the in/out table is part of the deliverable, not a mental note. Deferring with a record ("out of v1, because X") turns a suspicious omission into a defensible decision.
Exercises
Exercise 1 — Build the scope table. You're given this prompt: "Design a quick-notes service: people write a short note and get a link to share it; whoever opens the link sees the note." Apply the three criteria and build the in/out table for v1, with at least two features in and three out, each with its reason. (Consider candidates: create note, view note, edit note, password-protected notes, self-destruct after reading, rich formatting, user accounts.)
See solution
Applying the criteria (core? / changes the class? / added later?):
| In v1 | Out (deferred) | Why |
|---|---|---|
Create note (create_note) | Edit note | Not core; added later without redoing anything (criterion 3) |
View note (get_note) | Password-protected notes | Not core; it's a separate auth layer |
| 404 if it doesn't exist | Self-destruct after reading | Changes the data lifecycle (deletes on read); deferred until the core is done |
| — | Rich formatting | Cosmetic, not essential; pure accessory |
| — | User accounts | A whole other system (auth); out of v1 |
The core is identical in form to Enlace's: one operation that writes (create_note) and one that reads (get_note), plus the 404. Everything else is an accessory that fails criterion 1 and passes 3 (added later) or is a system apart. Notice "self-destruct after reading": it's the most interesting candidate, because it changes the data lifecycle (delete on read), brushing against criterion 2; that's why it's deferred more carefully than rich formatting, which is pure decoration.
Exercise 2 — Find the "hiking boots". From these candidate features for Enlace, identify which one (or ones) changes the class of the system —multiplies some load or adds a new kind of work— and which is an innocent accessory that gets deferred without drama. Justify with a back-of-the-envelope estimation where you can: (a) Custom URLs (enla.ce/my-brand). (b) A dashboard that shows, in real time, the clicks of all of a user's links. (c) Link expiration (expires_at). (d) A public API that allows creating links in bulk, up to 1000 per call.
See solution
- (a) Custom URLs — innocent accessory. It doesn't multiply any load; it only adds a uniqueness validation (the alias can't repeat) and an alternative way to generate the code. Deferred calmly (criterion 3).
- (b) Real-time click dashboard — hiking boots. It requires counting every click (the ×100 analytics on writes we computed) and, on top of that, aggregating them and serving them in real time, which adds heavy aggregation queries over 10 billion events/month. It changes the class of the system twice over. Deferred strongly.
- (c) Expiration — innocent accessory. An
expires_atfield and a check when resolving. It doesn't touch the scale or the core. Deferred (or even included) with no consequences (criterion 3). - (d) Bulk-creation API of 1000 — changes the class of the write. Remember normal writes are ~40/s; a single bulk call of 1000 creates, in an instant, 1000 links —25 times the write throughput of a whole second—. It introduces enormous write spikes and the need to process them without bringing down the DB. It's not as severe as (b), but it does change the write profile. Deferred or designed carefully (batch processing).
The lesson: (a) and (c) are light clothing; (b) and (d) are hiking boots that reorganize the suitcase. Recognizing which is which —weighing them with arithmetic, not by how they sound— is the central skill of scoping.
Exercise 3 — Defend a scope decision. A colleague reviews your Enlace v1 design and protests: "What do you mean there's no analytics? Every serious shortener shows how many clicks each link got. You're leaving it out out of laziness." Write your response in three or four sentences, defending the decision to defer analytics with a technical argument (not a taste one), and saying under what condition you'd reincorporate it.
See solution
A technical and honest defense:
"I'm not leaving it out out of laziness, but because it changes the class of the system. Counting every click turns into one write per each read, and since Enlace has ~4000 reads/s, the write load jumps from ~40/s to ~4000/s —a hundred times more— plus 0.5–1 TB a month of click events. Adding it naively in v1 would force designing the whole write path for a throughput the core doesn't need. That's why I defer it on purpose, with the core (shorten/resolve) clean at ~40 writes/s. I'd reincorporate it as soon as we have the infrastructure to absorb it well: an event queue that buffers the increments and a separate analytics storage —which is exactly what the sibling event-driven architecture guide teaches—. It's not 'never'; it's 'not in v1, and with a clear plan for how it comes in later'."
Notice that the defense doesn't say "I ran out of time" or "I don't like it": it gives a number (×100 on writes), a consequence (mis-sized write path), and a reincorporation condition (queues + separate storage). That's defending a scope decision as an engineer, not as a matter of preference. And in passing it demonstrates the golden rule: deferring isn't giving up, it's sequencing.
Summary and next step
In this lesson you learned to scope: draw the line between what goes into the first design and what gets deferred, like someone packing a weekend suitcase instead of a move. Enlace's core is tiny —shorten, resolve, 404—; everything else (analytics, custom URLs, expiration, accounts) are accessories deferred on purpose and with a record, in an in/out table where the "why" column turns each omission into a decision.
The technical idea you take away is that some features change the class of the system, not just make it bigger: you measured how naive click analytics multiplies Enlace's write load by a hundred (~40 → ~4000/s) and adds ~1 TB/month of data —"the hiking boots" you have to detect before putting them into the core—. The three criteria (is it core?, does it change the class?, added later?) give you a repeatable method to decide.
Before moving on you should be able to: separate the core from the accessories of any prompt; build an in/out table with the reason for each deferral; detect a feature that changes the class of the system by estimating its impact; and defend a scope decision with a technical argument and a reincorporation condition.
With this you close the phase of understanding the problem —requirements, questions, scope— and you're ready for the complete method. Lesson 5 puts everything from the module together in the 4-step framework (requirements → estimation → high-level design → deep dive), the backbone you'll follow the rest of the guide, and runs Enlace's estimation from start to finish. There, at last, the design begins to take shape.
Resources
- System Design Primer — "Step 1: Outline use cases, constraints, and assumptions" — the Primer insists on delimiting the scope ("we'll scope the problem to handle only the following use cases") at the start of every design. It's the same discipline as the in/out table: explicitly saying what you solve and what you leave out.
- Designing Data-Intensive Applications (DDIA), Chapter 1 — official site — Kleppmann's section on "simplicity" and managing complexity argues why a system that does less, but well, is more maintainable than one that tries to do everything. It's the foundation for why scoping improves the design, not just saves time.
- Martin Fowler — "MVP (Minimum Viable Product)" — the minimum viable product concept: build the essential that delivers value first and defer the rest. The in/out scope table is the system-design version of this product idea.