Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 

Repository files navigation

Open for Extension, Closed for Modification: Applying the Open/Closed Principle in Python

Prefer a shorter version?

Read the Dev.to article: https://dev.to/vemulakonda559/stop-editing-working-code-understanding-the-openclosed-principle-in-python-502b


Introduction

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.

The Problem OCP Tries to Solve

The Growing if-elif Chain

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:

Increased Risk of Bugs

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.

Reduced Maintainability

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.

More Regression Testing

Every modification requires retesting existing functionality to ensure that previously supported formats still work correctly.

Violation of a Stable Design

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?"

What Does "Open for Extension, Closed for Modification" Really Mean?

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.

Open for Extension

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.


Closed for Modification

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.


OCP Is a Design Goal, Not a Language Feature

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.


Thinking in Terms of Extension

When a new requirement arrives, there are generally two possible reactions.

Modification-Oriented Thinking

New Requirement
       ↓
Which existing code should I edit?

Extension-Oriented Thinking

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.


Multiple Paths to OCP in Python

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.

1. OCP Through Inheritance

Extending Behavior Without Changing Existing Classes

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.


Introducing an Abstraction

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):
        pass

The 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.


Creating Concrete Exporters

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")

Working with the Abstraction

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.


Extending the System

Suppose a new requirement arrives:

"Support JSON exports."

Do we modify:

  • Exporter
  • PDFExporter
  • CSVExporter
  • export_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.


How Inheritance Helps Achieve OCP

Let's compare both approaches.

Condition-Based Design

New Export Format
        ↓
Modify export()
        ↓
Retest export()

Inheritance-Based Design

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.


Important Observation

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.

2. OCP Through Polymorphism

One Interface, Multiple Behaviors

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.


What Is 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.


The Caller Doesn't Need to Know the Concrete Type

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.


How Polymorphism Supports OCP

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()
  • PDFExporter
  • CSVExporter
  • JSONExporter

The system grows through extension.


Thinking in Terms of Behavior

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:

  • PDFExporter
  • CSVExporter
  • JSONExporter
  • XMLExporter

It only cares that the object can perform:

export(data)

This shift in thinking is what makes polymorphic designs easier to extend.


Polymorphism Reduces Conditional Logic

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.


Is Inheritance Required?

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.

3. OCP Through Duck Typing

No Inheritance Required

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.


Revisiting the Exporter Example

Previously, we defined a common abstraction:

class Exporter(ABC):

    @abstractmethod
    def export(self, data):
        pass

and then inherited from it:

class PDFExporter(Exporter):
    ...
class CSVExporter(Exporter):
    ...

But is inheritance really necessary?

Let's remove it entirely.


Exporters Without Inheritance

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.


The Caller Remains Unchanged

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.


Extension Without Modification

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.


Behavior Over Hierarchy

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.


A Practical Benefit

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 and OCP

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.


Can We Go Even Further?

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.

4. OCP Through First-Class Functions

Extending Behavior by Passing Functions

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.


A Condition-Based Approach

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.


Treating Behavior as an Argument

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.


Extending the System

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.


Why This Supports OCP

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.


Classes vs Functions

At this point, we have seen two ways to achieve extensibility:

Using Classes

class PDFExporter:
    ...
class CSVExporter:
    ...

Using Functions

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.


Functions as Lightweight Strategies

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.


The Next Step

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.

5. OCP Through the Strategy Pattern

Replacing Conditional Logic with Interchangeable Behaviors

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.


A Typical Condition-Based Design

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.


Encapsulating Export Behavior

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):
        pass

Concrete Strategies

PDF 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.


The Context

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.


Extending the System

Suppose a new requirement arrives:

"Support XML exports."

Do we modify:

Exporter

No.

Do we modify:

PDFExportStrategy
CSVExportStrategy
JSONExportStrategy

No.

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.


Why the Strategy Pattern Supports OCP

The exporter depends on an abstraction:

ExportStrategy

rather 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.


Functions vs Strategy Objects

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.

