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.

Watch it evolve1 / 9
The problem

Three classes need the tax

Cart totals an order, Receipt emails it, and InvoiceExport writes it for the accountant. Each applies 8% itself.

80
Start
Stage 1
Stage 2
Stage 3
Final

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.

The first versionJava
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));
    }
}
*«record»Item− name: String− cents: intCart− items: List<Item>~ subtotalCents(): int~ totalCents(): intReceipt~ text(Cart): String− money(int): StringInvoiceExport~ line(String, Cart): String

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:

The copy that was missedJava
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

Cart.totalCents()

Changed

Charges 9%

Receipt.text()

Changed

Shows 9%

InvoiceExport.line()

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?

The general versionJava
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.

Java
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);
    }
}
*«record»Item− name: String− cents: intTax~ RATE_PERCENT: int~ on(int): intCart− items: List<Item>~ subtotalCents(): int~ totalCents(): intReceipt~ text(Cart): String− money(int): StringInvoiceExport~ line(String, Cart): String
The rate, in one lineJava
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
Copied
53
One home
62
General
93

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.

*«record»Item− name: String− cents: intTax~ RATE_PERCENT: int~ on(int): intCart− items: List<Item>~ subtotalCents(): int~ totalCents(): intReceipt~ text(Cart): String− money(int): StringInvoiceExport~ line(String, Cart): String

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 boolean parameter 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 constexpr constant in a namespace

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

1. Which pair is real duplication that should be merged?
2. The general version has one region and one rule. What does YAGNI say about it?
3. A helper merges two similar functions and gains a boolean forExport parameter that changes half its body. What happened?
4. When does designing for a future need stop being speculative?

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.