Applying the Open-Closed principle of software design using Strategy Design Pattern
Dipak Niroula
Senior Software Engineer | Python, Rust, JavaScript, etc| Software Architecture, Distributed System, Microservices | Developer, Trainer, Mentor, Learner
Open-Closed Principle in Software Design
The Open/Closed Principle is one of the SOLID principles of object-oriented design. SOLID is a set of five design principles mentioned below
These principles help software developers design robust, testable, extensible, and maintainable object-oriented software systems.
In this article, we will be discussing the Open-Closed principle using a Strategy Design Pattern. The Open-Closed Principle states:
"Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification."
In other words, once a class has been defined and implemented, its behavior should be extendable without modifying its source code. New functionality should be added through the introduction of new code (such as subclasses or modules) rather than by changing the existing code. This ensures that existing code remains stable and does not introduce unintended side effects.
Software Design Patterns
Software design patterns are proven solutions to recurring design problems encountered in software development. These patterns encapsulate years of collective experience and wisdom from seasoned developers, offering well-established templates for creating robust, scalable, and maintainable software. By providing a shared vocabulary and set of guidelines, design patterns facilitate effective communication and collaboration among software engineers. Design Patterns are generally classified into the following types.
Embracing design patterns promotes code flexibility, reusability, and adherence to best practices, ultimately leading to more efficient development processes and higher-quality software products.
In this article, we will only discuss the strategy design pattern.
Strategy Design Pattern
The Strategy Design Pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each algorithm within a separate class, and makes them interchangeable. The pattern allows the client to choose the appropriate algorithm at runtime.
The strategy design pattern has 3 components.
Let's consider a scenario that makes use of a strategy design pattern:
Problem Statement: In a software company, there are web developers who work on various frameworks based on various programming languages. When they finish developing web applications, they have to use a tool/software that helps them set up the application server environment based on their selected framework. After some time, the company hires new developers who work on a new framework. In this case, the feature addition to your software should be easy and hassle-free.
Now your job is to design and develop a tool/software that helps them do so easily. So how do you design and develop such software?
Now your job is to design and develop a tool/software that helps them do so easily. So how do you design and develop such software?
Problem Analysis and Planning Software Design:
Starting Software Development
Writing Code for each component mentioned above
Since we have already analyzed the problem and broken it down into the components of the Strategy Design Pattern, let's start writing codes for each component.
Starting Software Development
领英推荐
Writing Code for each component mentioned above
Since we have already analyzed the problem and broken it down into the components of the Strategy Design Pattern, let's start writing codes for each component.
Defining Strategy Interface
from abc import ABC, abstractmethod
class FrameworkInterface(ABC):
def __init__(self, framework_name: str, language: str) -> None:
self.framework_name = framework_name
self.language = language
@abstractmethod
def setup_server(self) -> None:
""" installing language and dependencies """
@abstractmethod
def run_server(self, host: str, port: int) -> None:
""" running {self.framework_name} server at https://{host}:{port} """
Implementing Framework Implementations (for now Django and Spring)
class DjangoFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Python installed successfully...")
print("Django installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class SpringFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Java installed successfully...")
print("Spring installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
Defining Context
class FrameworkContext:
def __init__(self, framework: FrameworkInterface) -> None:
self.framework = framework
print(f"{self.framework.framework_name} choosen.\n")
def setup_server(self) -> None:
self.framework.setup_server()
def run_server(self, host: str, port: int) -> None:
self.framework.run_server(host, port)
Implementing the above components in the main function
def main() -> None:
print("\nStarting our software\n----------")
framework = DjangoFramework(framework_name="Django Framework", language="Python")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 8080)
framework = SpringFramework(framework_name="Spring Framework", language="Java")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 9090)
if __name__ == '__main__':
main()
Joining them altogether
from abc import ABC, abstractmethod
class FrameworkInterface(ABC):
def __init__(self, framework_name: str, language: str) -> None:
self.framework_name = framework_name
self.language = language
@abstractmethod
def setup_server(self) -> None:
""" installing language and dependencies """
@abstractmethod
def run_server(self, host: str, port: int) -> None:
""" running {self.framework_name} server at https://{host}:{port} """
class DjangoFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Python installed successfully...")
print("Django installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class SpringFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Java installed successfully...")
print("Spring installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class FrameworkContext:
def __init__(self, framework: FrameworkInterface) -> None:
self.framework = framework
print(f"\n{self.framework.framework_name} choosen.\n")
def setup_server(self) -> None:
self.framework.setup_server()
def run_server(self, host: str, port: int) -> None:
self.framework.run_server(host, port)
def main() -> None:
print("\nStarting our software\n----------")
framework = DjangoFramework(framework_name="Django Framework", language="Python")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 8080)
framework = SpringFramework(framework_name="Spring Framework", language="Java")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 9090)
if __name__ == '__main__':
main()
After running the above program, the output will be as shown below
Starting our software
----------
Django Framework choosen.
Python installed successfully...
Django installed successfully...
Starting Django Framework server at https://127.0.0.1:8080 ...
Spring Framework choosen.
Java installed successfully...
Spring installed successfully...
Starting Spring Framework server at https://127.0.0.1:9090 ...
Now in the final case, what if we have to add a new feature/strategy to it? Assume we have a new development team working in the Axum framework (One of the popular web frameworks written in Rust Programming Language). Let's incorporate the new feature in the above codebase.
from abc import ABC, abstractmethod
class FrameworkInterface(ABC):
def __init__(self, framework_name: str, language: str) -> None:
self.framework_name = framework_name
self.language = language
@abstractmethod
def setup_server(self) -> None:
""" installing language and dependencies """
@abstractmethod
def run_server(self, host: str, port: int) -> None:
""" running {self.framework_name} server at https://{host}:{port} """
class DjangoFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Python installed successfully...")
print("Django installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class SpringFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Java installed successfully...")
print("Spring installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class AxumFramework(FrameworkInterface):
def setup_server(self) -> None:
print("Rust installed successfully...")
print("Axum installed successfully...\n")
def run_server(self, host: str, port: int) -> None:
print(f"Starting {self.framework_name} server at https://{host}:{port} ...\n")
class FrameworkContext:
def __init__(self, framework: FrameworkInterface) -> None:
self.framework = framework
print(f"\n{self.framework.framework_name} choosen.\n")
def setup_server(self) -> None:
self.framework.setup_server()
def run_server(self, host: str, port: int) -> None:
self.framework.run_server(host, port)
def main() -> None:
print("\nStarting our software\n----------")
framework = DjangoFramework(framework_name="Django Framework", language="Python")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 8080)
framework = SpringFramework(framework_name="Spring Framework", language="Java")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 9090)
framework = AxumFramework(framework_name="Axum Framework", language="Rust")
manager = FrameworkContext(framework=framework)
manager.setup_server()
manager.run_server("127.0.0.1", 9000)
if __name__ == '__main__':
main()
If you observe the code above carefully, we have just extended the FrameworkInterface by writing a new AxumFramework class without modifying any of the existing framework implementations and the context class. Developers now can select the Axum framework in the main function(or their implementation block), inject them into the FrameworkContext instance, and set up and run the Axum-made webserver using the context.
The output of the above program would be
Starting our software
----------
Django Framework choosen.
Python installed successfully...
Django installed successfully...
Starting Django Framework server at https://127.0.0.1:8080 ...
Spring Framework choosen.
Java installed successfully...
Spring installed successfully...
Starting Spring Framework server at https://127.0.0.1:9090 ...
Axum Framework choosen.
Rust installed successfully...
Axum installed successfully...
Starting Axum Framework server at https://127.0.0.1:9000 ...
So in this way Strategy design pattern supports the Open-Closed design principle.
Having some advantages of the Strategy Design Pattern and Open-Closed design principle, they also have some disadvantages. For example, Increased Number of Classes, Increased code coupling, object proliferation, etc. Software Engineering involves numerous trade-offs. So getting advantages from one way of implementation may introduce other disadvantages and we have to choose which better suits our use-case scenarios.
Thank you so much for reading this article. I am hoping this aids something in your software engineering journey. If you have any doubts or feedback related to this topic and example, you can have a comment below.
Namaste!!!