Open-closed principle
Add behaviour by adding code, not by editing what works.
01The intuition
The problem it solves
A shop program gives discounts. The first version is one function with a branch per discount type.
def discount(cart, kind: str) -> int:
total = cart.total()
if kind == "student":
return total // 10
if kind == "festival":
return 50 if total > 500 else 0
if kind == "bulk":
return 20 * (cart.items // 10)
return 0
Every week marketing invents a new discount, and every week someone opens this function, adds a branch and hopes they did not break the other three. The tests for "student" have to be re-run every time "bulk" changes, because they share a function. Worse, a teammate in another file wants to add a "birthday" discount and cannot without editing code they do not own.
The open-closed principle says code should be open for extension, you can add new behaviour, but closed for modification, you do not edit the working code to do it. It sounds impossible until you see the mechanism: the thing that varies becomes an object, and the stable code only talks to the object's interface.
Build it one piece at a time
Step 1: name the thing that varies. Each branch is a rule that takes a cart and returns an amount. That is an interface.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def amount(self, cart) -> int: ...
Step 2: move each branch into its own class. The code inside each branch does not change; only its address does.
class StudentDiscount(Discount):
def amount(self, cart) -> int:
return cart.total() // 10
class FestivalDiscount(Discount):
def amount(self, cart) -> int:
return 50 if cart.total() > 500 else 0
class BulkDiscount(Discount):
def amount(self, cart) -> int:
return 20 * (cart.items // 10)
Step 3: make the stable code depend only on the interface. The checkout applies whatever discounts it is given. It no longer contains the word "student".
class Checkout:
def __init__(self, discounts: list[Discount]):
self._discounts = discounts
def payable(self, cart) -> int:
off = sum(d.amount(cart) for d in self._discounts)
return max(0, cart.total() - off)
Step 4: extend without editing. The teammate adds a birthday discount in their own file and registers it. Checkout and the three existing discounts are untouched and their tests still pass unchanged.
class BirthdayDiscount(Discount):
def __init__(self, today, member): self.today, self.member = today, member
def amount(self, cart) -> int:
return 100 if self.member.birthday == self.today else 0
checkout = Checkout([StudentDiscount(), FestivalDiscount(), BulkDiscount(), BirthdayDiscount(today, me)])
| Step | Files edited to add a discount | Tests re-run for old discounts | Can another team add one |
|---|---|---|---|
| Start | the discount function | all | no |
| 2 | a new class plus the function | all | no |
| 3 and 4 | a new class plus one wiring line | none | yes |
Why it works
The stable code depends on a promise, amount(cart), and the promise does not change when a new implementation arrives. This is polymorphism used on purpose: the if-else ladder was the only place that had to know every kind, and once it is gone there is no such place. The invariant is "Checkout is correct for every Discount that keeps the contract", which includes discounts written next year.
Where it breaks: you cannot be closed to every kind of change, only to the ones you predicted. If marketing asks for discounts that depend on each other ("festival only if no student discount applied"), the simple list is the wrong seam and Checkout must change. The skill is choosing the axis that really varies, which the interviewer often tells you in the follow-up ("what if we add...").
The trade-off you must say out loud
Every seam is an interface, a few classes and a registration step, and a seam nobody uses is dead weight. Say which axis you made open and why: "discount rules change weekly, so they are objects; the checkout arithmetic has not changed in years, so it stays a method". The follow-up is "where is the registry", and the answer is either a list passed to the constructor, as here, or a lookup by name when rules are configured rather than coded.
Where it shows up in the 100 Google problems
- Design a cache manager with pluggable eviction and TTL: eviction policy is the open axis; the cache's get and put never change when FIFO joins LRU and LFU.
- Design an extensible spreadsheet aggregation API: a new aggregate implements init, accumulate and finalise; the range traversal is closed.
- Merge two configuration trees with per-node rules: merge rules are registered by path or type; the tree walker is never edited.
- Design a multi-elevator dispatcher: assignment strategies (nearest car, SCAN, least loaded) are swapped in; the dispatcher loop is closed.
- Design a rule and workflow engine: the whole point of the system is a closed engine with open conditions and actions.