Basics
Software Design Principles
DRY, KISS and YAGNI, the three principles that keep a low-level design easy to change. DRY gives each piece of knowledge one home, but merging code that only looks alike builds the wrong abstraction; KISS picks the simplest correct design, not the shortest; YAGNI builds what is asked for now, except for decisions that are expensive to reverse. Every example runs in Java and Python, with verified output.
Suggest an editSoftware Design Principles — DRY, KISS and YAGNI
Every design decision is a guess about how the code will need to change. DRY, KISS and YAGNI are three rules of thumb for making good guesses. They apply at every level, from a single method to a whole system.
Each one removes a different kind of waste. DRY removes duplicated knowledge, so that a change only has to be made in one place. KISS removes needless complexity, so that the code is quick to read and hard to get wrong. YAGNI removes speculative code, so that you don't build and maintain features nobody asked for. Each principle also does harm if you push it too far, and this lesson shows where that point is.
💡 The core idea.
- DRY: every piece of knowledge (a rule, a rate, a limit) should be defined in exactly one place. Code that only looks the same does not necessarily represent the same knowledge.
- KISS: prefer the simplest design that is correct, meaning the one with the fewest parts, not the fewest characters.
- YAGNI: build what is needed now, not what you guess will be needed later. The exception is decisions that would be expensive to undo.
This lesson covers in depth the three principles introduced in Introduction to Low-Level Design. Every output below was produced by running the code on Java 21 and Python 3.11.
You'll be able to: spot duplicated knowledge, not just duplicated text, and define it in one place; decide when two similar blocks of code should stay separate, using the Rule of Three and the cost of the wrong abstraction; simplify over-built code without losing correctness, including the n % 2 == 1 trap for negative numbers; tell a speculative feature, where YAGNI applies, from a committed requirement or an expensive-to-reverse decision, where it does not.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what a change to the code costs, and where it has to land.
- A concrete bite — a specific, runnable program where ignoring the principle (or overusing it) gives a wrong or fragile result.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- DRY: one fact, one place
- When DRY hurts
- KISS: the simplest design that works
- YAGNI: build what is asked for now
- When building ahead is right
- How the three pull against each other
- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. DRY: one fact, one place
DRY stands for Don't Repeat Yourself. The original definition is about knowledge, not about text: "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system" [1].
A shop shows a total in the shopping cart and again on the invoice. Both add VAT, and each has its own copy of the VAT rate. The rate has just gone up from 20% to 25%, and only the cart code was updated:
Output:
cart total: 125
invoice total: 120Analysis. Both methods compile, run and look reasonable on their own. Together they are wrong: the customer is shown 125 but billed 120. No compiler, and no test of a single class, catches it, because each class does exactly what its own code says. The fault is that one fact, "VAT is 25%", is stored in two places.
The fix stores the fact in one place, and both totals read it from there:
Output:
cart total: 125
invoice total: 125The next rate change needs one edit, to Vat.PERCENT, and both totals pick it up.
Intuition. Mechanism. Every copy of a fact is another place that a future change must reach. With one copy, a change is one edit. With two copies, it is two edits, plus the risk that someone makes only one of them, and then nothing tells you which copy is right. The words "single, unambiguous, authoritative" in the definition name all three needs: one copy, one meaning, and one copy that everyone treats as correct.
Concrete bite. The first program above shows it. The duplication cost nothing on the day it was written. It cost a wrong bill on the day the rate changed, and the bug was in the class that nobody edited.
Non-example: code that looks the same may not be the same knowledge. Usernames and product codes were both limited to 20 characters, so someone made them share one check. Later, product codes were shortened to 8 characters, and the shared limit was changed:
Output (Java; Python prints True and False):
product code AB123456: true
username ada_lovelace: falseThe change for product codes made a valid 12-character username fail. The two checks had the same code, but they expressed two different rules, owned by different people and free to change independently. Merging them tied together two things that were never one fact. Keep UsernameRules.MAX_LENGTH = 20 and ProductCode.LENGTH = 8 apart.
💡 Earned rule. Before you remove duplication, ask: if one of these changes, must the other change too? If yes, it is one fact: define it in one place now, as a constant, a method or a class. If no, it is two facts that happen to look the same today, so keep them separate.
The cost of DRY is that shared code ties its users together: every user of the shared definition is affected when it changes. That is exactly what you want when the knowledge really is shared, and a bug when it isn't.
2. When DRY hurts
Merging two similar blocks of code creates an abstraction: one method or class that does the job of both. If the guess about what they have in common is wrong, the abstraction is worse than the duplication it replaced. Sandi Metz's summary is that "duplication is far cheaper than the wrong abstraction" [2].
The usual protection is the Rule of Three, credited to Don Roberts: "The first time you do something, you just do it. The second time you do something similar, you wince at the duplication, but you do the duplicate thing anyway. The third time you do something similar, you refactor" [3]. By the third copy, you can see what the copies really have in common and what differs, so the shared version is based on evidence instead of a guess.
The rule is for code that looks the same. It is not permission to copy a business fact, such as the VAT rate, twice. A fact that you already know is shared should be defined in one place from the start.
Four situations where DRY does more harm than good:
- Abstracting too early. Two blocks of code look the same now, but will change for different reasons, like the username and product-code checks in section 1. Merging them ties them together. Example:
AdminReportandUserReportsharegenerateReport()today, but admin reports are about to get audit columns. A shared method would soon need a flag for every difference. - Readability. A shared method used by unrelated callers gradually gains flag parameters:
process(data, isOrder, skipAudit). Anyone reading it for one caller now has to read through code paths that don't apply to that caller. Two plain methods,validateUser()andvalidateOrder(), are easier to read than one with atypeswitch. When a shared method has already gone this way, Metz's advice is to copy its code back into each caller and start again [2]. - Performance-critical code, after measuring. Calling a small helper method is usually cheap, because Java's JIT compiler can copy it into the caller. But a generic helper that wraps numbers in objects, or calls a lambda for every element, can cost real time inside a loop that runs millions of times. Keep a separate, specialised copy only when a profiler shows the cost, not on a hunch.
- Old code without tests. Merging duplicated logic in old, untested code can change its behaviour without anyone noticing, because the copies may differ in ways nobody remembers. First add tests that record the current behaviour, or leave the code alone until you need to change that feature anyway.
💡 Earned rule. Remove duplicated knowledge immediately. Remove duplicated-looking code when it appears for the third time, once you can see what the copies really share. If a shared method starts collecting flag parameters, split it back up.
The cost of waiting is a temporary copy that you must remember to merge later. That is cheap. Untangling the wrong abstraction once many callers depend on it is not.
3. KISS: the simplest design that works
KISS stands for Keep It Simple, Stupid: out of the designs that solve the problem, prefer the one with the fewest parts. Python's design notes put it as "Simple is better than complex" and "Readability counts" [4].
Here are two versions of isEven, run on the same inputs:
Output (Java; Python prints True and False):
4 true true
7 false false
0 true true
-3 false falseAnalysis. The two columns agree for every input, including 0 and -3. The longer version's extra variable, its starting value of false and both branches added nothing, because number % 2 == 0 is already a boolean. The extra lines only gave a reader more to check.
Intuition. Mechanism. Every variable, branch, class and extra layer of method calls is something a reader must keep in mind, and a place where a bug can hide. Simplicity is measured by counting those parts, not characters. A design is simple when nothing more can be removed without breaking it.
Concrete bite. In low-level design, the usual breach of KISS is not an extra if. It is a design pattern used for a single case. Here, a 10% discount is wrapped in an interface, a class and a factory, next to the same rule written as one method:
Output (prints two lines, then a thrown exception; Python raises ValueError: unknown discount: TEN_PERCNT):
90
90
Exception in thread "main" java.lang.IllegalArgumentException: unknown discount: TEN_PERCNT
at DiscountPolicyFactory.create(Main.java:17)
at Main.main(Main.java:32)Both designs give 90. The pattern added three types and a string key, and the string key turned a compile-time error into a run-time error. A typo in the method name Pricing.discounted would not compile. The typo TEN_PERCNT in the string did compile, and only failed when that line ran. The interface becomes worthwhile when a second kind of discount arrives; the design patterns chapter is about exactly those cases.
Non-example: simple is not the same as short. isOdd looks like the obvious opposite of isEven:
Output (Java):
isOdd(3) = true
isOdd(-3) = false
-3 % 2 = -1Output (Python):
is_odd(3) = True
is_odd(-3) = True
-3 % 2 = 1The same one-line function is correct in Python but wrong in Java. Java's % gives a result with the sign of the dividend, so -3 % 2 is -1 [5]. Python's % gives the sign of the divisor, so it is 1 [6]. Write n % 2 != 0 instead: it is just as short, and correct in both languages. The bit trick (n & 1) != 0 is also correct, but a reader has to understand how negative numbers are stored in binary to trust it. % 2 says what you mean.
💡 Earned rule. Write the most direct correct version first: a plain expression rather than a flag variable, a method rather than a class hierarchy, a class rather than a framework. Add structure only when a second, real case needs it.
The cost of KISS is that the simple version may need reworking when the second case arrives. That rework is a small change, made with real knowledge of both cases. Removing structure that all the calling code already depends on is a much bigger job.
4. YAGNI: build what is asked for now
YAGNI stands for You Aren't Gonna Need It. It comes from Extreme Programming, where Ron Jeffries put it as: "Always implement things when you actually need them, never when you just foresee that you need them" [7].
The task is a note-taking app that lets users create notes and view them. The first version is written for a future that its author imagined:
Output:
Groceries: milk, eggs
Ideas: a parking-lot designThe version that does what was asked:
Output:
Groceries: milk, eggs
Ideas: a parking-lot designAnalysis. The output is identical. The speculative version contains three fields and three methods that no code uses, and one of those methods throws an exception if anything ever does call it. The minimal version is short enough to read at a glance, and because Note can no longer be changed after it is created, it is also harder to misuse.
Intuition. Mechanism. Martin Fowler splits the price of building a feature before it is needed into four costs [8]. The cost of build is the time spent writing it. The cost of delay is the needed work that had to wait in the meantime. The cost of carry is the extra complexity that everyone must work around in every later change. The cost of repair comes when the guess was wrong and the feature has to be reworked. Even a correct guess pays the costs of delay and carry until the feature is actually needed.
Concrete bite. Suppose tags are requested a year later, but as coloured labels shared across notebooks. The List<String> tags field on each note is now the wrong design. The team must convert it, and change every caller of byTag, before they can build what was actually asked for. The guess was right that tags would be needed, but wrong about what they would look like, which is how guesses about the future usually go wrong.
Non-example: YAGNI is not a reason to skip tests or refactoring. Fowler is explicit: "Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify" [8]. Tests, clear names and small classes are what make "we'll add it later" cheap. They are what makes YAGNI work, not a breach of it.
💡 Earned rule. When you hear yourself say "we might need…", stop, and build only what is needed now. Keep the code easy to change, so that adding the feature later is cheap.
The cost of YAGNI is real: sometimes you will add something later that would have been cheaper to add at the start. The trade is still worth it, because most guesses are never used, and the ones that are often turn out to need a different design.
5. When building ahead is right
YAGNI is about features built on a guess. Some work done before it is needed is not a guess:
- The requirement has been agreed, not guessed. Example: a messaging service only sends text today, but image attachments are scheduled for two sprints from now. Designing the message data model to hold attachments now avoids converting all the stored messages in two weeks' time. The test is whether someone has committed to the feature, not whether it seems likely.
- The decision is expensive to undo. A database schema, a public API, a file format or a network message format: once stored data or other teams depend on them, changing them means converting data and coordinating with other people. Think these through up front, even though you only build today's features on top of them.
- Testing a known requirement early. Example: a multiplayer game server must support 10,000 players at once from launch day. A load test with 10,000 simulated players in week one, while there are only 5 real testers, finds slow spots where threads block each other, before the architecture is too settled to change. That is testing a requirement you already have, not building a feature you might need.
💡 Earned rule. Build ahead only for requirements that have been agreed, or for decisions that are expensive to undo. Everything else waits until someone asks for it.
Building ahead has the same four costs as any other guess. It is only worth paying them when changing later would clearly cost more, and you can explain why.
6. How the three pull against each other
The three principles usually agree. Where they disagree is where design judgement is needed:
- DRY vs KISS. Every abstraction that removes duplication adds a new name, and another step for the reader to follow. If the shared version is harder to read than two plain copies, KISS wins. This is the flag-parameter problem from section 2.
- DRY vs YAGNI. A general framework built "so we never repeat ourselves" is itself a feature built on a guess. YAGNI and the Rule of Three agree: wait until the third case shows you what the shared design should really be.
- KISS vs YAGNI. These rarely disagree. Code built for a guessed future adds parts, so it breaks KISS too. The exceptions in section 5 are the cases where a little extra complexity now makes things simpler overall.
The SOLID principles come next. They are about how to structure abstractions, once you have decided they are worth building.
7. Mental-model summary
| Principle | Consequence |
|---|---|
| DRY is about knowledge, not text | Each rule, rate or limit is defined in one place, so a change is one edit |
| Copies of the same knowledge drift apart | Two copies of the VAT rate gave 125 in the cart and 120 on the invoice |
| Code that looks the same can express two different rules | Merging the username and product-code checks broke usernames when one rule changed |
| The wrong abstraction costs more than duplication | Wait for the third copy; split up a shared method that keeps gaining flags |
| KISS counts parts, not characters | Prefer a plain expression, then a method, then a class; add a pattern when a second real case appears |
| Simple must still be correct | n % 2 == 1 fails for negative numbers in Java; n % 2 != 0 works in Java and Python |
| YAGNI: build only what is asked for now | Speculative features pay build, delay, carry and repair costs |
| YAGNI never forbids tests or refactoring | They make adding a feature later cheap |
| Agreed requirements and hard-to-undo decisions are the exception | Plan schemas, public APIs and formats ahead; build features when they are needed |
8. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Two screens show different numbers after a rule changed | the same fact is stored in two places, and only one was edited | define it in one place (a constant or one method), and make both read it |
| Changing a shared helper for one caller breaks another | two different rules were merged because their code looked the same | split it back into two; merge again only if a truly shared rule appears |
A shared method keeps gaining boolean flags or a type parameter |
the abstraction doesn't fit some of its callers | copy its code back into the callers and start again |
| A one-line rule is hidden behind an interface, a class and a factory | a pattern was added before a second case existed | write the rule as a method; add the interface when a second variant arrives |
| A typo in a string key only fails at run time | the factory looks up behaviour by string instead of by type | call the method directly, or key by an enum once there are several cases |
isOdd(-3) returns false in Java |
Java's % keeps the sign of the dividend, so -3 % 2 is -1 |
test n % 2 != 0 |
| Fields and methods that no caller uses, or that throw "not built yet" | features built for a guessed future | delete them; add each one when it is asked for |
| "YAGNI" used as a reason to skip tests or refactoring | YAGNI applied to work that makes code easier to change | keep the tests and the refactoring; YAGNI only covers features built on a guess |
✅ Check yourself
One check per objective. Answer before you open anything.
A teammate sees Cart.total and Invoice.total both calling Vat.addTo, and proposes a PricingEngine with pluggable TaxStrategy, RoundingStrategy and CurrencyStrategy "to keep it DRY". Which principles push back, and when would you build it?
DRY is already satisfied: the VAT rate is defined in one place, Vat.PERCENT, and both totals read it. The engine would not remove any duplicated knowledge.
KISS argues against it, because it adds three interfaces, and the code to connect them, to express a single rule. YAGNI argues against it, because rounding and currency strategies support features that nobody has asked for.
Build it when a second real tax rule, rounding mode or currency arrives. By then you know which parts actually vary, and the design can be based on those real cases instead of a guess.
📚 Sources
- Andrew Hunt and David Thomas, The Pragmatic Programmer: From Journeyman to Master (Addison-Wesley, 1999), ch. 2, "The Evils of Duplication" — the DRY definition.
- Sandi Metz, "The Wrong Abstraction" (20 January 2016) — https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
- Martin Fowler, Refactoring: Improving the Design of Existing Code (Addison-Wesley, 1999), ch. 2, "The Rule of Three" (credited to Don Roberts).
- Tim Peters, PEP 20 – The Zen of Python — https://peps.python.org/pep-0020/
- The Java Language Specification, Java SE 21, §15.17.3 "Remainder Operator
%" — https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.17.3 - The Python Language Reference, §6.7 "Binary arithmetic operations" (the
%result has the sign of its second operand) — https://docs.python.org/3/reference/expressions.html#binary-arithmetic-operations - Ron Jeffries, "You're NOT gonna need it!" — https://ronjeffries.com/xprog/articles/practices/pracnotneed/
- Martin Fowler, "Yagni" (26 May 2015) — https://martinfowler.com/bliki/Yagni.html
🧪 Predict, then check.
- In the first VAT program in section 1 (the one where the cart and the invoice disagree), edit
Cartto 30% andInvoiceto 25%. Predict both totals, then run it. - Predict
isOdd(-7)in Java andis_odd(-7)in Python. Then change the comparison so both printtrue. - Delete
byCategory,byTagandsyncfrom the speculativeNotebookin section 4. Predict whether the output changes, then run it.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.