Open to being extended, Closed to being modified
Section 8: SOLID Principles — Open/Closed (OCP)
OOP Principles Used: Abstraction, Polymorphism
You already met this informally back in Section 0 ("LLD exists to make change cheap") and saw it in action in the Polymorphism section (the process_order if/else chain vs. order.execute()). Now let's formalize it properly, on its own.
Start with an everyday picture: renovating vs. rebuilding
Imagine your house needs a new room. A good contractor builds an extension — new walls, connected to the existing structure, without touching your existing kitchen, bedrooms, or plumbing. A bad contractor knocks down a load-bearing wall to fit the new room in — now your whole existing structure is at risk, and you have to re-inspect everything that used to work.
Good software design works the same way: adding new functionality should feel like building an extension, not knocking down walls.
Technical: Software entities (classes, modules, functions) should be open for extension but closed for modification — new functionality should be added by writing new code, not by altering existing code that other parts of the system already depend on and trust.
Why bother — what breaks without it? (The exact if/else problem from Polymorphism, formalized)
Bad — violates OCP:
class TaxCalculator:
def calculate_tax(self, order_type, order):
if order_type == "domestic":
return order.price * order.quantity * 0.10
elif order_type == "foreign":
return order.price * order.quantity * 0.20
elif order_type == "crypto":
return order.price * order.quantity * 0.30
# every new order type = editing this method again
Every time a new tax category is introduced (say, "ETF" tax rules), you must reopen this method and add another elif. This method is never "finished" — it grows forever, and every edit risks breaking the domestic/foreign/crypto logic that already worked and was already tested.
Good — follows OCP, using Abstraction (Type 2) + Polymorphism together:
from abc import ABC, abstractmethod
class TaxStrategy(ABC):
@abstractmethod
def calculate(self, order):
pass
class DomesticTax(TaxStrategy):
def calculate(self, order):
return order.price * order.quantity * 0.10
class ForeignTax(TaxStrategy):
def calculate(self, order):
return order.price * order.quantity * 0.20
class CryptoTax(TaxStrategy):
def calculate(self, order):
return order.price * order.quantity * 0.30
Using it:
def process_tax(order, tax_strategy):
return tax_strategy.calculate(order)
process_tax(my_order, DomesticTax())
process_tax(my_order, ForeignTax())
class EtfTax(TaxStrategy):
def calculate(self, order):
return order.price * order.quantity * 0.05
Nothing else changes. DomesticTax, ForeignTax, CryptoTax, and process_tax() are never touched. You only ever add a new class — you never edit an existing, working one. That's OCP.
C++ version:
class TaxStrategy {
public:
virtual double calculate(const Order& order) = 0;
virtual ~TaxStrategy() {}
};
class DomesticTax : public TaxStrategy {
public:
double calculate(const Order& order) override {
return order.price * order.quantity * 0.10;
}
};
class ForeignTax : public TaxStrategy {
public:
double calculate(const Order& order) override {
return order.price * order.quantity * 0.20;
}
};
// Adding EtfTax later = new class only, nothing above is touched
class EtfTax : public TaxStrategy {
public:
double calculate(const Order& order) override {
return order.price * order.quantity * 0.05;
}
};
Addendum: What Does @abstractmethod Actually Add? (Clarified via Dog/Cat)
Step 1 — Same shape as the very first if/else problem
class TaxCalculator:
def calculate_tax(self, order_type, order):
if order_type == "domestic":
return order.price * order.quantity * 0.10
elif order_type == "foreign":
return order.price * order.quantity * 0.20
This is doing the exact same thing as the very first Polymorphism if/else example: checking a string, then picking a formula based on it.
def make_sound(animal_type):
if animal_type == "dog":
print("Woof")
elif animal_type == "cat":
print("Meow")
Same pattern: check a string → run different logic. The tax if/else and the animal if/else are the same shape of problem, just different words.
Step 2 — Map DomesticTax/ForeignTax directly onto Dog/Cat
Recall the Dog/Cat classes from Polymorphism:
class Dog:
def make_sound(self):
print("Woof")
class Cat:
def make_sound(self):
print("Meow")
Two separate classes, each with their own version of a method with the same name (make_sound).
The tax version is the identical pattern, just renamed:
class DomesticTax:
def calculate(self, order):
return order.price * order.quantity * 0.10
class ForeignTax:
def calculate(self, order):
return order.price * order.quantity * 0.20
Mapping:
| Dog/Cat | Tax |
|---|---|
Dog | DomesticTax |
Cat | ForeignTax |
make_sound() | calculate() |
Dog's make_sound returns "Woof" | DomesticTax's calculate returns 10% |
Cat's make_sound returns "Meow" | ForeignTax's calculate returns 20% |
DomesticTax and ForeignTax are just two classes, each with their own version of a calculate method — exactly like Dog and Cat each had their own version of make_sound.
Step 3 — What does @abstractmethod add, that Dog/Cat didn't have?
Notice: in Dog/Cat, there was no shared parent class. Dog and Cat were two totally separate, unrelated classes that just happened to both have a method called make_sound. Python let you put them in the same list and call .make_sound() on each — no ABC, no @abstractmethod, nothing extra needed.
So why does the tax version add TaxStrategy with @abstractmethod at all?
Answer: it doesn't add new behavior. It adds a safety promise.
class TaxStrategy(ABC):
@abstractmethod
def calculate(self, order):
pass
This line says: "Any class that claims to be a TaxStrategy MUST have a calculate method — or Python will refuse to let you create it."
Without that promise (plain classes, like Dog/Cat):
class DomesticTax:
def calculate(self, order):
return order.price * order.quantity * 0.10
class BrokenTax:
pass # forgot to write calculate()! Python won't stop you.
b = BrokenTax()
b.calculate(order) # crashes at runtime — AttributeError, only discovered when it's called
With the ABC + @abstractmethod promise:
class TaxStrategy(ABC):
@abstractmethod
def calculate(self, order):
pass
class BrokenTax(TaxStrategy):
pass # forgot calculate() again
b = BrokenTax() # ERROR IMMEDIATELY — Python refuses to even create the object
Without ABC/@abstractmethod, a missing method is a bug you discover later, possibly in production, when it's finally called. With it, Python catches the mistake the moment you try to create the broken class — much earlier, much safer.
So TaxStrategy is NOT doing the "different tax formulas" work — DomesticTax, ForeignTax, CryptoTax do that part, exactly like Dog/Cat did. TaxStrategy is just a contract-checker sitting above them, making sure nobody forgets to write calculate().
How OCP directly depends on the OOP pillars you already know
Technical: OCP is not a separate mechanism — it's an outcome achieved by combining Abstraction (Type 2: a shared interface like TaxStrategy) with Polymorphism (the caller invokes .calculate() without checking which subclass it has). Without those two pillars already in place, OCP would be impossible to achieve — you'd be forced back into if/else chains.
An important caution — OCP doesn't mean "never edit any class, ever"
OCP applies to code that's already stable and depended upon by others — not to every single line you ever write. If Order itself gains a genuinely new field everyone needs (like order_id), you edit Order — that's normal evolution, not an OCP violation. OCP specifically targets the pattern of "growing if/else or switch statements based on type," which is the exact trap that breaks as a system scales.
In your own words — how does TaxStrategy let you add EtfTax without touching any existing class, and which two OOP pillars make that possible?