• The open-closed principle made simple
  • Original post by Mihai Sandu
  • Translator: maybelence

In fact, Scala itself is very similar to the syntax of Java. Considering that more developers are Java, I converted the original code into Java. Some conflicts with Java syntax are also annotated in Chinese.


A truck is a versatile device. They can perform different tasks depending on the type of part the carriage is mounted on, or even carry multiple carriages if the load allows.

In short, a truck can be expanded with different parts, but it cannot modify the internal units of each part (such as the engine or cabin). Extensible code should be as handy as truck mounting parts.

Ice machine problem

Suppose we had to write a program to make cocoa ice cream for Ted&Kelly.

public class IceCreamMachine
{
    public void MakeIceCream(a)
    {
        System.out.println("Cacao ice-cream"); //logic to create cacao ice cream}}Copy the code

We delivered the program to the customer, and while the code was simple, it looked fine and didn’t break any rules. The customer is very satisfied at the moment.

But it doesn’t end there. There’s always something new. This means that we need to constantly tweak our code.

Add new flavors

The app was so popular when it launched that Ted&Kelly wanted to scale it up. They asked us to be able to make vanilla ice cream as well.

After a brief thought, we came up with the following solutions:

public class IceCreamMachine 
{
    
    public void MakeIceCream(String flavor) throws Exception {
        if("cacao".equals(flavor))
        {
            System.out.println("Cacao ice-cream"); //logic to create cacao ice-cream
        }
        else if ("vanilla".equals(flavor))
        {
            System.out.println("Vanilla ice-cream"); //logic to create vanilla ice-cream
        }
        else
        {
            throw new Exception("Flavor not supported"); }}}Copy the code

We modified the original code, adding a parameter to specify the desired flavor and an if statement to switch between the logic. Since we have changed the original function signature, the method that calls our code will be broken, but at least from now on we should support the production of other flavors without breaking the change.

Create a cocoa and vanilla combination

The business went well, but soon a second request came from the customer, who wanted us to produce ice cream made from cocoa and vanilla. Things started to get a little complicated, but we were able to handle it.

public class IceCreamMachine
{
    public void MakeIceCream(String flavor) throws Exception 
    {
        if("cacao".equals(flavor))
        {
            System.out.println("Cacao ice-cream"); //logic to create cacao ice-cream
        }
        else if ("vanilla".equals(flavor))
        {
            System.out.println("Vanilla ice-cream"); //logic to create vanilla ice-cream
        }
        else if ("cacao-vanilla".equals(flavor))
        {
            //copy & paste the cacao ice-cream logic
            System.out.println("Cacao ice-cream");

            //copy & paste the vanilla ice-cream logic
            System.out.println("Vanilla ice-cream");
        }
        else
        {
            throw new Exception("Flavor not supported"); }}}Copy the code

We added an if statement, in which case the ice cream production logic was copied inside each if. In a real application, I might extract the production logic in a separate service. However, as we will see, extracting services is not always the best solution.

What happens when Ted&Kelly asks for support to produce more flavors. What if he wants to combine them further? Just adding an if clause is not an ideal solution.

This solution creates problems

Every time we add a new flavor or combination, we have to update the IceCreamMachine class, which means:

  • We update the deployed code
  • The class becomes more complex and less readable. Assuming we have 100 flavors, this class could easily swell to 5000+ lines of code
  • We might break existing unit tests

Now think back to the truck analogy at the beginning. When you change the part mounted on the car, do you change the engine every time? Obviously not. Let’s see how we solve this problem.

The traditional extensibility approach

Bertrand Meyer was the person who first coined the term open closed principle, defining that “software entities (classes, modules, functions, etc.) should be open for extension and closed for modification.”

In other words, whenever we need to add new behaviors to old objects, we can inherit and update them as needed. The open close principle is one of those principles that are easy to understand but hard to apply.

Let’s rewrite the code we just wrote this way:

public abstract class BaseIceCream {
    public abstract void MakeIceCream(a);
}

public class CacaoIceCream extends BaseIceCream {
    @Override
    public void MakeIceCream(a) {
        System.out.println("Cacao ice-cream"); }}public class VanillaIceCream extends BaseIceCream {
    @Override
    public void MakeIceCream(a) {
        System.out.println("Vanilla ice-cream"); }}public class CacaoAndVanilla extends CacaoIceCream {
    @Override
    public void MakeIceCream(a) {
        super.MakeIceCream();
        System.out.println("Vanilla ice-cream"); //duplicate vanilla logic because we can't iherit both cacao and vanilla}}Copy the code

The original class is divided into four categories, each representing a production flavor. With this solution, we solved all the original problems:

  • Each class is short, concise, and maintainable and readable
  • When we need to add a new flavor, we just need to add a new class
  • Existing unit tests will not be affected

The code looks like there’s nothing wrong with it, and we can stop thinking at this point, but there are a few problems with this solution:

  • You can’t inherit multiple classes, so for a combination of two flavors, you have to copy some code or extract logic into the service
  • If the code in the base class is updated, all subclasses are affected. Assuming that the base class has injected some dependencies through the constructor, every time we add a new dependency, all the children must resolve that parameter into the base constructor

Now extensible methods

When Robert C. Martin reiterated Meyer’s on/off principle, he made some updates. Favoring inheritance over inheritance.

When combining objects, we are free to combine as many and as many combinations as we want. Moreover, if we program against abstract classes (interfaces), we can modify the behavior of existing code without modifying the code. Let’s look at the final solution:

public interface IMakeIceCream {
    void Make(a); }class CacaoIceCream implements IMakeIceCream {
    private IMakeIceCream iceCreamMaker;

    public CacaoIceCream(IMakeIceCream iceCreamMaker)  // Optional argument, pass null if another flavor is not required
    {
        this.iceCreamMaker = iceCreamMaker;
    }

    public void Make() {
        if(iceCreamMaker ! =null) //if flavor passed in, make that first
        {
            iceCreamMaker.Make(a); }System.out.println("Cacao ice-cream"); }}class VanillaIceCream implements IMakeIceCream {
    private IMakeIceCream iceCreamMaker;

    public VanillaIceCream(IMakeIceCream iceCreamMaker)  // Optional argument, pass null if another flavor is not required
    {
        this.iceCreamMaker = iceCreamMaker;
    }

    @Override
    public void Make() {
        if(iceCreamMaker ! =null) //if flavor passed in, make that first
        {
            iceCreamMaker.Make(a); }System.out.println("Vanilla ice-cream"); }}Copy the code

The original class is now divided into three objects:

  • IMakeIceCream Interfaces define a general abstraction for making ice cream
  • CacaoIceCreamTo achieve theIMakeIceCream
  • VanillaIceCream To achieve theIMakeIceCream

By using interfaces, we decouple the class from the implementation. Interfaces are closed and immutable, so once we define an object, we cannot change it. But we can define new interfaces for new functionality and inherit them. This makes the code extensible.

Why add the IMakeIceCream argument to each constructor? Does the new code have all the functionality of the old method?


The answer is yes. The cocoa vanilla combination is still there, but we don’t need an if clause or a class dedicated to it. We can use constructor arguments. Something like this:

var cacaoVanillaIceCream = new CacaoIceCream(new VanillaIceCream());
cacaoVanillaIceCream.Make(a);Copy the code

The beauty of composition is that we can compose as many objects as we need. Need 4 flavors? Just write a constructor chain. This is called the decorator pattern. You can read more about it here.

Note that the IMakeIceCream parameter is optional. This allows me to use the class in combination or on its own.

Writing code like this implements a pluggable architecture. This is nice because we can write code to add new functionality without changing existing functionality. Mission accomplished.