Prefer a shorter version?
Read the Dev.to article: https://dev.to/vemulakonda559/stop-editing-working-code-understanding-the-openclosed-principle-in-python-502b
Software is rarely built once and left unchanged forever.
Requirements evolve. New features are requested. Existing functionality needs to support additional use cases. As applications grow, developers are constantly challenged with balancing two competing goals:
- Adding new functionality.
- Preserving the stability of existing code.
While implementing new requirements is a natural part of software development, repeatedly modifying code that is already working can introduce unintended side effects. A small change made to support a new feature may accidentally break an existing one. As the codebase grows, such modifications become increasingly risky and expensive to test.
This raises an important design question:
How can we design software so that new behavior can be added with minimal impact on existing, stable code?
One of the most influential answers to this question is the Open/Closed Principle (OCP), one of the five SOLID design principles.
The Open/Closed Principle states:
Software entities should be open for extension but closed for modification.
At first glance, this may sound paradoxical. How can software be both open and closed at the same time?
The principle does not suggest that code should never be modified. Bug fixes, refactoring, and improvements are often necessary. Instead, it encourages developers to design systems so that future requirements can be accommodated primarily through extension rather than by repeatedly changing existing implementations.
Many discussions of OCP focus on inheritance and abstract base classes. While these are certainly useful techniques, Python offers several additional language features that naturally support extensibility. In many cases, inheritance is only one of several possible solutions.
In this article, we'll explore how Python's language features help us achieve the Open/Closed Principle through:
- Inheritance
- Polymorphism
- Duck Typing
- First-Class Functions
- Strategy Pattern
- Registration Decorators
functools.singledispatch
By the end, you'll see that the Open/Closed Principle is not tied to a specific design pattern or language construct. Instead, it is a design goal that can be achieved through multiple Pythonic approaches, each with its own strengths and trade-offs.
To understand the value of the Open/Closed Principle, let's first look at a common design pattern that many developers have written at some point.
Suppose we are building a file export utility.
Initially, the application only needs to support PDF and CSV exports.
def export(data, file_type):
if file_type == "pdf":
export_pdf(data)
elif file_type == "csv":
export_csv(data)The implementation is simple, readable, and completely reasonable for the current requirements.
A few weeks later, a new requirement arrives:
"Can we export data as Excel files?"
The function is modified:
def export(data, file_type):
if file_type == "pdf":
export_pdf(data)
elif file_type == "csv":
export_csv(data)
elif file_type == "xlsx":
export_xlsx(data)Soon after:
"Can we support JSON exports?"
elif file_type == "json":
export_json(data)Then:
"Can we support XML exports?"
elif file_type == "xml":
export_xml(data)Over time, the function continues to grow.
def export(data, file_type):
if file_type == "pdf":
...
elif file_type == "csv":
...
elif file_type == "xlsx":
...
elif file_type == "json":
...
elif file_type == "xml":
...
elif file_type == "html":
...
elif file_type == "yaml":
...The problem is not that the code stops working.
The problem is that every new export format requires modifying an existing function that was already tested and deployed.
This creates several challenges:
Whenever we modify existing code, there is a possibility of introducing unintended side effects.
A change intended to support a new format may accidentally affect existing behavior.
As the number of conditions grows, the function becomes harder to read and understand.
Developers must repeatedly revisit the same code location whenever a new requirement appears.
Every modification requires retesting existing functionality to ensure that previously supported formats still work correctly.
The function becomes a central point of change.
Every new requirement forces us to modify the same implementation.
A useful way to visualize the situation is:
New Requirement
↓
Modify Existing Code
↓
Retest Existing Code
↓
Risk Introducing Bugs
At a small scale, this may not seem problematic.
However, in large applications with dozens of developers and constantly evolving requirements, repeatedly modifying the same code can become expensive and error-prone.
This is precisely the problem that the Open/Closed Principle attempts to address.
Instead of asking:
"Which existing code should I modify?"
OCP encourages us to ask:
"How can I add this new behavior without changing code that already works?"
After seeing the growing if-elif chain problem, the Open/Closed Principle may sound like a reasonable solution.
However, its wording often causes confusion.
Software entities should be open for extension but closed for modification.
At first glance, this statement appears contradictory.
How can software be both open and closed at the same time?
The answer lies in understanding what is being opened and what is being closed.
A software component is considered open for extension when new behavior can be added without rewriting its existing implementation.
For example, suppose we have a reporting system that currently generates PDF reports.
If a future requirement asks for Excel reports, we should ideally be able to add that capability without rewriting the existing PDF implementation.
In other words:
New Requirement
↓
Add New Code
↓
Existing Code Remains Untouched
The system grows by extension.
A software component is considered closed for modification when its existing implementation does not need to be repeatedly changed whenever new requirements appear.
This does not mean the code can never be modified.
Bug fixes, security patches, performance improvements, and refactoring may still require changes.
Instead, the principle encourages us to avoid making existing, stable code the primary place where every new feature is implemented.
Consider the following thought process:
New Requirement
↓
Open Existing Function
↓
Add Another Condition
↓
Retest Everything
When this becomes a recurring pattern, the software is likely not following the Open/Closed Principle.
One of the most common misconceptions is:
Open/Closed Principle
=
Inheritance
Inheritance is merely one technique that can help achieve OCP.
The principle itself is much broader.
OCP is a design goal:
Design software so that future requirements can be implemented through extension rather than repeated modification.
Different programming languages provide different mechanisms to achieve this goal.
For example:
- Java often relies heavily on interfaces and inheritance.
- C++ frequently uses abstract base classes and virtual functions.
- Python provides several alternatives, including duck typing, first-class functions, decorators, and generic function registration.
Because of this flexibility, Python developers are not limited to a single approach.
When a new requirement arrives, there are generally two possible reactions.
New Requirement
↓
Which existing code should I edit?
New Requirement
↓
What new code can I add?
The Open/Closed Principle encourages the second mindset.
The goal is not to eliminate modifications completely.
The goal is to make software evolve primarily by adding new behavior rather than repeatedly changing stable, working code.
Unlike many textbook examples that focus solely on inheritance, Python offers multiple mechanisms that naturally support extensibility.
In the upcoming sections, we'll explore how the Open/Closed Principle can be achieved through:
- Inheritance
- Polymorphism
- Duck Typing
- First-Class Functions
- Strategy Pattern
- Registration Decorators
functools.singledispatch
Each of these approaches enables extensibility in a different way, but they all share the same objective:
Allow software to grow without repeatedly modifying existing implementations.
One of the most common ways to achieve the Open/Closed Principle is through inheritance.
The core idea is straightforward:
Instead of modifying an existing implementation whenever new behavior is required, define a common abstraction and extend it through new classes.
Let's revisit the file export example.
Suppose our application currently supports exporting data as PDF and CSV files.
A typical implementation might look like this:
def export(data, file_type):
if file_type == "pdf":
export_pdf(data)
elif file_type == "csv":
export_csv(data)Initially, this works perfectly.
However, requirements rarely remain static.
Soon, new export formats are requested:
- Excel (
xlsx) - JSON
- XML
- HTML
Following the current design, every new format requires modifying the same function.
def export(data, file_type):
if file_type == "pdf":
export_pdf(data)
elif file_type == "csv":
export_csv(data)
elif file_type == "json":
export_json(data)
elif file_type == "xml":
export_xml(data)As the number of supported formats grows, the function becomes a central point of change.
This is precisely the situation that OCP encourages us to avoid.
Rather than identifying exporters through string comparisons, we can define a common abstraction that represents the concept of an exporter.
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, data):
passThe Exporter class establishes a contract:
Every exporter must implement an
export()method.
The abstraction does not care whether the data is exported as PDF, CSV, JSON, or any future format.
PDF exporter:
class PDFExporter(Exporter):
def export(self, data):
print("Exporting data as PDF")CSV exporter:
class CSVExporter(Exporter):
def export(self, data):
print("Exporting data as CSV")Now we can write a generic function that operates on the abstraction.
def export_data(exporter, data):
exporter.export(data)Usage:
export_data(PDFExporter(), data)
export_data(CSVExporter(), data)Notice that the function no longer needs to know anything about specific file formats.
It simply works with objects that satisfy the Exporter contract.
Suppose a new requirement arrives:
"Support JSON exports."
Do we modify:
ExporterPDFExporterCSVExporterexport_data()
No.
Instead, we extend the system by creating a new class.
class JSONExporter(Exporter):
def export(self, data):
print("Exporting data as JSON")Usage:
export_data(JSONExporter(), data)The existing implementation remains untouched.
Let's compare both approaches.
New Export Format
↓
Modify export()
↓
Retest export()
New Export Format
↓
Create New Exporter Class
↓
Existing Code Unchanged
In the second approach, the system evolves through extension rather than modification.
This aligns directly with the Open/Closed Principle.
Inheritance alone is not the real reason this design works.
The key advantage comes from programming against an abstraction (Exporter) rather than concrete implementations (PDFExporter, CSVExporter, etc.).
The export_data() function doesn't care which exporter it receives.
It only cares that the object provides the expected behavior.
This brings us to the next mechanism that makes OCP possible:
Polymorphism, where different objects can be treated uniformly through a common interface while providing their own implementations.
In the previous section, we used inheritance to create multiple exporter classes:
class PDFExporter(Exporter):
...class CSVExporter(Exporter):
...class JSONExporter(Exporter):
...At first glance, it may appear that inheritance is what enabled the Open/Closed Principle.
However, inheritance alone is not the real reason the design works.
The real power comes from polymorphism.
The word polymorphism literally means:
"Many forms"
In object-oriented programming, polymorphism allows different objects to be treated through a common interface while each object provides its own implementation.
Consider our exporters:
class PDFExporter(Exporter):
def export(self, data):
print("Exporting data as PDF")class CSVExporter(Exporter):
def export(self, data):
print("Exporting data as CSV")class JSONExporter(Exporter):
def export(self, data):
print("Exporting data as JSON")Each class implements the same method:
export()but performs a different operation.
Our generic export function remains unchanged:
def export_data(exporter, data):
exporter.export(data)Usage:
export_data(PDFExporter(), data)
export_data(CSVExporter(), data)
export_data(JSONExporter(), data)Notice something important.
The function never asks:
if exporter == PDFExporter:or
if exporter == CSVExporter:or
if exporter == JSONExporter:Instead, it simply invokes:
exporter.export(data)The appropriate implementation is selected automatically based on the object's type.
This is polymorphism.
Imagine a new requirement arrives:
"Support XML exports."
Without polymorphism, we might be forced to modify existing code:
if file_type == "pdf":
...
elif file_type == "csv":
...
elif file_type == "json":
...
elif file_type == "xml":
...With polymorphism, we simply add another implementation:
class XMLExporter(Exporter):
def export(self, data):
print("Exporting data as XML")Usage:
export_data(XMLExporter(), data)No modifications are required to:
export_data()PDFExporterCSVExporterJSONExporter
The system grows through extension.
A useful way to think about polymorphism is:
Instead of asking:
What type of object is this?
ask:
What behavior does this object provide?
The export_data() function doesn't care whether it receives:
PDFExporterCSVExporterJSONExporterXMLExporter
It only cares that the object can perform:
export(data)This shift in thinking is what makes polymorphic designs easier to extend.
A common sign that polymorphism may be useful is the presence of growing conditional logic.
For example:
if file_type == "pdf":
...
elif file_type == "csv":
...
elif file_type == "json":
...
elif file_type == "xml":
...Every new format requires another branch.
Polymorphism replaces this pattern with:
exporter.export(data)As new exporters are introduced, the calling code remains unchanged.
This is one of the most common ways developers achieve the Open/Closed Principle in object-oriented systems.
Interestingly, the answer is no.
Polymorphism often appears alongside inheritance, but Python provides another mechanism that can achieve the same extensibility with even less structure:
Duck Typing
In the next section, we'll see how Python can achieve the Open/Closed Principle without abstract base classes, inheritance hierarchies, or even a shared parent class.
In the previous sections, we used an abstract base class (Exporter) along with inheritance and polymorphism to achieve the Open/Closed Principle.
While that approach works well, Python offers another powerful mechanism:
Duck Typing
One of the most famous sayings in the Python community is:
If it walks like a duck and quacks like a duck, then it is a duck.
In other words, Python often focuses on an object's behavior rather than its inheritance hierarchy.
Previously, we defined a common abstraction:
class Exporter(ABC):
@abstractmethod
def export(self, data):
passand then inherited from it:
class PDFExporter(Exporter):
...class CSVExporter(Exporter):
...But is inheritance really necessary?
Let's remove it entirely.
PDF exporter:
class PDFExporter:
def export(self, data):
print("Exporting data as PDF")CSV exporter:
class CSVExporter:
def export(self, data):
print("Exporting data as CSV")JSON exporter:
class JSONExporter:
def export(self, data):
print("Exporting data as JSON")Notice that none of these classes inherit from a common parent.
There is no abstract base class.
There is no interface.
There is no explicit relationship between them.
Our generic function still looks exactly the same:
def export_data(exporter, data):
exporter.export(data)Usage:
export_data(PDFExporter(), data)
export_data(CSVExporter(), data)
export_data(JSONExporter(), data)Everything works.
Why?
Because the function only expects one thing:
The object must provide an export() method.
Nothing more.
Suppose a new requirement arrives:
"Support XML exports."
Do we modify:
export_data()No.
Do we modify existing exporters?
No.
We simply add another class:
class XMLExporter:
def export(self, data):
print("Exporting data as XML")Usage:
export_data(XMLExporter(), data)The existing implementation remains untouched.
Once again, the system evolves through extension rather than modification.
This is exactly what the Open/Closed Principle encourages.
Many object-oriented designs focus heavily on inheritance.
The thought process often becomes:
Can this class inherit from another class?
Python frequently encourages a different perspective:
Does this object provide the required behavior?
In our example, the caller doesn't care whether the object:
- Inherits from
Exporter - Implements an interface
- Belongs to a particular class hierarchy
It only cares that the object supports:
export(data)This makes the design more flexible and often simpler.
Consider a third-party library that already provides an exporter:
class ExternalJSONExporter:
def export(self, data):
print("Third-party JSON export")Because Python relies on behavior rather than strict inheritance, we can use it immediately:
export_data(ExternalJSONExporter(), data)No adapters.
No interface implementation.
No inheritance requirements.
If the object provides the expected behavior, it works.
Duck typing naturally supports the Open/Closed Principle because new behavior can be introduced simply by creating objects that provide the expected methods.
The calling code remains unchanged.
New Export Format
↓
Create New Object
↓
Provide export()
↓
Existing Code Unchanged
This is one of the reasons Python developers often achieve extensibility with far less ceremony than is required in many statically typed languages.
So far, we've been extending behavior by creating new classes.
But Python treats functions as first-class objects.
What if we could extend behavior without creating classes at all?
In the next section, we'll explore how First-Class Functions provide another elegant way to achieve the Open/Closed Principle in Python.
So far, we've achieved the Open/Closed Principle by introducing new exporter classes.
However, Python offers another way to extend behavior:
Functions are first-class objects.
This means functions can be:
- Assigned to variables
- Stored in collections
- Passed as arguments
- Returned from other functions
Since functions represent behavior, they can also become extension points.
Suppose we implement our exporter using conditional logic.
def export(data, file_type):
if file_type == "pdf":
export_pdf(data)
elif file_type == "csv":
export_csv(data)
elif file_type == "json":
export_json(data)When a new format is requested, we modify the function.
elif file_type == "xml":
export_xml(data)The function becomes a central point of change.
Instead of deciding which export operation to perform, we can allow the caller to provide the behavior.
PDF exporter:
def export_pdf(data):
print("Exporting data as PDF")CSV exporter:
def export_csv(data):
print("Exporting data as CSV")JSON exporter:
def export_json(data):
print("Exporting data as JSON")Generic export function:
def export_data(exporter, data):
exporter(data)Usage:
export_data(export_pdf, data)
export_data(export_csv, data)
export_data(export_json, data)Notice that we are passing functions themselves, not calling them immediately.
A new requirement arrives:
"Support XML exports."
Do we modify:
export_data()No.
We simply add a new function.
def export_xml(data):
print("Exporting data as XML")Usage:
export_data(export_xml, data)The existing implementation remains untouched.
Again, the system evolves through extension rather than modification.
The generic function:
def export_data(exporter, data):
exporter(data)does not need to know:
- Whether the export is PDF
- Whether the export is CSV
- Whether the export is JSON
- Whether the export is XML
It simply executes the behavior it receives.
As new export formats are introduced, we add new functions instead of modifying existing logic.
New Export Format
↓
Create New Function
↓
Existing Code Unchanged
This aligns perfectly with the Open/Closed Principle.
At this point, we have seen two ways to achieve extensibility:
class PDFExporter:
...class CSVExporter:
...def export_pdf(data):
...def export_csv(data):
...Both approaches support OCP.
The choice depends on the complexity of the behavior.
If the exporter needs:
- State
- Configuration
- Multiple related methods
a class may be appropriate.
If the exporter is simply a single operation, a function is often sufficient.
Interestingly, this approach resembles a well-known design pattern:
The Strategy Pattern
Traditionally, strategy patterns are implemented using classes.
However, in Python, functions can often act as lightweight strategies because they can be passed around just like objects.
This allows us to achieve extensibility with less code and less complexity.
Passing functions directly works well when the behavior is simple.
But what if exporters require:
- Configuration
- Internal state
- Different algorithms selected at runtime
In such situations, organizing behavior into interchangeable objects can be beneficial.
This leads us to another common way of implementing the Open/Closed Principle:
The Strategy Pattern.
In the previous section, we used first-class functions to make our export system extensible.
That approach works well when the behavior is simple.
However, real-world export operations often require:
- Configuration
- Internal state
- Validation
- Multiple helper methods
As behavior becomes more complex, representing it as a class can make the design easier to manage.
This is where the Strategy Pattern becomes useful.
The Strategy Pattern encapsulates an algorithm or behavior inside an interchangeable object.
Instead of hardcoding behavior with conditional logic, the caller selects a strategy and delegates the work to it.
Consider the following implementation:
def export(data, file_type):
if file_type == "pdf":
print("Exporting PDF")
elif file_type == "csv":
print("Exporting CSV")
elif file_type == "json":
print("Exporting JSON")Every new export format requires modifying the same function.
New Format
↓
Modify export()
↓
Retest export()
This violates the spirit of the Open/Closed Principle.
Let's move each export algorithm into its own strategy object.
First, define a common contract.
from abc import ABC, abstractmethod
class ExportStrategy(ABC):
@abstractmethod
def export(self, data):
passPDF export strategy:
class PDFExportStrategy(ExportStrategy):
def export(self, data):
print("Exporting data as PDF")CSV export strategy:
class CSVExportStrategy(ExportStrategy):
def export(self, data):
print("Exporting data as CSV")JSON export strategy:
class JSONExportStrategy(ExportStrategy):
def export(self, data):
print("Exporting data as JSON")Each strategy contains its own implementation.
Now create a class that delegates the export operation to the selected strategy.
class Exporter:
def __init__(self, strategy):
self.strategy = strategy
def export(self, data):
self.strategy.export(data)Usage:
exporter = Exporter(PDFExportStrategy())
exporter.export(data)Switching strategies:
exporter = Exporter(CSVExportStrategy())
exporter.export(data)The exporter class remains unchanged.
Only the strategy changes.
Suppose a new requirement arrives:
"Support XML exports."
Do we modify:
ExporterNo.
Do we modify:
PDFExportStrategy
CSVExportStrategy
JSONExportStrategyNo.
We simply create another strategy.
class XMLExportStrategy(ExportStrategy):
def export(self, data):
print("Exporting data as XML")Usage:
exporter = Exporter(XMLExportStrategy())
exporter.export(data)The existing implementation remains untouched.
This is a textbook example of extending behavior without modifying stable code.
The exporter depends on an abstraction:
ExportStrategyrather than specific implementations.
As new strategies are introduced, the exporter does not need to change.
New Export Format
↓
Create New Strategy
↓
Existing Exporter Unchanged
This aligns perfectly with the Open/Closed Principle.
At this point, you might notice that the Strategy Pattern looks similar to the first-class function approach from the previous section.
And that's true.
These two approaches solve a similar problem.
export_data(export_pdf, data)Exporter(PDFExportStrategy())The difference is that strategy objects can easily maintain:
- State
- Configuration
- Multiple related methods
For simple behavior, functions are often sufficient.
For more sophisticated behavior, strategy objects provide better organization.
Even with strategies, we often end up maintaining some form of lookup code:
if format == "pdf":
strategy = PDFExportStrategy()
elif format == "csv":
strategy = CSVExportStrategy()Wouldn't it be nice if new exporters could register themselves automatically?
Python decorators provide exactly that capability.
In the next section, we'll explore how Registration Decorators can help us build extensible systems where new behavior is added simply by registering it.
In the previous section, we used the Strategy Pattern to make our export system extensible.
While this approach follows the Open/Closed Principle, we often encounter another problem:
How do we select the appropriate strategy?
A common solution is to maintain a lookup table.
strategies = {
"pdf": PDFExportStrategy(),
"csv": CSVExportStrategy(),
"json": JSONExportStrategy(),
}The export function can then select the appropriate strategy.
def export(data, file_type):
strategies[file_type].export(data)This works well.
However, every time a new export format is introduced, we must modify the registry.
strategies["xml"] = XMLExportStrategy()Although this modification is relatively small, there is still a central location that must be updated whenever new behavior is added.
Can we make the system even more extensible?
The answer is yes.
Let's start with an empty registry.
exporters = {}The registry will store mappings between file types and their corresponding exporter functions.
Now define a decorator responsible for registration.
def register(file_type):
def decorator(func):
exporters[file_type] = func
return func
return decoratorThe decorator performs two tasks:
- Registers the function.
- Returns the original function unchanged.
PDF exporter:
@register("pdf")
def export_pdf(data):
print("Exporting data as PDF")CSV exporter:
@register("csv")
def export_csv(data):
print("Exporting data as CSV")JSON exporter:
@register("json")
def export_json(data):
print("Exporting data as JSON")When Python executes these function definitions, each exporter automatically registers itself.
The registry now looks conceptually like:
{
"pdf": export_pdf,
"csv": export_csv,
"json": export_json
}The dispatcher becomes very simple.
def export(data, file_type):
exporter = exporters[file_type]
exporter(data)Usage:
export(data, "pdf")
export(data, "csv")
export(data, "json")Notice that the dispatcher does not contain any conditional logic.
Suppose a new requirement arrives:
"Support XML exports."
Do we modify:
export()No.
Do we modify:
register()No.
Do we modify:
export_pdf()
export_csv()
export_json()No.
We simply add another exporter.
@register("xml")
def export_xml(data):
print("Exporting data as XML")That's it.
The existing implementation remains untouched.
The system grows entirely through extension.
The registry acts as an extension point.
New behavior is introduced through registration rather than modification.
New Export Format
↓
Create New Function
↓
Register It
↓
Existing Code Unchanged
This is exactly the kind of extensibility encouraged by the Open/Closed Principle.
Most developers initially encounter decorators as tools for:
- Logging
- Timing
- Authentication
- Caching
However, decorators can also serve as registration mechanisms.
In this example:
@register("pdf")is effectively saying:
"Make this exporter available to the system."
This allows new functionality to be added without modifying the dispatcher or any previously registered exporters.
Many extensible systems follow a similar idea.
Instead of maintaining a growing collection of:
if ...
elif ...
elif ...they rely on registration.
Components register themselves, and the core system discovers them dynamically.
The result is a system that remains stable while continuously gaining new capabilities.
Interestingly, Python's standard library already provides a mechanism that follows a similar idea.
Instead of manually maintaining a registry, Python can automatically dispatch behavior based on the type of an argument.
This capability is provided by:
functools.singledispatchIn the next section, we'll explore how singledispatch allows us to extend generic functions without modifying their original implementation.
In the previous section, we built our own registration-based extension mechanism using decorators.
Interestingly, Python's standard library provides a similar capability through:
functools.singledispatchThe idea is simple:
Define a generic function once, and then extend its behavior by registering implementations for different types.
This aligns remarkably well with the Open/Closed Principle because new behavior can be added without modifying the original function.
Suppose we want to export different kinds of data.
A common implementation might look like this:
def export(data):
if isinstance(data, list):
print("Exporting list data")
elif isinstance(data, dict):
print("Exporting dictionary data")
elif isinstance(data, str):
print("Exporting string data")Initially, this works.
However, every new supported type requires modifying the same function.
elif isinstance(data, tuple):
print("Exporting tuple data")Over time, the function becomes another growing collection of conditions.
Let's define a generic exporter.
from functools import singledispatch
@singledispatch
def export(data):
raise TypeError(
f"Unsupported type: {type(data).__name__}"
)This function serves as the default implementation.
If no matching type is found, an exception is raised.
List exporter:
@export.register
def _(data: list):
print("Exporting list data")Dictionary exporter:
@export.register
def _(data: dict):
print("Exporting dictionary data")String exporter:
@export.register
def _(data: str):
print("Exporting string data")Usage:
export([1, 2, 3])
export({"name": "Rajesh"})
export("Hello")The appropriate implementation is selected automatically based on the argument's type.
A new requirement arrives:
"Support tuple exports."
Do we modify:
export()No.
We simply register another implementation.
@export.register
def _(data: tuple):
print("Exporting tuple data")Usage:
export((1, 2, 3))The original generic function remains untouched.
Notice the pattern.
The original function:
@singledispatch
def export(data):
...acts as an extension point.
Every new type-specific behavior is added through registration.
New Data Type
↓
Register New Implementation
↓
Existing Function Unchanged
This is a textbook example of extension without modification.
Throughout this article, we've explored several ways to achieve OCP.
class PDFExporter(Exporter):
...class PDFExporter:
def export(self, data):
...export_data(export_pdf, data)@register("pdf")@export.register
def _(data: list):
...Although the mechanisms differ, they all share the same objective:
Allow software to grow by adding new behavior rather than modifying existing implementations.
One of the most interesting aspects of singledispatch is that it demonstrates how OCP can be achieved without:
- Inheritance hierarchies
- Abstract base classes
- Strategy objects
Instead, extensibility is achieved through registration.
This reinforces an important idea:
OCP is not tied to a particular pattern or language construct.
It is a design goal that can be achieved through many different techniques.
Python simply gives us multiple ways to get there.
At this point, we've seen several mechanisms that support the Open/Closed Principle.
But does that mean every piece of code should be designed this way?
Not necessarily.
In the next section, we'll discuss when OCP is beneficial, when it becomes unnecessary complexity, and how to decide whether it is the right tool for a particular problem.
Throughout this article, we've explored several ways to achieve the Open/Closed Principle in Python:
- Inheritance
- Polymorphism
- Duck Typing
- First-Class Functions
- Strategy Pattern
- Registration Decorators
functools.singledispatch
At this point, it may be tempting to conclude that every piece of software should be designed around OCP.
In practice, that is not the case.
Like most design principles, the Open/Closed Principle provides the greatest value when applied to the right problems.
The challenge is identifying those situations.
One of the strongest indicators that OCP may be beneficial is when new behaviors are expected to appear regularly.
Consider a file export system.
Today it supports:
PDF
CSV
Tomorrow it may need:
JSON
XML
HTML
YAML
Parquet
In such cases, new functionality is likely to be added repeatedly over time.
Designing the system around extensibility can significantly reduce future modifications.
A common sign that a system is becoming difficult to extend is the appearance of large conditional structures.
For example:
if file_type == "pdf":
...
elif file_type == "csv":
...
elif file_type == "json":
...
elif file_type == "xml":
...
elif file_type == "html":
...Every new format requires another branch.
When this pattern appears repeatedly, it is often a signal that behavior should be separated into independent components.
OCP works particularly well when a system contains:
- A stable core
- Frequently changing behaviors
For example:
Export Workflow
↓
Stable
Export Formats
↓
Frequently Changing
The workflow remains unchanged.
Only the export implementations evolve.
This separation naturally supports extension without modification.
The Open/Closed Principle becomes increasingly valuable as more developers interact with the same codebase.
Imagine a shared utility used by multiple teams.
If every new feature requires modifying the same module:
Team A modifies it
Team B modifies it
Team C modifies it
the module quickly becomes a hotspot for changes.
By providing extension points instead, new behavior can often be added independently.
OCP is particularly useful when the application naturally supports plugins or extensions.
Examples include:
- File exporters
- Data parsers
- Report generators
- Notification handlers
- Payment processors
In these situations, new functionality is often introduced as separate components rather than modifications to existing implementations.
The registration decorator approach discussed earlier is especially useful for this style of architecture.
Short-lived scripts may not benefit much from OCP.
However, software that is expected to evolve for years often does.
The more frequently a system changes, the more valuable extensibility becomes.
A design that allows new behavior to be added with minimal disruption tends to be easier to maintain over time.
Before introducing abstractions, ask yourself:
Am I solving a problem that changes frequently, or am I preparing for a change that may never happen?
If the behavior is likely to evolve, OCP can provide significant benefits.
If not, simpler designs are often preferable.
The Open/Closed Principle is most effective when applied to areas of the system that are expected to grow.
The goal is not to eliminate modifications entirely.
The goal is to identify places where change is inevitable and design those areas so that new behavior can be added without repeatedly altering stable, existing code.
In the next section, we'll examine the opposite scenario:
What happens when the Open/Closed Principle is applied too aggressively, and how can it lead to unnecessary complexity?
The Open/Closed Principle is a valuable design guideline, but like any design principle, it can be overused.
A common mistake is assuming that every piece of code must be designed for future extensibility.
In reality, excessive abstraction can make code more difficult to understand than the problem it is trying to solve.
The goal of OCP is to manage change.
If there is little or no expectation of change, introducing complex extension mechanisms may provide little benefit.
Consider the following example:
if age >= 18:
print("Eligible")
else:
print("Not Eligible")This code is simple, clear, and unlikely to require extension.
Creating abstractions for such logic would add complexity without providing meaningful value.
For example:
class EligibilityStrategy:
...would almost certainly be unnecessary.
Sometimes a simple conditional is exactly the right solution.
Developers occasionally fall into the trap of designing for possibilities rather than actual requirements.
Suppose the current requirement is:
Export data as PDF.
An overengineered design might immediately introduce:
Exporter
PDFExporter
CSVExporter
JSONExporter
XMLExporter
ExportFactory
ExporterRegistryeven though only PDF exports are needed.
The result is a system that is significantly more complex than the problem it solves.
This is often described as:
Solving tomorrow's problem before tomorrow arrives.
Abstractions are not free.
Every abstraction introduces:
- Additional classes
- Additional functions
- Additional concepts
- Additional maintenance
Consider these two implementations.
def export_pdf(data):
print("Exporting PDF")class ExportStrategy:
...
class PDFExportStrategy:
...
class ExportFactory:
...
class ExportManager:
...Both may produce the same output.
The second approach may be justified if multiple export formats exist and are expected to grow.
Otherwise, the additional complexity may not provide sufficient benefit.
The Open/Closed Principle is most valuable when applied to areas of software that genuinely evolve.
Examples include:
- File formats
- Payment methods
- Notification channels
- Data parsers
- Authentication providers
These are areas where new behaviors commonly appear over time.
By contrast, stable business rules that rarely change often do not require sophisticated extension mechanisms.
A useful guideline is:
Start with the simplest design that satisfies current requirements.
As the software evolves and new requirements emerge, introduce abstractions when they become justified.
This approach aligns with another well-known engineering principle:
You Aren't Gonna Need It (YAGNI)
In other words:
Don't build extensibility
until extensibility becomes valuable.
The challenge is not choosing between:
No Abstraction
and
Maximum Abstraction
The real challenge is finding the appropriate level of abstraction for the problem being solved.
Too little abstraction may lead to constant modifications.
Too much abstraction may create unnecessary complexity.
Good software design typically lies somewhere in the middle.
The Open/Closed Principle should not be viewed as a rule that must always be followed.
Instead, think of it as a tool.
When a component is expected to grow and evolve, OCP can improve maintainability and reduce the risk of modifying stable code.
When the requirements are simple and unlikely to change, straightforward implementations are often the better choice.
The key is not to maximize abstraction.
The key is to apply abstraction where it provides real value.
At this point, we've explored both sides of the Open/Closed Principle:
- How Python provides multiple mechanisms for extensibility.
- When extensibility is beneficial.
- When extensibility becomes unnecessary complexity.
Before concluding, let's summarize these ideas into a practical checklist that can help determine whether OCP is appropriate for a particular problem.
Throughout this article, we've seen several ways to achieve the Open/Closed Principle in Python.
However, before introducing abstractions, strategies, decorators, or registration mechanisms, it's worth taking a moment to evaluate whether OCP is actually needed.
The following questions can serve as a practical guide.
Ask yourself:
Is this functionality expected to evolve over time?
For example:
- New export formats
- New payment methods
- New notification channels
- New data parsers
are all areas where change is common.
If new behaviors are likely to appear regularly, OCP may be beneficial.
Consider how new requirements are currently implemented.
Do they usually involve changes to the same function or class?
New Requirement
↓
Modify export()
↓
Modify export()
↓
Modify export()
If the same component is becoming a recurring point of change, it may be a candidate for extension-oriented design.
Large conditional structures often indicate that multiple behaviors are being managed in a single place.
For example:
if file_type == "pdf":
...
elif file_type == "csv":
...
elif file_type == "json":
...
elif file_type == "xml":
...
elif file_type == "html":
...If new branches are being added frequently, separating those behaviors may improve maintainability.
Not every variation requires an abstraction.
A useful question is:
Can this behavior be separated into an independent component?
If the answer is yes, mechanisms such as:
- Inheritance
- Duck Typing
- First-Class Functions
- Strategy Pattern
- Registration Decorators
functools.singledispatch
may help achieve extensibility.
The Open/Closed Principle often becomes more valuable as team size increases.
When multiple developers repeatedly modify the same component, the likelihood of merge conflicts and accidental regressions grows.
Providing extension points can reduce the need for developers to constantly modify shared code.
Perhaps the most important question is:
Am I addressing an actual requirement or an imagined future possibility?
Designing for likely change is useful.
Designing for every conceivable future scenario often leads to unnecessary complexity.
If the need for extensibility is uncertain, a simpler design may be the better choice.
Consider applying OCP when most of the following statements are true:
✅ New behaviors are expected to appear regularly.
✅ Existing code is being modified repeatedly.
✅ Conditional logic continues to grow.
✅ The behavior can be isolated cleanly.
✅ Long-term maintainability is important.
If most of these conditions are not present, a simpler implementation may be perfectly adequate.
The Open/Closed Principle is not about maximizing abstraction.
It is about identifying areas of software that are likely to change and designing those areas so that new behavior can be added with minimal impact on existing, stable code.
The Open/Closed Principle is one of the most influential software design principles because it addresses a common challenge in software development:
How can we add new behavior without repeatedly modifying existing, working code?
Throughout this article, we've seen that OCP is not a specific language feature, framework capability, or design pattern.
Instead:
The Open/Closed Principle is a design goal that encourages software to evolve through extension rather than modification.
Python provides multiple mechanisms that help achieve this goal.
Create new subclasses instead of modifying existing implementations.
New Requirement
↓
Create New Class
↓
Existing Code Unchanged
Treat different objects through a common interface while allowing each object to provide its own behavior.
Focus on behavior rather than inheritance hierarchies.
If an object provides
the required behavior,
it can participate.
Use functions as extension points by passing behavior as arguments.
Encapsulate algorithms into interchangeable objects that can be selected without modifying the caller.
Allow new functionality to register itself rather than requiring changes to a central dispatcher.
Extend generic functions through registration instead of modifying existing implementations.
The Open/Closed Principle becomes particularly valuable when:
- New behaviors appear regularly.
- Conditional logic continues to grow.
- Multiple developers work on the same codebase.
- Long-term maintainability is important.
In these situations, designing for extension can significantly reduce the need to modify stable code.
Not every piece of code needs:
- Abstract base classes
- Strategy objects
- Registries
- Decorators
Simple requirements often benefit from simple solutions.
A straightforward implementation is frequently preferable to unnecessary abstraction.
The goal is not to maximize extensibility everywhere, but to apply it where it provides meaningful value.
When a new requirement arrives, try asking:
Can I add new behavior without changing code that already works?
If the answer is yes, you're likely moving closer to the Open/Closed Principle.
And if Python has taught us anything throughout this article, it's that there is more than one way to achieve that goal.
Whether through inheritance, duck typing, functions, strategies, decorators, or generic function registration, the underlying idea remains the same:
Design software so that it grows by adding new behavior, not by repeatedly rewriting existing behavior.
Throughout this article, we've explored several ways to apply the Open/Closed Principle in Python.
Each technique has its strengths, and the best choice depends on the nature of the problem you're solving.
The following table summarizes when each approach is most useful.
| Technique | Best Used When | Example |
|---|---|---|
| Inheritance | Multiple implementations share a common abstraction | File Exporters |
| Polymorphism | Different objects should be used through a common interface | Exporter implementations |
| Duck Typing | Behavior matters more than class hierarchy | Any object with export() |
| First-Class Functions | Behavior is simple and stateless | Export callbacks |
| Strategy Pattern | Behavior requires configuration or internal state | Complex export algorithms |
| Registration Decorators | Building plugin-like systems | Automatic exporter registration |
functools.singledispatch |
Dispatching behavior based on argument types | Type-specific export logic |
Notice that all of these techniques support the same underlying goal:
Extend behavior without repeatedly modifying existing code.
The difference lies in the level of structure, flexibility, and complexity each approach introduces.
Choosing the right mechanism is often more important than choosing the most sophisticated one.
The Open/Closed Principle is often introduced as a simple statement:
Software entities should be open for extension but closed for modification.
However, as we've seen throughout this article, the principle is much more than a textbook definition.
At its core, OCP is about managing change.
As software evolves, new requirements inevitably arrive. The challenge is not whether change will happen, but how our code responds to that change.
A design that requires repeatedly modifying the same classes, functions, or modules can become increasingly difficult to maintain. In contrast, a design that provides clear extension points allows new behavior to be introduced with minimal impact on existing, stable code.
One of the strengths of Python is that it offers multiple ways to achieve this goal. We explored how extensibility can be implemented through:
- Inheritance
- Polymorphism
- Duck Typing
- First-Class Functions
- Strategy Pattern
- Registration Decorators
functools.singledispatch
Although these techniques differ in implementation, they all support the same fundamental idea:
Allow software to grow through extension rather than repeated modification.
At the same time, it's important to remember that OCP is a guideline, not a rule. Not every problem requires abstractions, strategies, or plugin-style architectures. Simplicity should always remain a primary design goal.
The next time you find yourself adding another branch to a growing if-elif chain or repeatedly modifying the same component to support new behavior, pause and ask:
Can this be extended instead?
That question is often the first step toward applying the Open/Closed Principle effectively.
In the end, good software design is not about eliminating change—it is about making change easier to accommodate.