Exercises — SOLID — Open - Closed Principle
Before we start, a picture of the whole idea we keep returning to:

The frozen wall (the interface) never changes. Every new feature is a new plug slotted in behind it. "Closed for modification" = the wall. "Open for extension" = the sockets.
Level 1 — Recognition
Goal: given code or a description, decide whether OCP is violated and name the exact smell.
Exercise 1.1
Look at this function. Does it violate OCP? Point to the exact line that will force a future edit.
def shipping_cost(order):
if order.country == "US":
return 5.0
elif order.country == "CA":
return 7.5
elif order.country == "UK":
return 9.0Recall Solution 1.1
Yes, it violates OCP. The smell is the ==if/elif chain that branches on a "type" field== (order.country). Every new country — say "DE" — forces you to reopen and edit shipping_cost, a working, tested function.
The exact tell: the branch condition is data (country) that can take new values you haven't listed yet. That is the "varying axis" the parent note warns about. New value ⇒ new edit ⇒ not closed for modification.
Exercise 1.2
Which of these two is the OCP-friendly one, and why?
# (A)
def area(shape):
if shape.kind == "circle": return 3.14159 * shape.r**2
elif shape.kind == "square": return shape.s**2
# (B)
class Shape(ABC):
@abstractmethod
def area(self): ...
def total(shapes): return sum(s.area() for s in shapes)Recall Solution 1.2
(B) is OCP-friendly. In (A), adding a "triangle" means editing area(). In (B), total() depends only on the ==Shape abstraction== — the frozen contract "every shape can compute area()". A new shape is a new class; total() never opens. This is exactly Worked Example 1 in the parent note.
Level 2 — Application
Goal: mechanically apply the fix — hide the varying part behind an interface, add new implementations.
Exercise 2.1
Refactor Exercise 1.1's shipping_cost to follow OCP. Then add a new country "DE" (cost 8.0) without editing any existing class.
Recall Solution 2.1
Hide the varying part (the per-country cost) behind an interface:
from abc import ABC, abstractmethod
class ShippingRate(ABC):
@abstractmethod
def cost(self): ...
class USRate(ShippingRate):
def cost(self): return 5.0
class CARate(ShippingRate):
def cost(self): return 7.5
class UKRate(ShippingRate):
def cost(self): return 9.0
def shipping_cost(rate: ShippingRate): # depends only on the abstraction
return rate.cost()Adding Germany — no existing file touched:
class DERate(ShippingRate):
def cost(self): return 8.0Now shipping_cost(DERate()) returns 8.0. The three original classes and shipping_cost stayed frozen. That is OCP done: add, don't edit.
Exercise 2.2
Given the Discount interface from the parent note, write a BlackFriday discount of 70% off and compute the price of a $200 item after it.
class Discount(ABC):
@abstractmethod
def apply(self, price): ...Recall Solution 2.2
class BlackFriday(Discount):
def apply(self, price):
return price * (1 - 0.70) # 70% offPrice after: 200 \times (1 - 0.70) = 200 \times 0.30 = \60$.
The checkout function and every other discount class are untouched — this is the Strategy Pattern realization of OCP.
Level 3 — Analysis
Goal: reason about when to apply OCP, why it fails, and diagnose subtle cases.
Exercise 3.1
A junior wraps every class in an interface "to be safe," including a Logger that has exactly one implementation and has never changed in 3 years. Is this good OCP? Explain.
Recall Solution 3.1
No — this is over-engineering, the second parent-note mistake. OCP says add abstraction at axes that have actually varied (predicted variation). The Logger has one implementation and zero history of change — there is no varying axis. Wrapping it adds indirection (harder to read, more files) with no payoff. OCP is speculative-generality bait: the goal is stability where change happens, not interfaces everywhere.
Rule: abstraction earns its cost only along an axis with a track record (or strong forecast) of change.
Exercise 3.2
You have this if/else with two branches that has not grown in a year. A teammate demands you refactor it to polymorphism now. Using the Rule of Three, what do you do?
Recall Solution 3.2
Do not refactor yet. The Rule of Three says: tolerate the first if/else, add the second, and refactor to polymorphism on the third variation. At two stable branches, the abstraction's upfront cost is not yet justified. Refactoring too early is premature abstraction — the opposite failure of adding one more if too late.
Balanced answer: watch the axis; the moment a third variation arrives, do the Refactoring — Replace Conditional with Polymorphism. Not before, not much after.
Exercise 3.3
Why does OCP depend on SOLID — Liskov Substitution Principle? Give a one-line failure scenario if LSP is broken.
Recall Solution 3.3
OCP works by letting total(shapes) (or checkout, etc.) call the abstraction and trust any implementation slotted behind it. That trust is LSP: subtypes must be safely substitutable. Failure scenario: you add SecretShape(Shape) whose area() raises an exception or returns a negative number — now total(), which was "closed and trusted," silently breaks. So OCP's promise ("old code stays trusted") only holds if every new plug obeys the contract. That contract-honoring is LSP.
Level 4 — Synthesis
Goal: design a small extensible system from scratch, combining OCP with sibling principles.
Exercise 4.1
Design a notification system that today sends Email and SMS, and tomorrow may add Push and Slack — all without editing existing senders or the dispatch core. Sketch the classes and the dispatch function.
Recall Solution 4.1
Isolate the varying axis (how a message is delivered) behind one interface:
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, msg): ...
class EmailNotifier(Notifier):
def send(self, msg): return f"EMAIL: {msg}"
class SMSNotifier(Notifier):
def send(self, msg): return f"SMS: {msg}"
def dispatch(msg, notifiers): # depends only on the Notifier contract
return [n.send(msg) for n in notifiers]Tomorrow — add Push and Slack, zero edits above:
class PushNotifier(Notifier):
def send(self, msg): return f"PUSH: {msg}"
class SlackNotifier(Notifier):
def send(self, msg): return f"SLACK: {msg}"dispatch("hi", [EmailNotifier(), PushNotifier()]) → ["EMAIL: hi", "PUSH: hi"].
Principle stack: the varying axis is one delivery channel = one responsibility (SOLID — Single Responsibility Principle); dispatch depends on the abstraction not concretes (SOLID — Dependency Inversion Principle); new channels plug in safely (SOLID — Liskov Substitution Principle); the whole shape is the Strategy Pattern over Polymorphism.
Exercise 4.2
Extend Worked Example 1: add a Triangle(base, height) and compute the total area of a Circle(r=2), Rectangle(3×4), and Triangle(6×2) using the unchanged AreaCalculator.total.
Recall Solution 4.2
class Triangle(Shape):
def __init__(self, base, height):
self.base, self.height = base, height
def area(self): return 0.5 * self.base * self.heightAreas:
- Circle:
- Rectangle:
- Triangle:
Total .
AreaCalculator.total was not opened — it just summed s.area() over the three objects.

