ComplexityGENERALLIFETIME-SPECIFICCONTESTED

YAGNI, With Its Bill Attached

Do not build features or flexibility for a requirement nobody has. The rule is right often enough to be a default, and its cost is real: the refactor you deferred arrives under deadline pressure.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Someone wants to build for a requirement that has not been asked for. When is refusing that the cheap choice, and what am I agreeing to pay if I refuse?

The requirement

The checkout flow needs to send an order confirmation by email. An engineer proposes a notification abstraction with pluggable channels, "because we will obviously want SMS and push".

The obvious build

Build the channel abstraction now. It is only an interface and a factory, and retrofitting it later means touching checkout again.

Why it breaks

The abstraction is designed from one example, so it encodes the shape of email: a subject, a body, an HTML part. SMS has none of those and push has a payload limit, so when the second channel actually arrives the interface is wrong and has to change anyway (The Rule of Three).

How it breaks as requirements change
  • The abstraction is designed from one example, so it encodes the shape of email: a subject, a body, an HTML part. SMS has none of those and push has a payload limit, so when the second channel actually arrives the interface is wrong and has to change anyway (The Rule of Three).
  • Meanwhile every engineer reading the confirmation path goes through a registry, a factory and an interface to find a provider call, forever, for a flexibility that has never been exercised (Local Reasoning).
  • The extension point invites more of itself. Once there is a channel interface, someone adds a channel-preference model and a fallback ordering, none of which any requirement asked for.
  • And it will not be removed. Removing an abstraction is work with no feature attached, so it stays until someone rewrites the module (Speculative Generality).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Nobody has asked for SMS or push, and no one with budget authority has mentioned either.
  • The team is four engineers with a committed roadmap for the quarter.
  • Email sending is one provider call and roughly thirty lines including retries.
Invariants
  • Every completed order results in exactly one confirmation to the customer, or an alert if it could not be sent.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Checkout owns "an order completed". It does not own how a customer is reached.
  • One notification function owns sending the confirmation. Today that means calling the email provider, and that is the whole responsibility.
  • Whoever proposes speculative flexibility owns naming the requirement and its likelihood; "obviously" is not a name (The Cost of Change).
Boundaries
  • There is still a boundary: checkout calls sendOrderConfirmation(order) rather than the email SDK. That is not speculative — it is one function with one implementation and no interface, and it is where a second channel would land.
  • The distinction that matters is between a *seam* and a *mechanism*. A named function is a seam and costs nothing; a registry, a factory and a plugin lookup are a mechanism and cost every reader (Finding Seams).

What the rule actually applies to

YAGNI gets misapplied in both directions because "it" is never specified. It is a rule about speculation — features nobody asked for and flexibility nobody has needed — and it says nothing about whether your code should have coherent responsibilities.

Separating the four cases takes the argument out of the realm of slogans. Two of them are obvious deferrals, one is not speculation at all, and one is the genuine exception that people forget when they recite the rule.

The proposalDefer?Why, and what deferring costs
A feature nobody asked forYes, almost alwaysIt has no claimant. Cost of deferring: nothing, unless a claimant appears — and then you build the feature they actually described rather than the one you guessed.
Flexibility for an unnamed variationYesOne implementation teaches you nothing about what varies, so the abstraction encodes the example rather than the axis. Cost of deferring: a two-to-three day extraction if the second case arrives (What an Abstraction Costs).
Giving a rule one owner and a nameNo — this is not speculationPutting the confirmation rule in one function with an intent-revealing name costs nothing and is not a bet on the future. Refusing it in YAGNI's name is how the rule gets used to justify a mess (Designing by Responsibility).
Something catastrophic to retrofitNo — build a minimal versionId schemes, tenancy, audit trails, timezone handling and anything that shapes stored rows. Cost of deferring is not a refactor, it is a data migration across every historical record (Data Migration).
The flexibility is the productNo — it is the requirementA plugin API a customer is paying for is not speculation; it is a feature with versioning obligations attached (Plugin Architecture).

The bill, when the deferral loses

Any honest presentation of YAGNI has to show the branch where it costs you, because that branch is real and teams that have lived it are right to be sceptical of the rule.

The important detail is *when* the bill arrives. It is not the refactor that is expensive — two to three days is a fair price for building from evidence instead of a guess. It is that the bill arrives attached to a feature with a date, so the refactor competes with the launch and usually loses.

Q3: "growth wants SMS confirmations for the checkout flow, live before the campaign"
The change

A second notification channel is genuinely required, with a launch date three weeks out.

Deferred: one `sendOrderConfirmation` function calling the email provider
NotificationsCheckout
testsnotification_testcheckout_test
2 modules · 2 test files

Two to three days to extract an interface from two real implementations, and the shape is right because both cases are visible. Under deadline pressure the realistic outcome is worse: the SMS path is copied alongside the email path with the retry logic duplicated, and the extraction is filed as a follow-up that does not happen.

Built ahead: channel interface, registry, factory
NotificationsSmsChannel
testssms_channel_test
2 modules · 1 test file

Add one implementation and register it — half a day, if the interface fits. It usually does not fit exactly: SMS has no subject, has a length limit, and the delivery receipt is asynchronous, so part of the interface changes anyway and every existing implementation changes with it.

what it cost The prepared design got here by paying a week up front and a permanent indirection tax on a path that four engineers read regularly — a cost paid with certainty against a benefit that arrived with probability well under one half. The deferred design is cheaper in expectation and strictly worse in this branch, and it also carries the risk that the extraction never happens at all and the confirmation rule ends up living twice.

Deferring on purpose, in one paragraph

The difference between a deferral and an oversight is written evidence, and it does not need to be long. Four lines in the module, next to the code, saying what was not built and what would change the answer.

The trigger is the part that matters. Without it, the next engineer to arrive sees a direct provider call and cannot tell whether it is a considered decision or something nobody got to — and in that situation they will either build the abstraction unasked or copy the code.

notifications/README — the whole record
1# One channel, no channel abstraction
2
3Context Order confirmation by email. One provider, ~30 lines.
4 SMS and push have been mentioned; no claimant, no date.
5
6Decision Ship sendOrderConfirmation(order) calling the provider
7 directly. No interface, no registry. Callers depend on the
8 function name, not on the mechanism.
9
10Why One implementation cannot tell us what varies. An interface
11 designed from email alone would have a subject and an HTML
12 body, neither of which SMS has.
13
14Cost If a second channel lands under a deadline, we pay ~3 days
15 of extraction at the worst moment, and there is a real risk
16 it gets copied instead.
17
18Revisit A second channel gets a named owner and a date, OR the
19 provider call appears anywhere outside notifications/.

The second revisit trigger is doing quiet work: it catches the failure where someone copies the sending code into a new path instead of extending this one, which is the actual way this deferral goes wrong (Revisit Triggers).

How to build it

Most important first.

  • Require a named requirement with a claimant. "SMS, asked for by the growth team, targeted for Q3" is a fact you can be wrong about; "we will obviously want SMS" is not.
  • Build the smallest thing that satisfies today, behind a name that describes intent rather than mechanism. sendOrderConfirmation survives a channel change; emailService.send does not (Naming).
  • When the second case arrives, extract from two real examples. The shape you get is evidence rather than a guess, and it is usually different from what you would have guessed (Premature Abstraction).
  • Make the exceptions explicit and short. Data model decisions, id schemes, tenancy and audit trails are the cases where retrofitting touches every stored row, and they justify building ahead of the requirement (Reversible and Irreversible Decisions).
  • Write the deferral down with its trigger, so the next engineer knows this was decided rather than overlooked (Decision Records).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Defer, and SMS never arrives: cost zero, forever. This is the outcome in the large majority of cases, and it is the reason the default is what it is.
  • Defer, and SMS arrives in Q3: roughly two to three days to extract an interface from two real implementations, plus the feature — done with knowledge of what actually varies. If it arrives during a launch freeze, the same work costs more and is done worse.
  • Build now, SMS never arrives: the week, plus a permanent readability tax on the confirmation path and a mechanism nobody can remove.
  • Build now, SMS arrives: the abstraction is wrong in some detail — no subject line, a length limit, a delivery receipt — and part of the week is refunded, not all of it.
