As software developers, we're constantly facing new challenges and requirements that require us to make changes to our code. But as our codebase grows and becomes more complex, making changes can become increasingly difficult and risky. One small mistake can cause a ripple effect that breaks the entire system.
Imagine a city with a robust infrastructure, such as bridges, roads and buildings. These were built to serve the citizens' needs and to make the city function efficiently. Now imagine the city's growth and new needs arise, such as adding a new district, new transportation needs, etc. The city's infrastructure needs to adapt to these new needs without breaking the existing one. In software development, we are in a similar situation.
That's where the open-closed principle comes in. It's a principle that states that "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification" It helps us design our code in a way that allows us to make changes and add new features without breaking existing functionality. In other words, it's a way to make sure that our code can grow and evolve with our needs, just like the city infrastructure.
The open-closed principle is often associated with object-oriented programming, but it's a general principle that can be applied to any kind of code. In this article, we'll take a closer look at what the open-closed principle is, why it's important, and how we can apply it to our own code. We'll also explore some real-world examples and discuss the benefits of using this principle in your software development projects.
What is the open-closed principle?
The open-closed principle states that software entities (such as classes, modules, and functions) should be open for extension, but closed for modification. This means that you should be able to add new functionality to a class or module without having to change its source code.
One way to think about this principle is to consider the analogy of a software module as a black box. The internal workings of the black box (the implementation details) should be hidden from the outside world, and only the inputs and outputs (the interface) should be visible. This allows you to make changes to the internal workings without affecting the rest of the system.
For example, imagine you have a class called Calculator that can perform basic arithmetic operations. You decide to add a new feature that allows the class to perform more advanced mathematical operations. If you followed the open-closed principle, you would create a new class called AdvancedCalculator that inherits from the original Calculator class and adds the new functionality. This way, the original Calculator class remains unchanged, and the new functionality can be added without affecting the existing code.
Another way to implement the open-closed principle is through the use of interfaces and polymorphism
. This allows the implementation details to be hidden behind an interface, and new functionality can be added by creating new classes that implement the interface.
This principle encourages the separation of concerns and the single responsibility principle, makes your code more reusable and less prone to bugs. It also makes it possible to add new functionality without having to make changes to the existing code which is a common cause of bugs and issues.
This is not a hard rule to follow but more like a guideline to be aware of, and it often comes in conjunction with other principles to make a robust and maintainable codebase.
How is following the open-closed principle of any importance?
The open-closed principle can bring a number of benefits to your software development projects, including:
Increased flexibility
When you can add new functionality without modifying existing code, it becomes easier to make changes to your system in response to new requirements or bug fixes. This means you can be more agile and responsive to changing business needs.
Improved maintainability
Code that follows the open-closed principle is generally easier to understand and maintain. This is because the internal workings of a class or module are hidden behind an interface, making the code more modular and less prone to bugs.
Reusability
Classes and modules that follow the open-closed principle are often more reusable, since they have a well-defined interface and aren't tightly coupled to the implementation details. This makes it easier to use the same code in multiple places, which can save time and effort in the long run.
Better scalability
When you can add new functionality to your codebase without modifying existing code, it becomes easier to scale up your system as your business needs grow.
Easier testing
Code that follows the open-closed principle is often more testable, since the implementation details are hidden behind an interface. This makes it easier to write automated tests and to make sure your code is working correctly.
How to apply the open-closed principle to your code
There are several design patterns and techniques that can help you implement the open-closed principle in your code:
Inheritance
You can use inheritance to create new classes that inherit from existing classes and add new functionality. This allows you to add new functionality without modifying the existing code.
class Shape {
func draw() {
print("Drawing a shape")
}
}
class Circle: Shape {
override func draw() {
print("Drawing a circle")
}
}
Interfaces and polymorphism
You can create an interface that defines the behavior of a class or module, and then create multiple implementations of that interface. This allows you to add new functionality by creating new classes that implement the interface.
protocol Shape {
func draw()
}
class Square: Shape {
func draw() {
print("Drawing a square")
}
}
class Triangle: Shape {
func draw() {
print("Drawing a triangle")
}
}
Composition
You can use composition to create classes that have a has-a relationship with other classes. This allows you to add new functionality by creating new classes and composing them with existing classes.
protocol Shape {
func draw()
}
struct Square: Shape {
func draw() {
print("Drawing a square")
}
}
struct Triangle: Shape {
func draw() {
print("Drawing a triangle")
}
}
struct MultiShapeComposer: Shape {
let shapes: [Shape]
func draw() {
for shape in shapes {
shape.draw()
}
}
}
struct ShapeDrawer {
func drawShape(shape: Shape) {
shape.draw()
}
}
let square = Square()
let triangle = Triangle()
let multiShape = MultiShapeComposer(shapes: [square, triangle])
ShapeDrawer().drawShape(shape: multiShape)
Strategy pattern
you can use the strategy pattern to encapsulate different algorithms and behavior inside different classes, and then use interfaces to change the behavior of the algorithm.
protocol Pay {
func pay()
}
class CreditCard: Pay {
func pay() {
// implementation
}
}
class PayPal: Pay {
func pay() {
// implementation
}
}
class Checkout {
var payMethod: Pay
init(payMethod: Pay) {
self.payMethod = payMethod
}
func processPayment() {
self.payMethod.pay()
}
}
Decorator pattern
the decorator pattern allows you to add new functionality to a class at runtime, by wrapping the original class with a decorator object that adds the new functionality.
protocol Pizza {
func getCost() -> Double
func getDescription() -> String
}
class MargheritaPizza: Pizza {
func getCost() -> Double {
return 7.5
}
func getDescription() -> String {
return "Margherita Pizza"
}
}
class MushroomDecorator: PizzaDecorator {
override func getCost() -> Double {
return super.getCost() + 1.0
}
override func getDescription() -> String {
return super.getDescription() + ", Mushroom"
}
}
let margheritaPizza = MargheritaPizza()
let mushroomDecoratedPizza = MushroomDecorator(margheritaPizza)
print(mushroomDecoratedPizza.getCost()) // 8.5
print(mushroomDecoratedPizza.getDescription()) // Margherita Pizza, Mushroom
Real-world example
Imagine that you are working on an iOS app that shows a list of products to the user using the Model-View-Presenter (MVP) pattern.
Now the team realises that it would be good to know how many users have seen this page, so they decide to add a new feature that emits an event to Google Analytics every time the users sees results on the screen and also a different event when the user sees an error message.
One way to achieve, would be to add some code in the presenter, that before calling the view to show the list of products, makes a call to the GoogleAnalyticsTracker, to emit such event.
This would go against the open-closed principle, since it would require modifying the existing code to add new functionality.
Instead, we can apply the decorator pattern to add this functionality without modifying any pre-existing code.
This is how the existing code looks like:
class ProductsListPresenter {
weak var view: ProductsListView?
func showProducts(products: [Product]) {
let productViewModels = products.map({ ProductViewModel(product: $0) })
view?.showProducts(products: products)
}
func showErrorFetchingProducts() {
view?.showError(message: "Something went wrong, please try again later!")
}
}
protocol ProductsListView: class {
func showProducts(products: [ProductViewModel])
func showError(message: String)
}
class ProductsViewController: UIViewController, ProductsListView {
func showProducts(products: [ProductViewModel]) {
datasource.products = products
tableView.reloadData()
}
func showError(message: String) {
errorLabel.text = message
}
}
let viewController = ProductsViewController()
let presenter = ProductsListPresenter()
presenter.view = viewController
This is how the code would look like after applying the decorator pattern:
class AnalyticsProductsListViewDecorator: ProductsListView {
let analytics: GoogleAnalyticsTracker
let decoratee: ProductsListView
func showProducts(products: [ProductViewModel]) {
analytics.trackEvent(event: "ProductsListScreenViewed")
decoratee.showProducts(products: products)
}
func showError(message: String) {
analytics.trackEvent(event: "ProductsListScreenError")
decoratee.showError(message: message)
}
}
let viewController = ProductsViewController()
let analyticsDecorator = AnalyticsProductsListViewDecorator(
analytics: GoogleAnalyticsTracker(),
decoratee: viewController
)
let presenter = ProductsListPresenter()
presenter.view = analyticsDecorator
Conclusion
In conclusion, the open-closed principle is a fundamental principle in software architecture that promotes writing code that is open for extension and closed for modification. By following this principle, we can create more flexible and adaptable code that can evolve over time, without needing to make significant changes to existing code. It helps make the codebase maintainable, understandable, and flexible, and it is an essential aspect of writing high-quality software. In short, the open-closed principle promotes robust, flexible and easy to maintain code that can handle changes over time.
Bonus tips
Keep in mind the SOLID principles, especially the Single Responsibility Principle (SRP) and the Liskov Substitution Principle (LSP), which are closely related to the open-closed principle, when designing your code.
Testing should be considered when following the open-closed principle, as this principle makes it easier to write testable code by separating concerns and encapsulating behaviors. Luckily, the open-closed principle is easy to test, since it is easy to create mocks and stubs for the classes that are being tested.
Try to apply the open-closed principle as early as possible in the development process, this will make your codebase more maintainable and adaptable over time, without the need to make significant changes to existing code. That said, I think you should also starty thinking about it at any point of the codebase's lifecycle, the sooner you start applying it, the better!
Remember that the open-closed principle is just one aspect of writing high-quality software, and it should be used in conjunction with other principles and practices to create robust, maintainable, and flexible code.