Level 5 — Mastery
Goal: judge trade-offs, defend a decision, and handle the limits of the principle.
Exercise 5.1
Your abstraction was wrong: you built PaymentMethod.charge(amount), but a new "gift card" needs charge(amount, card_pin) — an extra argument the interface never anticipated. To add it, you must change the interface signature, which touches every existing implementation. Is this an OCP failure? What is the deeper lesson?
Recall Solution 5.1
This is an OCP failure at the design level, not the coding level. OCP keeps you closed to modification only along the axis your abstraction predicted. Here the real varying axis included extra data per method, which your interface never modeled — so a new requirement pierces the frozen contract and forces edits everywhere.
Deeper lesson: OCP protects you only if the abstraction captures the true axis of change. A wrong abstraction is worse than none, because it feels safe while quietly making change harder. The senior move: model the varying data too (e.g. charge(context) where context is an extensible object), or accept that the first design is a hypothesis and be ready to re-abstract when reality contradicts it. OCP is not "never edit" — it is "well-factored modules shouldn't need editing for anticipated variation."
Exercise 5.2
A teammate says: "OCP means we can never edit this class again — it's closed." A critical bug is found inside it. Who is right, and what does 'closed' actually mean?
Recall Solution 5.2
The teammate is wrong. "Closed for modification" targets adding new behavior/features, not banning bug fixes. Fixing a bug requires editing code — OCP never forbids that (parent note, third mistake). Read the principle as: new behavior should not require modifying existing well-factored modules. A bug fix is correcting existing behavior, a fundamentally different act from bolting on a new feature.
Exercise 5.3 (capstone judgement)
You must choose between (A) one report(format) function with a switch on "pdf" | "csv" | "html", and (B) a ReportFormatter interface with three classes. There are exactly three formats and product has told you no more will ever be added. Which do you pick, and defend it against a purist who insists on (B)?
Recall Solution 5.3
Pick (A), the switch — with eyes open. OCP's payoff is future additive change. If the axis is genuinely frozen (no more formats, ever), the abstraction's cost (three files, indirection) buys nothing — there is no future extension to protect. The Rule-of-Three's whole point is that abstraction is justified by repeated real variation, and here variation has been declared dead. Defense against the purist: OCP is a means (stability under change), not an end. Applying it where change won't happen is the over-engineering the parent note warns against — you'd be paying premium for insurance on a car you'll never drive. Caveat / honesty: "never" from product is a risky bet; if there's any real chance of a fourth format, (B) becomes correct. Mastery is weighing that probability, not reciting the rule.
Recall One-line self-check before you leave
Ask of any class: "When the next requirement lands on this known axis of change, do I add a file or open a file?" Add ⇒ you're OCP-clean. Open ⇒ you have a varying axis begging for an abstraction.
Connections
- SOLID — Open - Closed Principle — the parent topic these exercises drill.
- SOLID — Single Responsibility Principle · SOLID — Liskov Substitution Principle · SOLID — Dependency Inversion Principle — the sibling principles that appear in L3–L5.
- Strategy Pattern · Template Method Pattern — patterns realizing the fixes.
- Polymorphism · Refactoring — Replace Conditional with Polymorphism — the mechanism and the move.