What is the Open/Closed Principle: An Explanation with Examples in Java
The open-closed principle states that classes and modules in a software system should be open for extension but closed for modification. It is one of the SOLID principles for software design.
In this post, we will build a step-by-step understanding of the open/closed principle using examples in Java.
Why Should You Apply the Open/Closed Principle?
Software systems that are not carefully designed, tend to develop an increasing amount of unwanted interdependencies. Often these interdependencies are accidental, and they accumulate to such an extent that it becomes increasingly hard to keep track of them. Changes in one part of such a codebase often necessitate changes somewhere else. These changes, in turn, require modifications in another part of the system leading to a domino effect. If you have to change the code in various parts of the codebase to fulfill a new requirement, your system will become unstable and increasingly behave in a non-deterministic manner.
The open/closed principle helps you avoid such a scenario by requiring you to design your system in a manner that makes modifications largely unnecessary. All extensions to the functionality of the system should be achieved by adding new code rather than changing existing code. This way, you are much less likely to run into unwanted ripple effects that necessitate changes in several parts of the system and lead to instability.
This all sounds very abstract. You probably want to know how you can design a software system in a manner that avoids modifications to existing code. Let’s jump into an example to make this concrete.
Open/Closed Principle Example
Suppose a developer is tasked with extending the functionality of a software system for managing a bookstore. The system contains a BookQuery class that contains methods to retrieve information about the books currently available in that store. I’ve used the same example in the post on the single responsibility principle. In that post, our developer has had to learn the hard way that he should not put the logic for the accounting team into the BookQuery class. This time, the developer’s job is to add functionality that allows clerks to distinguish between different editions of a book. The BookQuery class contains a method to retrieve the price of a book by title.
getBookPricebyTitle(String title)
Having learned his lesson about the single responsibility principle, the developer now thinks to himself: The book query class is responsible only to the clerk. Now the clerk’s requirements have changed, so it makes sense to change the code in the book query class. Different editions or the hardcover and paperback versions of the same book could have different prices. Therefore, I need to change the method getBookPriceByTitle to reflect these differences. The most straightforward way to do this is to simply add two more arguments to the function call:
getBookPricebyTitle(String title, String edition, Enum bookType)
The enum book type distinguishes between paperback, hardcover, and ebook versions. The edition is passed as a string. Subsequently, the developer changes the database call inside the function to include the edition and the book type in addition to the title.
He happily pushes the changes into production and informs the frontend development team of the changes. The next day, he checks his mailbox. It is full of voice messages urging him to call back. Book clerks across the country are unable to retrieve book prices. They just receive an error when they try to. You can probably imagine what happened here. The clients used by the clerks still rely on the old method. The developer has changed it and just relied on the frontend team to adjust the client to the new method signature.
How to Apply the Open/Closed Principle
To avoid the problems described in the previous section, the developer should not touch the original method that retrieves the book price by title. If he does, he will immediately break the code of all systems that rely on this method. Instead, it is much safer to add a new method to the class which has the new signature. To be as expressive as possible and distinguish this new method clearly from the existing method, I call it “getBookPricebyTitleEditionType”.
getBookPricebyTitleEditionType(String title, String edition, Enum bookType)
This is the open/closed principle in action. You’ve extended your existing class but did not modify the existing code in that class.
Concerns with the Open/Closed Principle
Some people might find this unappealing. Firstly, you have a really long and ugly name, and secondly, you are cluttering your class with additional code. If you do this several times, you could easily end up with a huge monster class that has dozens or hundreds of methods that are only marginally distinct from each other.
Method Overloading
Regarding the first issue, the method name should clearly express what the method is doing. If you can make the name nice and short while explaining what the method does, that’s great. But remember that in software development expressivity trumps aesthetics.
Some languages like Java allow method overloading which allows you to have multiple methods with the same name but different parameter signatures. If your language allows method overloading, I suggest that you make use of it and retain the same method name while only changing the parameter signature. It will simplify your code because you can keep the name very short.
getBookPrice(String title) getBookPrice(String title, String edition, Enum bookType)
The compiler will inform the client what implementations of that method name are available, so you don’t have to worry about mentioning that in the method name.
Taming Complexity
The danger that your class spirals out of control is there regardless of whether you apply the open/closed principle or not. Applying the open/closed principle does not mean that you should pile method upon method in your class without cleaning up what is already there. On the contrary, having more methods that do similar things will help you update and clean your code.
You may realize that you can refactor the code in your system to rely only on the new method. As a consequence, you can eliminate the “getBookPricebyTitle” method altogether. In my interpretation, this would not constitute a violation of the open/closed principle since you are not modifying existing code but throwing out old, unused code (something you should absolutely do on a regular basis).
Of course, this is possible only if the BookQueryClass is purely used internally by components that you and your team control. In a clean software system, the majority of your code should be internally facing. The external client should only interact with a few carefully selected methods.
When it comes to the external facing interface, sticking to the open/closed principle becomes even more important. You don’t want the connections of your customers to break. Especially not if they are paying customers.
The implementation above might become problematic because you still have to change your interface by adding the additional method. This might break external clients relying on the interface. To address this problem, you want to close your interface and your classes for modification.
Closing Classes for Modification
To add new functionality without adding new code to existing classes, we need to add new classes we the desired functionality and make them conform to the existing interface. This way we don’t even have to recompile and redeploy existing code.
In the case of our BookQuery functionality, we could address this problem by adding an interface that requires the implementation of a method to get a book’s price without any arguments.
public interface BookQueryInterface { public String getBookPrice(); }
We then create two implementations of the book query class and supply the required arguments through the constructors when we instantiate the classes.
public class BookQuery implements BookQueryInterface { public BookQuery(String title) {}; public String getBookPrice() {}; }
public class BookQueryByEditionandType { public BookQueryByEdition(String title, String edition, String bookType){}; public String getBookPrice(){}; }
To add more flexibility, I would further split the last class into two separate classes. One of the is responsible for querying by edition, and the other one for querying by type. So in total, we now have three classes.
public class BookQueryByEdition { public BookQueryByEdition(String title, String edition){}; public String getBookPrice(){}; }
public class BookQueryByType { public BookQueryByType(String title, String bookType){}; public String getBookPrice(){}; }
The Open/Closed Principle in Java
The developer works on a preexisting class that already has a method to obtain the book’s price by its title.
public class BookQuery implements BookQueryInterface { public String getBookPricebyTitle(String title) { //database access magic happening here return "price"; }; }
Instead of changing this method, we extend the class with another method that takes the edition and the book type as parameters.
public class BookQuery implements BookQueryInterface { public String getBookPricebyTitle(String title) { //database access magic happening here return "price"; }; public String getBookPricebyTitleEditionType(String title, String edition, String bookType) { //database access magic using title, edition, and book type, happening here return "price"; }; }
In Java, we can use method overloading to define methods with the same name, but different parameter signatures. The parameter signature already tells us what arguments are required to find the book and the compiler will enforce the correct usage of arguments. This means we can simplify the naming of our methods by removing the parameters from the method name.
public class BookQuery { public String getBookPrice(String title) { //database access magic happening here return "price"; }; public String getBookPrice(String title, String edition, String bookType) { //database access magic using title, edition, and book type, happening here return "price"; }; //private methods for database access.. }
To keep our software more maintainable, I highly recommend defining an interface for the book query class. This way, we can add different implementations of the book query functionality without disturbing the classes that depend on BookQuery.
public interface BookQueryInterface { public String getBookPrice(String title); public String getBookPrice(String title, String edition, String bookType); }
Lastly, we have the BookQuery class conform to the interface.
public class BookQuery implements BookQueryInterface { @Override public String getBookPrice(String title) { //database access magic using title happening here return "price"; }; @Override public String getBookPrice(String title, String edition, String bookType) { //database access magic using title, edition, and book type, happening here return "price"; }; //private methods for database access.. }
Refactoring the Code to Close Classes for Modification
So far so good. We have added a method rather than modifying an existing method. So at a method level, we could say that we conform to the open/closed principle. But the basic building blocks of object-oriented software are classes. To make the code more modular, we want an unmodified interface and add new functionality by creating new classes conforming to that interface.
We start the refactoring by adding a constructor to the book query class that supplies the title. Then we change the first implementation of getBookPrice to rely on the class variable title and remove the second implementation of get bookPrice altogether.
public class BookQuery implements BookQueryInterface { String title; public BookQuery(String title) { this.title = title; } @Override public String getBookPrice() { //database access magic using this.title happening here return "price"; }; //private methods for database access.. }
This code will not compile because we first need to adjust our interface:
public interface BookQueryInterface { public String getBookPrice(); }
Now the interface is very simple, and we can implement the additional functionality to retrieve books by edition and type by creating new implementations of the BookQueryInterface. To make the code even more modular and flexible, I suggest creating a separate class each for retrieving books by edition and by type.
Here is the implementation of BookQueryByEdition:
public class BookQueryByEdition implements BookQueryInterface { String title; String edition; public BookQueryByEdition(String title, String edition) { this.title = title; this.edition = edition; } @Override public String getBookPrice() { //database access magic using this.title and this.edition happening here. return "title"; } }
And here is the implementation of BookQueryByType:
public class BookQueryByType implements BookQueryInterface { String title; String bookType; public BookQueryByType(String title, String bookType) { this.title = title; this.bookType = bookType; } @Override public String getBookPrice() { //database access magic using this.title and this.bookType happening here. return "title"; } }