SOLIDIntermediate 13 min Lesson 2 of 5

Open/Closed — Extend Without Editing

Add new behaviour by adding code, not by modifying code that already works. See the growing if/elif chain and the version that replaces it.

SOLID · Lesson 2 of 5
0/5 done(0%)

What is it? #

The Open/Closed Principle says code should be open for extension but closed for modification. New behaviour should arrive as new code, not as edits to code that already works and is already tested.

The reason is risk. Every edit to a working function can break something that passed yesterday. Adding a new class alongside it cannot.

The usual mechanism is an interface plus implementations. The stable part defines what is needed; each new behaviour is a new implementation.

This is not a rule to apply everywhere. It costs indirection, and it pays off where you can genuinely see new variants arriving — payment methods, export formats, notification channels, discount rules.

Think of it like this #

A power strip. Adding a new appliance means plugging it in, not rewiring the wall.

If every new device required an electrician to open the wall socket, every addition would risk the whole circuit. The socket is a stable interface; the devices extend the system.

Simple example #

A discount engine that started with one rule and now has six. Every new campaign means editing the same function, retesting everything, and hoping nothing broke.

Code #

PYTHON
# ---------- BAD: every new rule edits this function ----------
def calculate_discount(order, rule_name):
    if rule_name == "none":
        return 0
    elif rule_name == "flat_10_percent":
        return order.total * 0.10
    elif rule_name == "festive_500_off":
        return 500 if order.total > 3000 else 0
    elif rule_name == "loyalty":
        return order.total * 0.05 if order.customer.years > 2 else 0
    elif rule_name == "first_order":
        return min(order.total * 0.15, 300)
    # ... and a sixth, and a seventh
    raise ValueError("unknown rule")
TEXT
What is wrong

- Every new campaign modifies a function that already works.
- The function grows without limit and nobody dares delete a branch.
- A typo in one branch can break unrelated campaigns.
- All rules must live in one file, so two people cannot add campaigns cleanly.
- Testing one rule means importing everything.
PYTHON
# ---------- BETTER: add a class, do not edit the engine ----------
from abc import ABC, abstractmethod


class DiscountRule(ABC):
    @abstractmethod
    def applies_to(self, order) -> bool: ...

    @abstractmethod
    def amount(self, order) -> float: ...


class FlatPercentage(DiscountRule):
    def __init__(self, percent: float):
        self.percent = percent

    def applies_to(self, order): return True
    def amount(self, order): return round(order.total * self.percent / 100, 2)


class FestiveFlatOff(DiscountRule):
    def __init__(self, off: float, minimum: float):
        self.off, self.minimum = off, minimum

    def applies_to(self, order): return order.total > self.minimum
    def amount(self, order): return self.off


class LoyaltyDiscount(DiscountRule):
    def applies_to(self, order): return order.customer.years > 2
    def amount(self, order): return round(order.total * 0.05, 2)


class DiscountEngine:
    """Closed for modification: this class never changes when a rule is added."""

    def __init__(self, rules: list[DiscountRule]):
        self.rules = rules

    def best_discount(self, order) -> float:
        applicable = [r.amount(order) for r in self.rules if r.applies_to(order)]
        return max(applicable, default=0.0)


engine = DiscountEngine([
    FlatPercentage(10),
    FestiveFlatOff(off=500, minimum=3000),
    LoyaltyDiscount(),
])

# A new campaign next month: write one class, add it to the list.
class ReferralBonus(DiscountRule):
    def applies_to(self, order): return bool(order.customer.referred_by)
    def amount(self, order): return 250.0

How it works #

DiscountRule defines the contract: can this rule apply, and how much is it worth. That contract is the stable part.

Each rule is a separate class in its own right. FlatPercentage is configurable, so the same class serves a 10% and a 20% campaign without duplication.

DiscountEngine loops over whatever rules it was given. It contains no knowledge of any specific campaign, which is why it never needs editing.

ReferralBonus shows the payoff. Adding it requires no change to the engine, no change to existing rules and no retesting of them. The only change is where the rule list is assembled.

The rules are also independently testable. A test for FestiveFlatOff constructs one order and asserts one number, with nothing else imported.

The trade-off is honest: this is more classes and one more layer than the if/elif version. For two rules that will never grow, the conditional is fine. The principle earns its place when the list keeps growing — which discount rules, payment providers and export formats always do.

Real-world use #

Plugin architectures are this principle taken to its conclusion. The core defines interfaces; plugins provide implementations; the core never changes.

Payment integrations follow the same shape. Adding a new gateway means a new class implementing charge, refund and webhook handling, not editing the checkout flow.

Validation frameworks, serialisation formats, authentication backends and storage drivers all work this way in real libraries.

The signal to apply it is a conditional you keep coming back to edit. If a function has been modified for the fifth time to add another branch, that is the moment to extract an interface.

The counter-signal is equally important: a conditional with two stable branches does not need an abstraction. Applying OCP everywhere produces indirection that makes simple code hard to follow.

Common mistakes #

  • Predicting variation that never arrives and building interfaces for one implementation.
  • Leaving a hidden conditional inside the engine, which reopens it for modification.
  • Making the interface so specific that new implementations cannot fit it.
  • Registering rules in the engine itself, which defeats the point.
  • Applying it to stable code that has not changed in two years.

Practice #

Take a shipping cost function with branches for standard, express and international. Refactor it into a ShippingRule interface with three implementations plus a small selector. Then add a fourth rule for same-day delivery and confirm you modified no existing class.

Quick quiz

  1. 1. What does "closed for modification" mean?

  2. 2. What is the usual mechanism for achieving it?

  3. 3. What signals that a function should be refactored this way?

  4. 4. What is the cost of this principle?

  5. 5. What defeats the purpose of the engine being closed?

Summary

  • Prefer adding new code over editing code that already works.
  • An interface plus per-behaviour implementations is the standard approach.
  • The engine or caller should know nothing about specific variants.
  • Apply it where new variants genuinely keep arriving.
  • Do not abstract stable code with only one implementation.