What the recommended approach costs
  • The honest counter-cost: some fraction of deferred work lands at the worst time, and a refactor under deadline pressure is both more expensive and lower quality than the same refactor done calmly. YAGNI trades a certain small saving for an uncertain larger cost, and that is a real bet, not a free lunch.
  • Requiring a named requirement adds friction for senior engineers whose speculation is often correct, and some of that friction is pure waste.
  • Deferring structure repeatedly, without ever extracting, is how a codebase ends up with the same rule in six places. YAGNI needs the rule-of-three discipline attached or it degrades into an excuse (The Rule of Three).

What can go wrong

Failure modes
  • YAGNI is applied to structure rather than to speculation, and the result is a codebase with no responsibilities at all — every rule inline, because "we might not need a module". That is under-design wearing the rule as a badge (Over-Design and Under-Design).
  • The deferred change arrives on the worst possible date, and the refactor is done under pressure by whoever is on the ticket, badly.
  • The second case arrives and nobody extracts. The email path is copied for SMS, and now the confirmation rule lives twice (Duplicate Knowledge).
  • YAGNI is used to refuse work with genuine evidence behind it, because "you are not gonna need it" is unanswerable in a meeting.
Dependencies, and their direction
  • Deferring keeps the dependency graph honest: checkout depends on one notification function, which depends on one provider.
  • The speculative version adds a dependency from real code onto a hypothesis, and every call site that uses the abstraction becomes a reason it cannot be removed.
  • The deferral creates a dependency on future judgement: it assumes someone will notice the second case and do the extraction rather than copying the first one (Duplicate Knowledge).
Misreads
  • "YAGNI means do not design." It is about speculative features and speculative flexibility, not about giving code coherent responsibilities. Naming a function well and putting a rule in one place are not speculation (The Design Loop).
  • "YAGNI means no interfaces." An interface with two real implementations is not speculation; it is a description of what varies. The target is the one with a single implementation and a hypothetical second (Interface Versus Implementation).
  • "The refactor later is always cheap." Sometimes it is genuinely not — a change to stored data or to an identity scheme can be effectively impossible to retrofit, and those exceptions are the ones worth memorising (Data Migration).
  • "We applied YAGNI, so this mess is fine." A mess with duplicated knowledge is not a deferral, it is a decision nobody made (Duplicate Knowledge).
Smells this explains
  • speculative-generality
  • premature-abstraction

Testing it, and how it ages

What to test, and at which boundary
  • Test that a completed order produces a confirmation, at the checkout boundary. That test is unchanged whether there are one or five channels, which is the sign the boundary is in the right place (What a Unit Is).
  • Do not test the abstraction you did not build. A test suite that mirrors a registry and a factory is a test suite that will block the extraction when it finally happens (Mocking).
How this design ages
  • The cheapest moment to build the abstraction is when the second case exists and the first is still fresh. That window is short and teams routinely miss it in both directions.
  • A deferral with no written trigger decays into folklore — "we decided not to do notifications properly" — and the next person cannot tell whether it was a decision or an oversight (Revisit Triggers).
  • As a codebase ages, the balance shifts: retrofitting into a large, widely-called module is more expensive than into a small one, so the same deferral that was right at year one can be wrong at year four (Stability and Dependency Direction).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe expected-value shape — a certain cost now against an uncertain saving later — holds anywhere, though the size of the certain cost depends heavily on how much ceremony an abstraction requires in your language.
  • LIFETIME-SPECIFICFor code that will live three months, defer everything; the second case will not arrive. For code that must survive a decade and store data, the exceptions list grows sharply, because retrofitting an id scheme or a tenancy column into ten years of rows is a migration project rather than a refactor.
  • CONTESTEDThe strongest opposing view, held by experienced engineers who have lived through the other failure: in large organisations, retrofitting a boundary is not merely expensive but politically impossible — there is never a quarter in which "restructure the notification path" beats a feature, so the only time a seam ever gets built is before anyone depends on it. On that account YAGNI is a rule that optimises the first year and taxes the fifth. The counter is that this argues for a small number of named, expensive-to-retrofit exceptions rather than for building flexibility by default.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

API Designversioning
Domains that do not exist yet
  • Testing & Reliability Engineering — the deferral is only safe if you can extract later without fear, so the real enabler of YAGNI is a test suite that pins behaviour at the boundary rather than at the implementation.