Low-level designCore9 min
DRY, KISS and YAGNI
Give each piece of knowledge one home, keep the design as simple as today's needs allow, and don't build for needs you only imagine. The three pull against each other, and the skill is knowing when duplication is the lesser evil.
Three classes need the tax
Cart totals an order, Receipt emails it, and InvoiceExport writes it for the accountant. Each applies 8% itself.
1 · The situation
A bakery's tax rate, in three places
A bakery takes orders online. Three classes need the sales tax: Cart adds it to the total, Receipt shows it on the customer's email, and InvoiceExport writes a line for the accountant's spreadsheet. Each was written by a different person on a different day, and each works out 8% tax, rounded to the nearest cent, in its own line of code.
import java.util.List;
record Item(String name, int cents) {}
class Cart {
private final List<Item> items;
Cart(List<Item> items) {
this.items = List.copyOf(items);
}
int subtotalCents() {
int sum = 0;
for (Item item : items) sum += item.cents();
return sum;
}
int totalCents() {
int subtotal = subtotalCents();
return subtotal + (subtotal * 8 + 50) / 100;
}
}
class Receipt {
String text(Cart cart) {
int tax = (cart.subtotalCents() * 8 + 50) / 100;
return "Subtotal " + money(cart.subtotalCents()) + ", tax " + money(tax)
+ ", total " + money(cart.totalCents());
}
private static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}
class InvoiceExport {
String line(String orderId, Cart cart) {
int subtotal = cart.subtotalCents();
int tax = (subtotal * 8 + 50) / 100;
return orderId + "," + subtotal + "," + tax + "," + (subtotal + tax);
}
}
public class Main {
public static void main(String[] args) {
Cart cart = new Cart(List.of(
new Item("Sourdough", 650),
new Item("Croissant", 375),
new Item("Croissant", 375)));
System.out.println("Receipt: " + new Receipt().text(cart));
System.out.println("Invoice: " + new InvoiceExport().line("A-1001", cart));
}
}The class diagram of this version, drawn from its code. The tax rule doesn't appear: it's hidden inside three methods.
Receipt: Subtotal $14.00, tax $1.12, total $15.12 Invoice: A-1001,1400,112,1512
2 · The change
The rate rises, and one copy is missed
The city raises sales tax to 9%. The developer on duty changes the two copies they know about, in Cart and Receipt. The export was written by someone else, months ago, for the accountant:
class InvoiceExport {
String line(String orderId, Cart cart) {
int subtotal = cart.subtotalCents();
int tax = (subtotal * 8 + 50) / 100;
return orderId + "," + subtotal + "," + tax + "," + (subtotal + tax);
}
}Receipt: Subtotal $14.00, tax $1.26, total $15.26 Invoice: A-1001,1400,112,1512
The customer pays $15.26; the accountant's spreadsheet records $15.12.
Nothing crashed. The customer was charged correctly, and the books are 1% short on every order, 14 cents on this one, until someone reconciles them. The code was duplicated, but that isn't the real problem; the knowledge was. Three places each held their own copy of one fact, the tax rule, and nothing kept them in step.
Where the rule lived | At the change | Result |
|---|---|---|
| Changed | Charges 9% |
| Changed | Shows 9% |
| Not known about | Records 8% |
The practice checks don't look for repeated code, and wouldn't help here: three short, slightly different expressions are hard to match, and the important question, whether they express the same fact, is one only a person can answer.
3 · The fix
One home for the rule, and nothing more
One proposed fix goes further than the problem. If the rule should live in one place, why not make it pluggable: an interface for tax rules, a registry of rules by region, and a region on every cart, ready for the day the bakery sells in other cities?
interface TaxRule {
int taxOn(int cents);
}
class FlatRate implements TaxRule {
private final int percent;
FlatRate(int percent) {
this.percent = percent;
}
public int taxOn(int cents) {
return (cents * percent + 50) / 100;
}
}
final class TaxRules {
private static final Map<String, TaxRule> BY_REGION = new HashMap<>();
private TaxRules() {}
static void register(String region, TaxRule rule) {
BY_REGION.put(region, rule);
}
static TaxRule forRegion(String region) {
TaxRule rule = BY_REGION.get(region);
if (rule == null) throw new IllegalStateException("no tax rule for " + region);
return rule;
}
}It works and prints the right numbers. It also has one rule, one region and one implementation, and every cart now carries a region that's always "home". That's three more types to read, a registry that can fail at run time if nobody registered a rule, and a parameter threaded through code that never varies. The bakery has no plan to sell elsewhere.
The simpler fix gives the rule one home and stops there:
Tax.on(cents) applies the rate, and RATE_PERCENT is the only place the number appears. In Java that needs a small class; Python uses a module-level function and constant, and C++ a namespace.
class Receipt {
String text(Cart cart) {
int tax = Tax.on(cart.subtotalCents());
return "Subtotal " + money(cart.subtotalCents()) + ", tax " + money(tax)
+ ", total " + money(cart.totalCents());
}
private static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}
class InvoiceExport {
String line(String orderId, Cart cart) {
int subtotal = cart.subtotalCents();
int tax = Tax.on(subtotal);
return orderId + "," + subtotal + "," + tax + "," + (subtotal + tax);
}
}import java.util.List;
record Item(String name, int cents) {}
final class Tax {
static final int RATE_PERCENT = 9;
private Tax() {}
static int on(int cents) {
return (cents * RATE_PERCENT + 50) / 100;
}
}
class Cart {
private final List<Item> items;
Cart(List<Item> items) {
this.items = List.copyOf(items);
}
int subtotalCents() {
int sum = 0;
for (Item item : items) sum += item.cents();
return sum;
}
int totalCents() {
return subtotalCents() + Tax.on(subtotalCents());
}
}
class Receipt {
String text(Cart cart) {
int tax = Tax.on(cart.subtotalCents());
return "Subtotal " + money(cart.subtotalCents()) + ", tax " + money(tax)
+ ", total " + money(cart.totalCents());
}
private static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
}
class InvoiceExport {
String line(String orderId, Cart cart) {
int subtotal = cart.subtotalCents();
int tax = Tax.on(subtotal);
return orderId + "," + subtotal + "," + tax + "," + (subtotal + tax);
}
}
public class Main {
public static void main(String[] args) {
Cart cart = new Cart(List.of(
new Item("Sourdough", 650),
new Item("Croissant", 375),
new Item("Croissant", 375)));
System.out.println("Receipt: " + new Receipt().text(cart));
System.out.println("Invoice: " + new InvoiceExport().line("A-1001", cart));
}
}Receipt: Subtotal $14.00, tax $1.26, total $15.26 Invoice: A-1001,1400,126,1526
Lines of Java in each version. One home costs nine lines over the copies and removes the bug; the general version costs thirty more for regions that don't exist.
4 · The principles
Three rules of thumb, and what each one really means
Principle | What it says | What it's often mistaken for |
|---|---|---|
DRY, don't repeat yourself | Each piece of knowledge has one authoritative home | Never write two similar lines of code |
KISS, keep it simple | Prefer the design with the fewest moving parts that meets the need | Prefer the shortest code, however clever |
YAGNI, you aren't gonna need it | Build a capability when it's needed, not when it's foreseen | Never design for change |
DRY is about knowledge, not text. A business rule, a constant, a schema or a format should be stated once, with everything else derived from it. It reaches beyond code: a database schema and the class that mirrors it, or an API's documentation and its handlers, are duplicated knowledge too, and generating one from the other is DRY.
After the fix: the tax rule is a type of its own, and every class that needs it depends on it.
YAGNI and KISS guard the other side. Every abstraction has a price: types to read, indirection to follow, configuration that can be wrong. A plug-in point earns that price when a second case arrives. Until then, it's speculative, and if the second case differs from the guess, it's in the way. These rules assume you can change the code safely later, which is what tests and small refactoring steps provide.
5 · When breaking it is fine
When duplication is the lesser evil
Code that looks the same isn't always the same knowledge. Keep it apart when:
- It changes for different reasons. On Single responsibility, a summary and a history file formatted distances the same way; merging them would have tied the summary's miles to the file's kilometers.
- You've seen only two cases. A common rule of thumb waits for the third copy before extracting it: two cases rarely show which parts truly vary.
- The shared version would need flags. A helper with a
booleanparameter that switches half its body is two functions pretending to be one; that's a wrong abstraction, and it costs more than the copies did.
Situation | Merge? | Why |
|---|---|---|
The same tax rule in three classes | Yes | One fact; a change must reach every copy |
Two formats that look alike but serve different readers | No | Different reasons to change |
Two similar loops in a test file | Usually not | Tests read better when each one is complete on its own |
A third near-copy of a calculation | Yes, now | Three cases show what varies |
And YAGNI is not a reason to ignore what you know. If the bakery had signed a contract to open in a second city next month, the region would be a real requirement, and designing for it would be ordinary design, not speculation.
6 · Getting it right
Find the fact, then choose the smallest home
- Name the knowledge. Before merging two pieces of code, say in one sentence what fact they share. If you can't, they probably don't.
- Choose the smallest home that works: a constant, then a function, then a class, then an interface with implementations. Move up only when the code asks for it.
- Derive instead of copying where tools allow: generate types from a schema, read limits from one configuration.
- Delete speculative code once it's clear the need won't come; it costs reading time on every visit.
- Keep refactoring cheap, with tests, so that deferring a design decision really is safe.
Language | The smallest home for one rule |
|---|---|
Java | A static method and a constant in a small final class; Java has no free functions |
C++ | A free function and a |
JavaScript | An exported function and constant in a module, or a frozen object |
Python | A module-level function and constant |
7 · Look-alikes
Neighbors of the three
Each of the three has a neighbor that sharpens it.
Idea | How it relates |
|---|---|
Single responsibility | Decides when similar code is really two pieces of knowledge |
Open/closed | Says to leave room for change; YAGNI says to wait until the change is real |
Encapsulate what varies | Puts the part you've seen change behind a boundary, once you've seen it |
Speculative generality | The code smell YAGNI prevents: hooks, parameters and types nobody uses |
Shotgun surgery | The smell DRY prevents: one change edits many places |
The tension with Open/closed is real and useful. Close a design against the change you've seen happen; for the one you only imagine, keep the code simple enough that adding the plug-in point later is a small refactoring.
8 · In practice
Pricing that's just complex enough
In the parking-lot practice problem, the rates change on match days, so a pricing interface with two schemes earns its place: both cases exist. A third pricing interface for electric chargers that the garage doesn't have would be speculative.
The checks don't measure duplication or speculation; those are judgment calls. Design the parking lot with exactly the abstractions its story needs.
Check yourself
4 questions
Takeaways
Remember this
- DRY is about knowledge, not text: each fact should have one authoritative home.
- Code that looks the same but changes for different reasons should stay apart.
- KISS: choose the design with the fewest moving parts that meets today's need.
- YAGNI: build a capability when it's needed, not when it's imagined.
- Choose the smallest home for a rule (a constant, a function, a class) and move up only when the code asks for it.