Engineering Atlas › Low-Level Design

You aren't gonna need it (YAGNI)

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The speculative version

A team builds a notification service. The only requirement is email for password resets. The first design review produces this:

class Channel(ABC):
    @abstractmethod
    def send(self, msg: Message, opts: ChannelOptions) -> DeliveryResult: ...

class ChannelRegistry:
    def register(self, name, factory, priority=0, fallback=None): ...

class EmailChannel(Channel): ...
# SmsChannel, PushChannel: "coming soon"

There is also a ChannelOptions with 11 fields, a priority-and-fallback system, and a template engine with plugin support. Nine months later SMS arrives. It needs per-country sender IDs and opt-out handling, and the shared send signature fits neither. The team spends two weeks bending the abstraction, and the fallback system has still never run in production.

The YAGNI version

def send_password_reset(email: str, link: str, mailer: Mailer) -> None:
    mailer.send(to=email, subject="Reset your password", body=render_reset(link))

When SMS arrives, the team has two real channels to compare, so the shared shape is visible instead of guessed. The extraction takes a day, with tests that already cover the email path.

Four costs of a speculative feature

For any capability you are tempted to build early, compare:

Building early wins only when the later cost is much higher, the probability of need is high, and the wait before it is used is short. That combination is rarer than it feels in a design meeting.

What YAGNI does not cover

Some decisions are expensive or impossible to reverse:

The test is reversibility. If adding it later costs about the same as adding it now, wait.

Reviewing agent-written code

Coding agents add generality because it looks like quality: an interface with one implementation, a strategy parameter nobody passes, configuration for values that never change, **kwargs passed through "for flexibility". In review, ask for each: which current caller needs this? If none, remove it. Ask agents to "implement only what the tests require", and treat diffs that add abstractions without adding behaviour as a smell.

The same shape elsewhere

YAGNI is a speculation problem: do work early on a prediction, or wait for the fact. A CPU does speculative execution only because branch predictors are right about 95 percent of the time and a miss costs only about 15 to 20 cycles. Manufacturing calls the opposite of over-building "just in time" inventory: stock costs money to hold, and demand forecasts are often wrong. Capacity planning faces the same trade, which is why autoscaling usually beats provisioning for a 100x peak that may never come.

Down the stack

Layer 1The contract

YAGNI promises that every piece of code has a current reason to exist: a caller, a test from a real requirement, or a deliberate one-way-door decision written down. In return the team promises to keep the code cheap to change, so that adding a capability later costs a refactor, not a rewrite. If that second promise breaks, YAGNI stops paying.

Layer 2The mechanism

What makes deferral safe is tooling, not willpower:

  • Tests at the behaviour level let you extract an interface or split a module later without fear.
  • Refactoring tools (IDE rename, extract interface, gopls rename, the TypeScript language service) make "add the seam when the second case arrives" a minutes-long change.
  • Feature flags let half-built work ship dark, so you build in small slices against real demand.
  • Expand-and-contract migrations (add a nullable column, backfill, switch reads, drop the old column) make schema changes incremental, which removes the urge to design every column up front.
  • Dead code detection keeps carrying costs visible: Go's deadcode tool and staticcheck report unused functions, tsc flags unused locals with noUnusedLocals, and vulture finds unused Python code.

Layer 3The machine

Speculative generality has a runtime price. A direct Go method call can be inlined to a few instructions. Wrap the same work in an interface "in case of a second implementation" and the call goes through an itab lookup and an indirect branch, roughly 1 to 3 ns, and usually cannot be inlined. Worse, a value passed through an interface often escapes to the heap, adding an allocation (tens of nanoseconds) and garbage collector work per call.

In JavaScript, V8 optimises a call site that always sees one object shape. A plugin system that routes many shapes through one handle(event) makes that call site megamorphic, and V8 falls back to a slower generic lookup.

Unused dependencies cost elsewhere: a larger bundle means a slower cold start for a serverless function, more transitive packages to patch when a CVE lands, and longer CI builds on every commit. Configuration knobs that are read on every request are hash map lookups with a branch that the code paths behind them never exercise in tests.

Why it's built this way

  1. Requirements are uncertain, so a large share of features built ahead of need are never used or are used differently than guessed.
  2. A wrong guess is worse than no guess, because existing abstractions pull new code toward their shape.
  3. Carrying costs are paid on every change, so they compound with the size and age of the codebase.
  4. Tests and refactoring tools make the later change cheap, which turns waiting from a risk into the default.
  5. Some changes cannot be made cheap (lost data, published formats), so those are the exceptions to decide up front.

Diagram

flowchart TD
  A["Idea: we might need X"] --> B{"Current caller or requirement?"}
  B -- "yes" --> BUILD["Build it now"]
  B -- "no" --> C{"Expensive or impossible to add later?"}
  C -- "yes: data, formats, public API" --> D["Build the minimal hook now"]
  C -- "no" --> E{"Code cheap to change?"}
  E -- "yes" --> WAIT["Defer; note the trigger"]
  E -- "no" --> F["Invest in tests and refactoring first"]
  F --> WAIT

Worked example

A B2B analytics startup with 6 engineers debated multi-tenant sharding before they had a second large customer. The proposal: 15 engineer-days to build per-tenant database routing now. Estimated cost to add it later, once the codebase was larger: 25 engineer-days. Probability the sales pipeline would actually need it within a year: about 30 percent, and not before month 6. Carrying cost of the routing layer: about 1 engineer-day per month (tests, migrations that must run on every shard, onboarding).

Expected cost of building now: 15 days plus carrying it, about 6 months if needed and 12 months if not, which comes to 15 + (0.3 × 6 + 0.7 × 12) = 25.2 days. Expected cost of waiting: 0.3 × 25 = 7.5 days. They waited, and spent the 15 days on the reporting feature their first customer was asking for.

The same team built the audit log immediately. Building it now cost 3 days. Building it later was effectively 20 days plus permanent loss of history that a regulated customer's contract required, with a 90 percent chance of being asked within 3 months. Expected cost now: 3 days. Expected cost later: 18 days. YAGNI said build it, because the economics, not the slogan, decide.

Trade-offs

OptionStrengthsWeaknessesChoose when
Build only for current needsLeast code, right abstractions from real casesSome later changes cost moreDefault, with good tests
Build a minimal hook nowCheap insurance against a costly retrofitSmall carrying costVersions, IDs, audit data, extension points in public APIs
Build the full feature earlyReady when demand arrivesCarrying cost, often wrong shapeNeed is near-certain and imminent
Defer without testsFast todayLater change is a rewriteNever; fix the tests first

Pitfalls in production

Leadership lens

Check yourself

  1. Why does YAGNI depend on having good tests?

    Show answer

    Deferring is only cheap if the later change is cheap. Without tests, adding the capability later means a risky rewrite, so the economics flip toward building early.

  2. A colleague says YAGNI means you should not add a version field to a new event schema. What is wrong with that?

    Show answer

    A format other systems depend on is a one-way door; retrofitting versioning across producers and consumers is far costlier than one field now. YAGNI applies to features, not to keeping the system changeable.

  3. Why can a wrong early abstraction cost more than no abstraction?

    Show answer

    Later code is bent to fit the existing shape, so the cost is the original build plus the workarounds plus eventually undoing it.

  4. A feature is certain to be needed, but only in 10 months. Why might deferral still be right?

    Show answer

    Carrying cost accrues every month until it is used, and the details of a requirement 10 months away often change; if those costs exceed the extra cost of building later, waiting wins.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →