Open for extension, closed for change

Add a new behaviour by adding a class, not by editing a long if-else that already works.

Pattern: SOLID20 min read
Quick recall3 questions
  1. 01What is a reason to change, in the single responsibility principle?

    A person or team who can ask for a change, such as finance for tax rules. A class should answer to only one.

  2. 02In a class diagram, what does ╌╌▷ mean?

    Implements: the class at the tail implements the interface at the head.

  3. 03What is polymorphism?

    One method call, many behaviours. The code that runs depends on the class of the object.

Your food delivery app launches a new offer almost every festival. If each offer means an edit to the class that computes discounts, each launch can break the offers that are already live.

This lesson shows how to add an offer without touching code that works.

The problem

Checkout asks one class for the money off. The class picks the rule with an if-else on the coupon code.

class DiscountCalculator {
    int discount(String code, int amount) {
        if (code.equals("NEW_USER")) {
            return amount * 20 / 100;                 // 20% off
        } else if (code.equals("WEEKEND")) {
            return 50;                                // flat Rs 50 off
        } else if (code.equals("PREMIUM")) {
            return Math.min(amount * 10 / 100, 100);  // 10%, at most Rs 100
        }
        return 0;
    }
}

For a Rs 800 order, the three codes give Rs 160, Rs 50 and Rs 80 off. Any other code falls through and gives 0.

The festive request

Marketing asks: "For Diwali, add a FESTIVE code: 25% off, at most Rs 150."

Today, discount("FESTIVE", 800) returns 0. To ship the offer, you must:

  1. Open DiscountCalculator, a class that is live and tested.
  2. Add a fourth branch to its if-else.
  3. Retest all four rules, because they share one method.
  4. Redeploy the class that every offer depends on.

Each campaign adds one more branch. After a year of offers the method has dozens of them, and one wrong edit breaks offers that customers use today.

The idea

Open for extension, closed for change: add new behaviour by adding code, not by editing code that already works.

Bertrand Meyer named the principle in 1988. In a class design, it works like this:

  • Give every rule the same shape: an .
  • Put each rule in its own small class.
  • Let Checkout talk only to the interface.
Checkout knows only the Discount interface. A new offer is one more line at the bottom.
  • Discount is the one shape every rule shares: take an amount, return the money off.
  • Each rule is a small class that implements Discount.
  • Checkout holds a Discount and calls apply. It never asks which rule it holds.
  • A new offer is a new class at the bottom. Nothing above it changes.

"Open" means the design accepts new rules. "Closed" means the classes that work today stay as they are.

The fix

Step 1: one interface, one class per rule

Each branch of the old if-else moves into its own class. The math is the same.

Step 2: Checkout takes any discount

Checkout.pay receives a Discount and subtracts what apply returns.

interface Discount {
    int apply(int amount);
}

class NewUserDiscount implements Discount {
    public int apply(int amount) { return amount * 20 / 100; }
}

class WeekendDiscount implements Discount {
    public int apply(int amount) { return 50; }
}

class PremiumDiscount implements Discount {
    public int apply(int amount) { return Math.min(amount * 10 / 100, 100); }
}

class Checkout {
    int pay(int amount, Discount discount) {
        return amount - discount.apply(amount);
    }
}

checkout.pay(800, new WeekendDiscount()) returns 750. Polymorphism picks the right apply, so Checkout needs no if-else at all.

The festive request again

Now the Diwali offer is one new class:

class FestiveDiscount implements Discount {
    public int apply(int amount) { return Math.min(amount * 25 / 100, 150); }
}

checkout.pay(800, new FestiveDiscount()) returns 650. 25% of 800 is 200, and the cap brings it down to 150. The three old rules still give 160, 50 and 80.

  1. If-else on the codea new offer edits
  2. -Open a live, tested class
  3. -Add a fourth branch
  4. -Retest all four rules
  5. -Redeploy every offer
  6. One class per rulea new offer adds
  7. +Write FestiveDiscount
  8. +Test only the new class
  9. +Old rules stay untouched
  10. +Checkout stays untouched

One place still turns the text "FESTIVE" into a FestiveDiscount object: a lookup table, or a . That place gains one line, but it holds no discount math. Factories come in the creational patterns chapter.

How it runs

Cart pageRs 800, FESTIVE
Checkoutknows only Discount
FestiveDiscount25%, max 150
  1. Cart page to Checkout:
    pay(800, d)
  2. Checkout to FestiveDiscount:
    apply(800)
  3. FestiveDiscount to Checkout:
    150
  4. Checkout to Cart page:
    650
Read it top to bottom. Here d is a FestiveDiscount; Checkout sees only a Discount.
  1. The cart page passes the amount and a discount object to Checkout.
  2. Checkout calls apply on it. It does not know the object is a FestiveDiscount.
  3. Checkout subtracts the reply and returns the amount to pay.

Swap d for a WeekendDiscount and the same three calls return 750.

When to use it

Signals in a requirement

  • An if-else or switch on a type or code that grows every sprint.
  • Words such as "more payment providers later", "new offers each festival", "rules per city".
  • The same switch in several files. One new case means edits in all of them.

When it is too much

  • Two cases that never change. A short if-else is clearer than an interface and two classes (YAGNI, from DRY, KISS, YAGNI and composition).
  • Guessing the future. Wait for the second or third real request, then pull out the interface. You will know its shape by then.

Refactor a growing if-else in four steps

  1. Write the interface from what every branch takes and returns.
  2. Move each branch into its own class, with the math unchanged.
  3. Make the caller accept the interface instead of a code.
  4. Delete the if-else and run the old tests. Each rule must give the same answer as before.

Open-closed or single responsibility?

Single responsibilityOpen-closed
The questionWho asks this class to change?Can I add a case without edits?
The smellone class, many teamsa growing if-else on a type
The fixsplit by reason to changeone interface, a class per case

The two often work together. The EmailSender from Single responsibility and WhatsAppSender could share a Sender interface. Then Checkout loops over a list of senders, and a new channel is one new class in that list.

Remember4 points
  1. 01

    Open for extension, closed for change: add a class, do not edit one that works.

  2. 02

    The tool is an interface plus one class per rule. The caller talks only to the interface.

  3. 03

    The festive offer became one new class. Checkout and the three old rules did not change.

  4. 04

    Do not add an interface for two cases that never change. Wait for real requests.

Read more

Next: Liskov substitution. A child class must keep every promise of its parent, and the square that breaks a rectangle's promise.