[OOP] Five Principles of Object Orientation (SOLID) - Open-Closed Principle (OCP)
[OOP] Five Principles of Object Orientation (SOLID) - Open-Closed Principle (OCP)
The Open-Closed Principle is the principle that when handling objects, extension of an object should be open, while modification of an object should be closed. In short, it means showing what should be shown and hiding what should be hidden.
To put it more simply, it means functionality can change or be extended, but the code implementing that functionality must not be modified. This principle sounds a bit odd, though. Functionality changing — OK. Extension — OK. But you can't modify the code?? That's a somewhat confusing requirement.
Suppose you're modifying a single object. If doing so requires not only modifying that object but also chain-modifying the code of every other object that depends on it, that's hard to call good design. Think of a typical library as an example. When the code of an object that uses a library changes, the library's own code doesn't change along with it.
In this way, the Open-Closed Principle pursues proper modularization and information hiding for each object, thereby minimizing dependency between objects and reducing the impact of code changes.
If...
You work at a prominent IT company. Retirement is now on the horizon. To prepare for your later years, you decide to make a fresh start as the owner of a small convenience store. You even paid a considerable amount of money for a location you had scouted out long ago.
Fortunately, your judgment wasn't wrong — thanks to a steady flow of customers both in the morning and in the wee hours, your income has been better than expected. Maybe you should have started sooner...
Between juggling so many things, your initial budget was tight, so you picked a cheap POS machine. The sales rep mumbled something about which cards it supports, but hey, a POS is a POS, right?
Lately, the media just won't stop talking about a new card company. Their aggressive perks and their cute design — unlike anything seen in previous cards — are apparently a huge hit. Was it called Chocolate Bank...? The perks are great, but what's the point of a fancy design on a card anyway.
Lately, more and more people are using that chocolate-something card. The problem is that blasted POS machine doesn't recognize the new card at all. Because of this, you've had to turn away more than half your customers this week alone. Sales are one thing, but having to apologize to customers is even more painful.
You called the POS company, and they said it's just how the system is structured, and that it was all explained and signed off before the contract. You don't remember any of that.... Anyway, your only options now are to replace it with a new POS costing nearly twice your current maintenance fee, or pay a penalty and switch to a new POS provider. This problem has been keeping you up at night.
But you're a developer at heart. Maybe you can solve this yourself? Drawing on decades of experience, you start digging through your memory and analyzing the machine.
JAVA
/** * POS class * * @author RWB * @since 2021.08.14 Sat 02:10:12 */ public class Pos { /** * Payment processing and result-returning function * * @param card : [Object] card object * @param name : [String] card company name * @param price: [int] amount * * @return [boolean] payment result */ public boolean purchase(Object card, String name, int price) { boolean result; switch (card.toUpperCase()) { case "A" -> result = ((CardA) card).send(price); case "B" -> result = ((CardB) card).send(price); case "C" -> result = ((CardC) card).send(price); default -> { System.out.println("Invalid card company"); result = false; } } return result; } }
Fortunately, your skills haven't rusted, and you're able to identify the relevant module without much trouble. When the card reader recognizes a card, it casts the object containing the card information to Object and sends it. It seems the card company's name is sent along with it, in order to distinguish which company it belongs to.
At a glance, this structure is a nightmare. Sure enough, Chocolate Bank's card information is being transmitted from the reader just fine, but the purchase method has no logic to recognize the Chocolate Bank card, so the payment isn't going through.
JAVA
public boolean purchase(String card, int price) { boolean result; switch (card.toUpperCase()) { // Add logic to recognize each new company as it emerges. case "A" -> result = ((CardA) card).send(price); case "B" -> result = ((CardB) card).send(price); case "C" -> result = ((CardC) card).send(price); case "D" -> result = ((CardD) card).send(price); case "E" -> result = ((CardE) card).send(price); case "F" -> result = ((CardF) card).send(price); default -> { System.out.println("Invalid card company"); result = false; } } return result; }
So then, wouldn't it be solved by adding a case branch to recognize Chocolate Bank and send the payment info? Using this approach might put out the immediate fire, but it's obvious the same problem will recur the next time a new company shows up.
This approach is highly inefficient. It only widens the scope of the workaround; the underlying problem is never actually solved.
You decide to refactor the code by approaching it from a more object-oriented perspective.
JAVA
/** * Payment interface * * @author RWB * @since 2021.08.14 Sat 02:28:22 */ public interface Purchasable { /** * Function to send card company information and return the result * * @param price: [int] amount * * @return [boolean] transmission result */ boolean send(int price); }
To carry out the logic in a common form, you implement the Purchasable interface. You also require every card object sent from the reader to implement Purchasable.
JAVA
/** * Card A object * * @author RWB * @since 2021.08.14 Sat 02:36:11 */ class CardA implements Purchasable { /** * Function to send card company information and return the result * * @param price: [int] amount * * @return [boolean] transmission result */ @Override public boolean send(int price) { System.out.println(getClass().getSimpleName() + " payment request for " + price + " won"); return true; } } /** * Card B object * * @author RWB * @since 2021.08.14 Sat 02:38:00 */ class CardB implements Purchasable { /** * Function to send card company information and return the result * * @param price: [int] amount * * @return [boolean] transmission result */ @Override public boolean send(int price) { System.out.println(getClass().getSimpleName() + " payment request for " + price + " won"); return true; } } /** * Card C object * * @author RWB * @since 2021.08.14 Sat 02:39:51 */ class CardC implements Purchasable { /** * Function to send card company information and return the result * * @param price: [int] amount * * @return [boolean] transmission result */ @Override public boolean send(int price) { System.out.println(getClass().getSimpleName() + " payment request for " + price + " won"); return true; } }
Now every card object sent from the reader implements the Purchasable interface. You can treat the card objects as their parent type, Purchasable. Since the transmission behavior is implemented individually within each card object, none of them depend on another object's code.
JAVA
/** * POS class * * @author RWB * @since 2021.08.14 Sat 02:10:12 */ public class Pos { /** * Payment processing and result-returning function * * @param purchasable : [Purchasable] Purchasable interface * @param price: [int] amount * * @return [boolean] payment result */ public boolean purchase(Purchasable purchasable, int price) { return purchasable.send(price); } }
Now let's refactor the payment function. CardA, CardB, CardC, etc. are all individually separate objects, but since they now share the parent object Purchasable, we can group them together. All we need to do is accept the interface object handed over by the reader and call its send method.
Having successfully finished the refactor, you can now process payments for any card, as long as the reader recognizes it properly.
Thanks to your dedicated effort and quick action, sales were back to normal in no time.
Let's compare the code before and after the refactor.
JAVA
public boolean purchase(Object card, String name, int price) { boolean result; switch (card.toUpperCase()) { case "A" -> result = ((CardA) card).send(price); case "B" -> result = ((CardB) card).send(price); case "C" -> result = ((CardC) card).send(price); default -> { System.out.println("Invalid card company"); result = false; } } return result; } public boolean purchase(Purchasable purchasable, int price) { return purchasable.send(price); }
The one above is the old code, and the one below is the refactored code. This is where you can find the meaning of functionality can change or be extended, but the code implementing that functionality must not be modified.
In the pre-refactor code, adding recognition for a new card — that is, adding functionality — required adding code. In other words, it meant extending functionality required modifying the code.
Now look at the refactored code. Because it uses the unified Purchasable interface, there is no need to handle new cards at the code level as they are added. In other words, functionality is extended without any code changes.
As with the Single Responsibility Principle, when you find similar branching logic repeated over and over, it's a strong sign that the Open-Closed Principle has not been followed. This directly translates into high refactoring costs, so let's adhere to it carefully and design independent modules.
