Lesson 0002 · Core Designer · Module 1
SRP, DRY, KISS, YAGNI in Context
These four are quoted as rules and violated as rules. The skill is reading them as forces: each names a failure mode, and each can be wrong.
Mission tie-in: AI will happily generate code that "follows the rules" while violating the point. Understanding the forces is what lets you keep control of AI output instead of accepting its letter.
Knowledge: forces, not rules
SRP — one reason to change
The unit is the reason to change, not the method. "We must edit this class because tax rules changed" and "…because we switched databases" — two reasons in one class is the failure. A class with twenty methods but one change source is fine; a class with two methods and two change sources is not.
DRY — once and only once, per unit of knowledge
DRY targets knowledge, not text. These two functions are identical today — and encode different knowledge:
def fee_for_bank_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # bank fee, set by our bank
def fee_for_wire_transfer(amount: Money) -> Money:
return amount * Decimal("0.01") # wire fee, set by the clearing house
Both compute 1% today. Merge them into one fee(amount) and the next time one fee changes, you will silently charge the other's customers the wrong amount. That is false DRY — deduplication that creates coupling between things that will diverge. The two fees changing together twice is what earns the merge (the rule of three).
KISS and YAGNI — the cost of complexity
KISS: prefer the least complex solution that meets the requirement. YAGNI: do not build capability with no evidence of need. Both are about unpaid tax: every generality, hook, and abstraction layer is paid for on every future read of the code, whether it's used or not.
† The expensive combination: YAGNI in the present ("too complex to build now") + KISS misread as "shortest code". Shortest is not simplest — a clear twenty-line function beats a clever five-liner.The context is the whole story: a hook is YAGNI in a two-month project and common sense in a platform other teams will build on. The same line of code can be a violation and a virtue in two codebases.
All four are heuristics about the same hidden quantity: the cost of being wrong, and how expensive the mistake is to undo. Once you see that, they stop competing and start dividing the space.
DRY's asymmetry. Un-merging two things that turned out to be different is cheap — copy the function, give each half its own name, done in an afternoon. Un-coupling two things that were merged years ago is expensive: you must find every caller and decide, per call site, which of the two meanings it wanted, usually without the person who wrote it. Worse, the merge fails silently: nothing crashes when one policy diverges, you just charge the wrong number until someone notices in a reconciliation. Cheap to undo in one direction, expensive and silent in the other — which is why the rule of three says wait.
YAGNI's asymmetry. An unused abstraction is paid on every read by every reader for the life of the code; the same abstraction added later is paid once, by one person, with better information about what it actually needs to do. That is a straightforward win for waiting — provided the later change is local.
Where they stop applying. That proviso is the boundary condition, and it is the one people miss. When the later change is not local — a published API with external consumers, a wire format shared by four teams, a schema with ten million rows already written — the "add it later" cost is a migration, a dual-write window, and a coordination meeting, not an edit. YAGNI weakens exactly in proportion to how irreversible the decision is. Similarly, SRP's unit — the reason to change — is only knowable if you know who edits the code and why; in a domain you have worked in for two weeks you do not know that yet, so a confident split is a guess wearing a principle's clothes.
The practical rule that falls out: be greedy about simplicity where change is cheap, and thoughtful about extension points where change is expensive. Code is cheap. Data, contracts, and other people's calendars are not.
How a senior designer decides the fee question
Return to the two 1% fees. Here is the reasoning, not the verdict.
First they look for the owner, not the code. Who decides each number? One is set in the bank's commercial agreement; the other is published by the clearing house. Two documents, two counterparties, two renewal dates. That answer arrives before anyone opens the function bodies, and it is already most of the decision.
Then the question: when one of these changes, does the other change in the same commit? Not "could they" — has it happened? Check the history of the constants. If the two numbers have moved independently even once, they are two facts and the merge is a bug waiting for a date.
Then they reject the compromise. The tempting middle path is to keep one function and pass a discriminator:
def fee(amount: Money, kind: str) -> Money:
rate = Decimal("0.01") if kind == "bank" else Decimal("0.01")
return amount * rate
This is worse than either honest option. It is control coupling: the caller now passes a flag whose only job is to select which of two policies runs, so every caller must know the taxonomy, and the function's signature promises one behaviour while delivering two. It also creates the illusion of having deduplicated — the rate literal is written twice anyway. A senior designer's tell: if the parameter exists only to choose between branches, you have not shared knowledge, you have shared a switchboard.
The consequence over eighteen months. On a four-team payments platform, the wire fee went tiered — 1% up to €10,000, then 0.6%. In the merged version, that change edits the function both teams depend on; the bank team's regression suite starts failing for reasons that have nothing to do with their product, and the review needs someone from each team, so a two-line change waits three days for a quorum. In the kept-apart version it is one file, one reviewer, one afternoon. The duplication that looked like sloppiness was the thing that let two teams move at different speeds.
| You see this | What it costs | The move |
|---|---|---|
def fee(amount, kind) | A kind flag is two facts pressed into one function. | Keep two functions until a third change moves both. |
| OrderData + OrderLogic | SRP applied by shape, not by change source — one force, two files. | Merge them back; split only where the forces differ. |
| **kwargs three layers deep | KISS misread as “less typing”: the contract disappeared. | Name the parameters at every layer. |
| strategy= with one implementation | YAGNI: a hook paid for on every read, used by nobody. | Delete the hook; add it when case two arrives. |
When this is the wrong move
- Do not wait for the rule of three when the duplicated thing is a security or correctness check. Three copies of an authorisation test means two of them will drift, and the failure mode is not a wrong number, it is an open door. Centralise on the first copy.
- Do not apply YAGNI to stored data or published contracts. A version field on an event, a nullable column reserved for a known-coming attribute, an
extramap on a public payload — these are cheap now and cost a migration later. The principle is about capability, and its strength scales with reversibility. - Do not split for SRP in an unfamiliar domain. You cannot name change sources you have never seen. Keep it in one module, watch the edits for a quarter, and split when the history tells you where the line is.
- Do not read KISS as "fewer abstractions". The simplest thing for the reader is sometimes one more named concept, not one fewer. A clear twenty-line function with two well-named helpers beats a clever five-liner that needs a paragraph of explanation.
- DRY means no duplicated text.
- DRY is about duplicated knowledge — a fact that would have to be changed in two places if it changed at all. Two functions with identical bodies and different owners are not a DRY violation; one fact spread across a config file, a constant, and a comment is, even though the three look nothing alike.
- YAGNI means never design ahead.
- YAGNI is about building capability with no evidence of need. Thinking ahead is free and mandatory; the discipline is not shipping the machinery until the evidence arrives, except where the later fix is a migration rather than an edit.
- KISS means fewer lines.
- Simplicity is measured in the reader's head, not the file's byte count. Density and simplicity are frequently opposites: a comprehension nested three deep is short and hard, an explicit loop is longer and obvious.
- These principles conflict, so you have to pick a favourite.
- They govern different axes. DRY is about who owns a fact; SRP about what forces edit a module; KISS about the reader's load; YAGNI about the evidence for a capability. A concrete decision usually only triggers one of them, and when two seem to fight, you have mis-stated at least one.
Skill: judge the application, not the principle
Two systems charge the same 5% fee today. Extracting one shared function is:
A class has ten methods but exactly one reason to change. SRP says:
A team adds a version field to an event format that is written to durable storage, before any second version exists. This is:
Practice on your own code
Find one place where a principle was applied as a rule — a shared helper that two things were forced into, a class split "to follow SRP", a generality built "just in case". Name the force it ignored, and then name the reversibility: if this call was wrong, what would undoing it cost in hours and in coordination?
In the lab: the returns-resolution service contains at least one shared helper that two policies were pressed into. The phase-1 checkpoint asks you to decide whether it is true DRY or false DRY using the ownership test — who sets each number, and have they ever moved independently — and to write the one-line justification for whichever way you call it.
Reveal: a worked judgment
An AI assistant refactors a file and reports: "Extracted get_price() shared by sales and returns, following DRY." Look closer: sales prices include VAT; return prices are ex-VAT. The shared helper is false DRY — the moment VAT rules change, returns pricing changes too. The right move is to keep two functions and one documented fact: these rates are set by different policies.
- Explain to a junior why two identical functions can be correct design, in two sentences, without using the word "DRY".
- Find one abstraction in your own code this week that was added "just in case", and name its cost: how many readers have paid for it, and how many callers have used it.
- Predict what breaks in a year if a fact your system owns stays merged with a fact someone else owns — and say who will be the first to notice.
Your win
You can now apply the four principles as a judge, not an executor: for any "violation," name the force it actually threatens — and spot the rule-following that quietly does damage.
Read and watch deeper
- Clean Architecture, Martin — ch. 7 "SRP: The Single Responsibility Principle": the reason-to-change definition.
- The Pragmatic Programmer, Hunt & Thomas — "DRY: The Evils of Duplication", where DRY is defined over knowledge rather than text.
- Refactoring, Fowler — ch. 2, where the Rule of Three is stated ("three strikes and you refactor"). It belongs to Fowler, not to DRY, and the distinction matters: DRY says what to unify, the rule of three says when.
- "Yagni", Martin Fowler — the clearest primary statement of the cost model: presumptive features cost build, carry, repair, and delay.
- A Philosophy of Software Design, Ousterhout — ch. 11 "Design It Twice" and the discussion of complexity in ch. 2: KISS made concrete.
- Watch: ArjanCodes YouTube — search "7 principles of software design" for the source framing of these four.
- Reference: Design Principles — all nineteen, each with the way it fails when followed as a rule; and the glossary on false DRY and the rule of three.
Stuck on a judgment call? Ask your agent-teacher: paste the code and the claimed principle, and it will help you name the force actually at stake.