SOLID Code: How to Understand the Open-Closed Principle with the Strategy Pattern

SOLID Code: How to Understand the Open-Closed Principle with the Strategy Pattern image

SOLID is an acronym for code that follows five design principles so it's highly cohesive, easy to read, and easy to maintain. Robert C. Martin, better known as Uncle Bob, came up with the SOLID principles in his seminal paper Design Principles and Design Patterns. They go as follows:

  • Single-responsibility principle: There should never be more than one reason for a class to change.
  • Open-closed principle: Software entities should be open for extension, but closed for modification.
  • Liskov substitution principle: Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
  • Interface segregation principle: Many client-specific interfaces are better than one general-purpose interface.
  • Dependency inversion principle: Depend upon abstractions, not concretions.

I always found it hard to learn SOLID only through definitions and concepts. They were too vague and abstract for me to understand SOLID in a tangible way. Design patterns helped.

Understanding the Open-Closed Principle through the Strategy Pattern

I started to understand the open-closed principle of SOLID because of the strategy pattern. Described in the famous 1994 book Design Patterns: Elements of Reusable Object-Oriented Software, the strategy pattern is about writing code so you have the ability to swap out different algorithms for different situations.

A Payroll Application

The best way to demonstrate how the strategy pattern works is through payment methods and/or shipping methods. So let's build a payroll application. Imagine we have to code an algorithm that calculates the salary of a company's employees. The roles are as follows:

  • Developer
  • Team Leader
  • Recruiter

Find the GitHub repository here. Also note that the salaries described here have nothing to do with reality and are defined merely for educational purposes. This example can be written in any language that supports the object-oriented paradigm, but I'll be using JavaScript with the TypeScript superset.

The Code before the Strategy Pattern

At first, it might be tempting to develop the payroll app so it has a class responsible for the salary calculation. Let's do exactly that and call it Payroll.ts.

Payroll Class

The calculateSalary method takes two parameters of type String (thanks to Matias Battistello for correcting this) : employee and hours. The class also imports three other classes: Developer, TeamLeader, and Recruiter. The code has several conditionals that check what kind of employee is passed by the class constructor, after which it applies the correct salary calculation.

Immediately, we have a few challenges:

  1. Every time we create a new work category, we need to change our Payroll class to add the new category. Before long, we'll have too many if/else constructions.
  2. Imagine the TeamLeader class changes the name of the calculateTeamLeaderSalary method to calculateSalary. This would break our code. Similarly, if an external API instead of a class, and it goes through a change in a future release, our code would break too.
  3. Our code is tightly coupled. Whenever we instantiate a concrete entity with the new keyword, we create a strong coupling. This is not recommended for well-designed software.

Let's take a look at how our employee classes are defined:

Developer class
TeamLeader class
Recruiter class

The Code after the Strategy Pattern

The strategy pattern is behavioral and built on three pillars:

  1. Context: what will use/consume the strategy pattern.
  2. Strategy interface: the abstract entity responsible for the contract that must be implemented by the interested classes and injected into the context.
  3. Concrete strategy: the concrete entities that implement the strategy interface. In our example, the employee classes.

We use the strategy pattern whenever we need to group or encapsulate similar algorithms. In our example, every employee category is similar, but has its own salary calculation algorithm. The "consumer" class (Payroll) doesn't undergo any changes, even if we create new categories.

Let's first solve our coupling issue by making sure that our concrete classes depend on abstract instead of concrete entities. So instead of the Payroll class instantiating concrete classes, it will receive a class implementing an interface (our strategy interface).

Let's write that out in code. First, we create an interface defining a common contract between all the employee classes:

Interface Employee

Notice that this interface contains only one method, calculateSalary. All employee classes must implement this method, because they must all implement the Employee interface. Let's see what that looks like in our Developer, TeamLeader, and Recruiter classes:

Developer class with the IEmployee interface
TeamLeader class implementing the IEmployee interface
Recruiter class implementing the IEmployee interface

As you can see, all the employee classes implement the IEmployee interface. Because of this, all classes must implement the same calculateSalary method (i.e. they must follow the same contract). But what about our Payroll class?

Payroll class receiving an entity of type IEmployee by dependence injection

Finally, here's how an index file could consume our Payroll class:

index.ts file instantiating the Payroll class

As you can see, Payroll doesn't know anything about any employee class. It only receives the abstract IEmployee type as a dependency injection. This means we can remove all new keywords from our Payroll class and have it totally decoupled!

No matter how many new employee categories we create, the class won't need to be changed. In other words, it is closed for modification but open for extension, which is exactly the open-closed design principle.

As a Bonus

You've not just learned about the letter O in the SOLID acronym. You've also learned about the letter D. The dependency inversion principle states that classes should depend on abstract over concrete entities. We achieved exactly that in our example, because we passed an abstract representation of our classes as a parameter in our class constructors.

Not only that, but you've also learned about the letter S. The single responsibility principle states that a class must have only one reason to change. Before we applied the strategy pattern, our code had many reasons to change. For example, if the TeamLeader class would change the name of its salary calculation method, or if we would create a new employee category, our Payroll class would have to change as well.

This violated the single responsibility principle. With the strategy pattern applied, the Payroll class will only ever change if the defined interface changes. It has one and only one reason to change, which is exactly what the single responsibility principle demands.

In Conclusion

The hardest part of programming isn't learning a new programming language or paradigm. Instead, it's about understanding how to identify a problem and what design pattern you can apply to solve that problem. Understanding how code violates the best practice design patterns is the mark of an experienced programmer. The strategy pattern can help you understand the S, O, and D of SOLID code.

References

KEEP MOVING FORWARD

Pablo Rodrigo Darde / code