First-Class Functions

export_data(export_pdf, data)

Strategy Objects

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.


Can We Remove the Need for Central Registration?

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.

6. OCP Through Registration Decorators

Building Extensible Plugin Systems

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.


Creating a Registry

Let's start with an empty registry.

exporters = {}

The registry will store mappings between file types and their corresponding exporter functions.


Creating a Registration Decorator

Now define a decorator responsible for registration.

def register(file_type):

    def decorator(func):
        exporters[file_type] = func
        return func

    return decorator

The decorator performs two tasks:

  1. Registers the function.
  2. Returns the original function unchanged.

Registering Exporters

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
}

Building the Dispatcher

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.


Extending the System

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.


Why This Supports OCP

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.


Thinking of Decorators as Extension Mechanisms

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.


A Glimpse of Plugin Architectures

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.


Can Python Do This Automatically?

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.singledispatch

In the next section, we'll explore how singledispatch allows us to extend generic functions without modifying their original implementation.

7. OCP Through functools.singledispatch

Extending Generic Functions Without Modification

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.singledispatch

The 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.


A Traditional Approach

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.


Creating a Generic Function

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.


Registering Type-Specific Implementations

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.


Extending the System

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.


Why This Supports OCP

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.


Comparing the Approaches

Throughout this article, we've explored several ways to achieve OCP.

Inheritance

class PDFExporter(Exporter):
    ...

Duck Typing

class PDFExporter:

    def export(self, data):
        ...

First-Class Functions

export_data(export_pdf, data)

Registration Decorators

@register("pdf")

singledispatch

@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.


A Key Observation

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.


Choosing the Right Tool

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.

When OCP Makes Sense

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.


Frequent Requirement Changes

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.


Growing Conditional Logic

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.


Stable Core, Variable Behavior

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.


Shared Components Used by Multiple Developers

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.


Plugin-Like Architectures

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.


Long-Term Maintainability

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.


A Useful Rule of Thumb

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?

When OCP Becomes Overengineering

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.


Not Every if Statement Is a Problem

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.


Designing for Imaginary Requirements

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
ExporterRegistry

even 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.


The Cost of Abstraction

Abstractions are not free.

Every abstraction introduces:

  • Additional classes
  • Additional functions
  • Additional concepts
  • Additional maintenance

Consider these two implementations.

Simple Approach

def export_pdf(data):
    print("Exporting PDF")

Highly Abstracted Approach

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.


OCP Works Best at Real Change Points

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.


Favor Simplicity First

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.

Finding the Balance

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.


A Practical Perspective

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.

A Quick Checklist Before Applying OCP

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.


1. Is This Behavior Likely to Change?

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.


2. Am I Repeatedly Modifying the Same Code?

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.


3. Is the Conditional Logic Continuously Growing?

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.


4. Can the Behavior Be Isolated?

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.


5. Will Multiple Developers Work on This Area?

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.


6. Am I Solving a Real Problem?

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.


A Simple Rule of Thumb

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.

Key Takeaways

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.

Inheritance

Create new subclasses instead of modifying existing implementations.

New Requirement
       ↓
Create New Class
       ↓
Existing Code Unchanged

Polymorphism

Treat different objects through a common interface while allowing each object to provide its own behavior.

Duck Typing

Focus on behavior rather than inheritance hierarchies.

If an object provides
the required behavior,
it can participate.

First-Class Functions

Use functions as extension points by passing behavior as arguments.

Strategy Pattern

Encapsulate algorithms into interchangeable objects that can be selected without modifying the caller.

Registration Decorators

Allow new functionality to register itself rather than requiring changes to a central dispatcher.

functools.singledispatch

Extend generic functions through registration instead of modifying existing implementations.


OCP Is About Managing Change

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.


OCP Is Not a Requirement for Every Problem

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.


The Most Important Lesson

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.

Choosing the Right Extension Mechanism

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.

Conclusion

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.


About

A deep dive into the Open/Closed Principle (OCP) and extensible software design in Python.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors