DRY, KISS, YAGNI and composition

Four short rules that keep a design small and easy to change, and how to spot code that breaks them.

Pattern: Foundations20 min read
Quick recall2 questions
  1. 01What does low-level design decide?

    Which classes exist, what each one does, and how they talk to each other.

  2. 02Name the four pillars of object-oriented programming.

    Encapsulation, abstraction, inheritance and polymorphism.

A food delivery app grows one feature request at a time. After a year, a small change touches ten files, and nobody is sure which ones.

Four short rules keep that from happening: DRY, KISS, YAGNI, and composition over inheritance. Each one comes with a smell you can spot in code and a one-line test.

DRY: don't repeat yourself

The app shows a delivery fee on the cart page and charges it at checkout. The rule was "free delivery from ₹199". Then the business moved it to ₹299.

// Before: the same rule lives in two places
int cartFee(int total) {
    return total >= 299 ? 0 : 40;   // updated
}
int checkoutFee(int total) {
    return total >= 199 ? 0 : 40;   // forgotten
}

// After: the rule has one home
int deliveryFee(int total) {
    return total >= 299 ? 0 : 40;
}

For a ₹250 order, the cart shows a ₹40 fee and checkout charges nothing. Two copies of one rule drifted apart.

  • The rule: every piece of knowledge has one home in the code. The cart and checkout both call deliveryFee.
  • The test: "If this rule changes, how many places must I edit?" More than one means DRY is broken.

DRY is about knowledge, not about lines that look alike. Two functions with similar code but different reasons to change can stay apart.

KISS: keep it simple

The business asks for one discount: orders over ₹500 get 10% off. One developer builds a small rule engine for it.

  1. Too cleverone rule, five parts
  2. -A Rule interface and a RuleEngine
  3. -Conditions read from a JSON file
  4. -A factory that builds the rules
  5. -Nobody can follow it in one read
  6. Simpleone rule, one method
  7. +discount(total) with one if
  8. +Over ₹500: 10% off
  9. +A new teammate reads it in seconds
  10. +Easy to test with two numbers
  • The rule: pick the simplest design that solves today's problem.
  • The test: "Could a new teammate explain this code after one read?" If not, look for a simpler version.

Simple is not the same as short. A clear if beats a clever one-line trick.

YAGNI: you aren't gonna need it

The app sends one kind of message: "your order is on the way". The first send method was built for every future anyone could imagine.

  1. Built for latersix parameters
  2. -send(to, text, priority, locale, sendAt)
  3. -A scheduler that no screen calls
  4. -Tests for paths no user takes
  5. -Every change must keep them all working
  6. Built for nowtwo parameters
  7. +send(to, text)
  8. +Add priority on the day it is asked for
  9. +Less code to read, test and fix
  10. +The real request shapes the design
  • The rule: do not build a feature until a real requirement asks for it.
  • The test: "Which requirement asks for this today?" If the answer is "maybe someday", leave it out.

YAGNI does not mean "ignore change". It means you keep the code easy to change, then make the change when it comes.

Composition over inheritance

This rule needs the most space, because inheritance is the first tool most graduates reach for.

says "a RetryEmail is an Email". says "a Notifier has a channel and has a log".

The problem: a class tree that explodes

The app sends notifications by email and SMS. Then two requests arrive: retry a failed message up to 3 times, and log every attempt. With inheritance, each mix becomes its own subclass.

abstract class Notifier {
    abstract boolean send(String to, String text);
}

class Email extends Notifier {
    boolean send(String to, String text) {
        System.out.println("email to " + to + ": " + text);
        return true;
    }
}
// Sms looks the same, with "sms to".

class RetryEmail extends Email {
    boolean send(String to, String text) {
        for (int i = 0; i < 3; i++)
            if (super.send(to, text)) return true;
        return false;
    }
}

class RetrySms extends Sms {
    boolean send(String to, String text) {
        for (int i = 0; i < 3; i++)   // the same loop, copied
            if (super.send(to, text)) return true;
        return false;
    }
}

RetrySms cannot reuse the loop in RetryEmail, because it must extend Sms. So the loop is copied, and DRY breaks too. Add logging and the tree looks like this:

Every mix of channel and feature is a new class. Read it top to bottom.

Count what each new request costs:

ChangeClasses with inheritanceClasses with composition
Email, Sms, retry, log95
Add an InApp channel136
Add a third feature257

With inheritance, every channel needs a class for every mix of features. The count multiplies. With composition, each new channel or feature is one new class.

The fix: build it from small parts

Split the job into small parts:

  • A channel delivers a message.
  • A log writes a line.
  • The Notifier holds one of each, plus a number of tries.
Notifier holds its parts. The channels share one interface.
interface Channel {
    boolean deliver(String to, String text);
}
class Email implements Channel {
    public boolean deliver(String to, String text) {
        System.out.println("email to " + to + ": " + text);
        return true;
    }
}
// Sms and InApp look the same.
class Notifier {
    private final Channel channel;
    private final int tries;
    private final Log log; // null: no log
    Notifier(Channel channel, int tries, Log log) {
        this.channel = channel;
        this.tries = tries;
        this.log = log;
    }

    boolean send(String to, String text) {
        for (int i = 1; i <= tries; i++) {
            boolean ok = channel.deliver(to, text);
            if (log != null) log.write("try " + i + (ok ? " sent" : " failed"));
            if (ok) return true;
        }
        return false;
    }
}
  • The retry loop lives in one place, Notifier.send, for every channel.
  • Log is a small class with one method, write(line).
  • Each old subclass is now a mix of parts, chosen when you build the object:
Notifier orderPlaced = new Notifier(new Email(), 1, null);   // was Email
Notifier riderHere = new Notifier(new Sms(), 3, new Log());  // was RetryLogSms

A new InApp channel is now one new class that implements Channel. No other file changes.

How it runs

The rider arrives, and the app sends "Rider is here" by SMS with 3 tries and a log. The first SMS fails.

OrderServicethe rider arrived
NotifierSms, 3 tries, Log
Smsa Channel
Logwrites lines
  1. OrderService to Notifier:
    send(to, text)
  2. Notifier to Sms:
    deliver()
  3. Sms to Notifier:
    false
  4. Notifier to Log:
    write(try 1)
  5. Notifier to Sms:
    deliver()
  6. Sms to Notifier:
    true
  7. Notifier to Log:
    write(try 2)
  8. Notifier to OrderService:
    true
Read it top to bottom. Notifier runs the loop; each part does one small job.

OrderService never knows about retries or logs. It calls send and gets true or false back.

When inheritance is still right

Composition is the default, not a ban. Inheritance fits when both of these are true:

  • The child really is a kind of the parent, and keeps every promise the parent makes.
  • The parent is stable, so a change to it will not break many children.

Implementing an interface, as Email implements Channel, is also fine. It shares a promise, not code.

Spot them in code

PrincipleThe smellThe fix
DRYone rule copied in two placesgive the rule one home
KISSa framework for one small rulethe plainest code that works
YAGNIparameters and features nobody usesdelete them; add on request
Compositiona subclass for every mixhold small parts, pick at runtime

These rules pull against each other at times. DRY can tempt you to build a shared helper too early, and YAGNI says wait. When in doubt, pick the version that is easiest to read.

Remember4 points
  1. 01

    DRY: every rule has one home. Test: how many places must I edit when it changes?

  2. 02

    KISS: the simplest design that solves today's problem. Test: can a new teammate follow it in one read?

  3. 03

    YAGNI: build only what a requirement asks for today. Leave "maybe someday" out.

  4. 04

    Composition over inheritance: when subclasses multiply for every mix, hold small parts instead. A new channel or feature becomes one new class.

Read more

Next: how to read and draw a class diagram, so you can sketch designs like the Notifier in an interview.