On this page
Description
The Open/Closed Principle says code should be open for extension but closed for modification: when a new case comes along, you add code instead of editing code that already works. It's how plugin systems, payment providers and discount engines grow without every change re-opening (and re-testing) the core. This skill covers extension points, how to refactor a growing switch into them, and when not to bother.
Here's the kind of code it's about:
Java
double discount(Order order, String campaign) {
switch (campaign) {
case "SUMMER": return order.total() * 0.10;
case "BLACK_FRIDAY": return order.total() * 0.30;
case "LOYALTY": return Math.min(20, order.total() * 0.05);
default: return 0;
}
}
Python
def discount(order: Order, campaign: str) -> float:
match campaign:
case "SUMMER":
return order.total() * 0.10
case "BLACK_FRIDAY":
return order.total() * 0.30
case "LOYALTY":
return min(20, order.total() * 0.05)
case _:
return 0
Marketing launches a new campaign every month. Each one edits this method, re-tests every existing campaign, and risks breaking the ones that already work.
We'll go from simple to more technical:
- The principle, and the extension point that makes it work.
- Refactoring a
switchinto implementations and a registry, step by step. - Where you'll see it: patterns, plugins and middleware.
- Choosing which changes to close against - and which not to.
The principle
Software entities should be open for extension, but closed for modification.
Give the varying behavior an extension point - usually an interface - so new cases are new classes:
Java
interface DiscountRule {
boolean appliesTo(String campaign);
double discount(Order order);
}
class Checkout {
private final List<DiscountRule> rules;
Checkout(List<DiscountRule> rules) { this.rules = rules; }
double discount(Order order, String campaign) {
for (DiscountRule r : rules) if (r.appliesTo(campaign)) return r.discount(order);
return 0;
}
}
Python
from abc import ABC, abstractmethod
class DiscountRule(ABC):
@abstractmethod
def applies_to(self, campaign: str) -> bool: ...
@abstractmethod
def discount(self, order: Order) -> float: ...
class Checkout:
def __init__(self, rules: list[DiscountRule]):
self._rules = rules
def discount(self, order: Order, campaign: str) -> float:
for r in self._rules:
if r.applies_to(campaign):
return r.discount(order)
return 0
A new campaign is a new DiscountRule class added to the list. Checkout never changes. (In Python, when each rule is a single method, the extension point is often just a callable: a dict from campaign name to function works as well as a class per campaign.)
How to apply it, step by step
- Find the part that keeps changing - typically a
switchorifchain over "kinds". - Define an interface for what each kind does.
- Move each branch into its own implementation.
- Let the stable code receive the implementations (constructor, list, registry map).
- Adding a kind now means adding a class and registering it - no edits to tested code.
Where you'll see it
- Strategy, Decorator, Observer and Template Method are all OCP in practice.
- Plugin systems: IDE extensions,
java.util.ServiceLoader(Python: package entry points, loaded withimportlib.metadata.entry_points(), and pytest's plugin hooks), Spring picking up every bean that implements an interface. - Web frameworks let you add filters/middleware without editing the framework.
Which tool for which job?
| The variation | Extension point |
|---|---|
| New kinds of behavior keep arriving (campaigns, formats, providers) | an interface plus a registry of implementations |
| Each case is a single function | a map from key to function (lambda / callable) |
| Cases differ only in numbers or text | configuration data, not classes |
| Behavior added around existing behavior (logging, caching) | decorators or middleware |
| Third parties add behavior without touching your code | a plugin mechanism (ServiceLoader, Python entry points) |
| The set of kinds is fixed, but operations keep coming | a sealed type with exhaustive switches (closed on purpose) |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| An interface for something with one implementation and no second in sight | indirection without benefit | add it when the second variation arrives |
A factory with a growing switch | the edit just moved | a registry: adding means registering |
| Closing against the wrong axis | the next change doesn't fit the interface | close against changes you've actually seen |
| Overlapping rules in a first-match list | the result depends on registration order | disjoint conditions, or explicit priorities |
| A new class per case that differs only in a number | dozens of near-identical classes | put the numbers in configuration |
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. Your ReportExporter has if (format == PDF) ... else if (format == CSV) .... How would OCP restructure it?
Show answer
Introduce an Exporter interface with one implementation per format, and a registry mapping format -> exporter. A new format is a new class plus a registration; the exporting code doesn't change.
2. Is an interface with exactly one implementation always good OCP design?
Show answer
No. If no second implementation is realistically coming, it's indirection without benefit - add the extension point when variation actually appears.
3. Which other principle does OCP depend on so the stable code can stay unchanged?
Show answer
Dependency Inversion - the stable code must depend on the abstraction, not the concrete implementations.
Key takeaways
- Add behavior by adding code, not editing tested code.
- Put an interface where variation keeps happening; register new implementations.
- Close against the changes you actually expect - not every imaginable one.
- Strategy, Decorator, Observer and plugins are OCP in action.