This page is a drill floor. The parent note Open/Closed Principle told you the idea: add new behaviour by adding new code, not by editing code that already works. Here we hit every kind of situation that idea can land you in — the clean cases, the messy cases, the "wait, is this even a real OCP problem?" cases — and we work each one fully.
Before anything else, two words we lean on constantly.
Two Python tools appear in almost every example, so let us pin them down before using them:
Everything below is about recognising which situation you're in and applying (or NOT applying) OCP correctly.
Think of every OCP problem as landing in one cell of a grid. Read it as a decision journey, not a static table: first you ask "is this even a new feature?" (if it's a bug, you just edit — Case F). If it is a new feature, you count how many times this thing has varied: zero-or-one variation means don't abstract yet (Case B); a third variation is the trigger to refactor (Case C); an already-abstracted axis just takes a new class (Case A). Along the way you watch for two traps — abstracting when nothing ever varied (Case E) and cramming two independent variations into one hierarchy (Case G) — plus the special shapes: the degenerate "nothing happens" input (Case D), the plain-English word problem where you must spot the axis yourself (Case H), and the exam trick that tests whether you judge OCP by output or by extensibility (Case I).
The table below is that same journey pinned down. Each column is a question about the situation; each row is one landing spot, ending in the example that drills it.
Case class
What it looks like
Right move
Worked in
A. Clean new type
Add a shape/rule; abstraction already exists
Add one new class
Ex 1
B. First-ever variation ("count = 1")
Only one behaviour today, no if yet
Do nothing — no abstraction
Ex 2
C. Rule of Three trigger ("count = 3")
Third elif branch appears
Refactor to polymorphism now
Ex 3
D. Degenerate / zero case
Empty list, "no discount", null behaviour
Model it as a real class (Null Object)
Ex 4
E. Over-engineering trap
Interface with one implementation "just in case"
Delete the abstraction
Ex 5
F. Not-a-feature (bug fix)
Existing rule computes wrong number
Edit the code — OCP allows it
Ex 6
G. Hidden second axis
New requirement varies two things at once
Two abstractions, not one giant one
Ex 7
H. Word problem (real world)
Plain-English "add a payment method"
Spot axis → new class
Ex 8
I. Exam twist
"Is this OCP-compliant?" trick code
Judge: does new behaviour edit old code?
Ex 9
The same decision journey, drawn as a flow you can trace top-to-bottom, appears in the Scenario map diagram at the very bottom of this page. We now walk every row A → I. Read the "Forecast" and guess before scrolling.
Forecast: How many existing files must you open to edit? (Guess: 0, 1, or 2?)
Write a new class only. Why this step? Because "which shape?" is already the frozen variation axis — the contract area() is set, so a new shape is a new implementation, never an edit.
class Triangle(Shape): def __init__(self, b, h): self.b, self.h = b, h def area(self): return 0.5 * self.b * self.h # triangle owns its own formula
Compute. Why? To prove AreaCalculator.total never changed yet still handles the newcomer.
Circle: 3.14159×22=12.56636
Rectangle: 5×3=15
Triangle: 21×4×6=12
Total =12.56636+15+12=39.56636
The figure shows each shape feeding its own area() into a calculator that never opens:
Verify:total only calls s.area() — it cannot know a Triangle exists, so it literally could not have been edited. Sum =39.56636. Answer to the forecast: 0 files edited. ✅
Forecast: Yes, add the interface? Or no, leave it?
Count the variations. Why this step? OCP is fuel for predicted variation — variation you have actually seen. Here the count is one. There is no if, no axis yet.
Decide: leave it. Why? An interface with one implementation is pure indirection — the [!mistake] "over-engineering" trap from the parent note. Abstraction has upfront cost; you pay it only when variation is real.
Verify: Sanity check against the Rule of Three (defined above) — you refactor on the third variation, not the first (3 > 1, so not yet). Adding the interface now would create a one-implementation interface, which the parent note explicitly calls "speculative-generality bait." ✅ Correct move: do nothing.
Forecast: After refactoring, what does checkout look like — how many ifs remain?
Recognise the trigger. Why this step? The Rule of Three (defined at the top of this page) says: tolerate one branch, tolerate two, but on the third variation, refactor. We're at three. The if/elif on a "kind" field is the classic OCP smell.
Extract each branch into its own class. Why? So each rule owns its own number, and checkout depends only on the contract.
Recompute to prove behaviour is preserved: premium 200×0.80=160; seasonal 200×0.90=180.
Verify: Old checkout(200,"premium") gave 160; new checkout(200, PremiumDiscount()) gives 160. Same for seasonal → 180. Zero ifs remain in checkout. This is the Refactoring — Replace Conditional with Polymorphism move, producing the Strategy Pattern. ✅
This example stays entirely inside the discount checkout world of Ex 3 for its first two steps, then — clearly flagged — pivots to a second degenerate input in the area-calculator world of Ex 1. Watch the "which world" tag on each step.
Forecast: Is if discount is None a harmless special case, or a fresh OCP violation?
(Discount world)Spot the sneaked-back conditional. Why this step? The whole point of Ex 3 was to delete the type-if. Special-casing None puts a branch back into checkout — the closed code re-opens.
(Discount world)Model "nothing" as a real class. Why? "No discount" is a valid discount rule — it just returns the price unchanged. This is the Null Object: the degenerate case becomes an ordinary implementation, so it flows through the same abstraction. (NoDiscount was already defined in Ex 3.)
checkout(150, NoDiscount()) # a real class, not None!
(Now switching to the AREA world of Ex 1)Empty cart / empty shape list. Why cover it, and why here? It is the same idea — a degenerate input — but on the other abstraction, so you see the pattern generalise. What is the total area of no shapes at all?
AreaCalculator().total([]) # sum of nothing = 0, no crash, no special-case if
Verify:Discount world:checkout(150, NoDiscount())=150 (identity preserved, no if). Area world:total([])=0 — Python's sum of an empty sequence is 0, so the degenerate case is safe without any special if. ✅ In both worlds the zero case is handled by a class or a natural identity, never a branch.
Forecast: Keep the interface (it's "extensible!") or collapse it?
Count implementations across the axis. Why this step? OCP earns its keep only on an axis that has actually varied. Here the axis "which tax?" has stayed at exactly one for years — the abstraction predicts a variation that never came.
Collapse to a plain function. Why? Indirection with one implementation is cost without benefit — the parent's over-engineering [!mistake]. If GST ever splits into multiple regimes, then re-introduce Discount-style classes (Rule of Three).
def gst_tax(amount): return amount * 0.18
Verify: Behaviour identical: 1000×0.18=180 before and after. We removed a class and an ABC with no loss of behaviour → the abstraction was dead weight. ✅ OCP-correct here means less structure.
Forecast: Must you add a newCorrectPremiumDiscount class to obey "closed for modification"?
Classify the change. Why this step? OCP's "closed for modification" targets adding new behaviour, not banning bug fixes (parent's third [!mistake]). The intended behaviour was always 20% off — the code is simply wrong, so fixing it is not "new behaviour."
Edit in place. Why? Creating a parallel "correct" class would leave the broken one live and double the confusion.
Verify: Buggy: 500×0.08=40 (customer pays almost nothing — clearly wrong). Fixed: 500×0.80=400 (a true 20% off 500 is 500−100=400). ✅ Editing here is correct and OCP-compliant — OCP never said "never touch code."
Forecast: With 3 channels and 4 formats, how many classes in the naive design vs the split design?
Notice there are TWO variation axes. Why this step? "Channel" and "format" vary independently. Cramming both into one class hierarchy multiplies them.
Count the naive explosion. Why? To feel the pain: one class per combination = 3×4=12 classes. Add a 4th channel → jump to 4×4=16.
Split into two abstractions AND compose them. Why? Each axis gets its own interface; notifycombines one Formatter with one Channel at call time, so the two axes never multiply. Here is the complete, runnable design — note the closing of every block:
from abc import ABC, abstractmethod# --- axis 1: how do we deliver? ---class Channel(ABC): @abstractmethod def send(self, text): ...class EmailChannel(Channel): def send(self, text): return f"EMAIL >> {text}"class SmsChannel(Channel): def send(self, text): return f"SMS >> {text}"# --- axis 2: how do we format? ---class Formatter(ABC): @abstractmethod def format(self, msg): ...class PlainFormatter(Formatter): def format(self, msg): return msgclass HtmlFormatter(Formatter): def format(self, msg): return f"<p>{msg}</p>"# --- composition: pick ONE of each, no combined classes ---def notify(msg, channel: Channel, formatter: Formatter): return channel.send(formatter.format(msg))# e.g. HTML message over SMS:notify("Hi", SmsChannel(), HtmlFormatter()) # -> "SMS >> <p>Hi</p>"
Now: 3 channels +4 formats =7 classes cover all 12 combinations, and notify mixes them freely.
The figure contrasts the multiplying hierarchy with the flat "two axes" design:
Verify: Naive =3×4=12 classes; split =3+4=7 classes, yet both cover all 12 pairings (7<12, and adding one channel costs +1 class, not +4). The composition notify("Hi", SmsChannel(), HtmlFormatter()) chains format then send, yielding "SMS >> <p>Hi</p>" — proof the two axes combine at call time. ✅ Two axes → two abstractions. (This is why SRP pairs with OCP: one reason to change per class.)
Forecast: What is the variation axis hiding in that sentence?
Extract the axis from the words. Why this step? "also accept UPI… maybe PayPal" = the phrase "also / another" is the tell — the axis is "which payment method?", and it has already grown past two → refactor.
Define the contract. Why? So process_payment depends on PaymentMethod, never on names.
Forecast: Same output number — does that mean both satisfy OCP?
State the test. Why this step? OCP is judged by one question only: to add a new variation (a new region), must I edit existing code? Not "does it give the right number."
Apply to X. Why? Adding region "AU" means editing the rates dict inside price_with_tax — a working, tested function reopens. X violates OCP (the dict is a disguised if/elif).
Apply to Y. Why? Adding "AU" means writing a new class AustraliaTax(TaxRule) — no existing file opens. Y satisfies OCP.
Compute both for India, 1000: X: 1000×(1+0.18)=1180. Y: 1000×1.18=1180.
The figure drives home that identical output and OCP verdict are unrelated:
Verify: Both give 1180 — proving that identical output does not imply OCP compliance. The distinguisher is how you'd add the next region: X edits, Y extends. ✅ Exam trap dodged.
Recall Quick self-test across the matrix
Which case: only one behaviour today, no if? ::: Case B — do nothing, no abstraction yet.
Which case: a third elif on a type field appears? ::: Case C — Rule of Three, refactor to polymorphism.
Which case: fixing a wrong number in an existing rule? ::: Case F — a bug fix; edit the code, OCP allows it.
An interface with one implementation for 3 years is which case? ::: Case E — over-engineering; collapse it.
Which case: a plain-English request where you must spot the axis yourself? ::: Case H — word problem; the "also/another" phrase names the axis.
Which case: two things vary independently — how many abstractions? ::: Case G — two, not one giant combined hierarchy.
Which case: "is this OCP-compliant?" trick where output looks fine? ::: Case I — judge by extensibility, not by output.
"No discount / empty cart" is handled by what, not by an if? ::: Case D — a class (Null Object) / sum([]) = 0.
The single question that decides OCP compliance ::: To add a new variation, must you EDIT existing code? If yes, it violates OCP.