SOLID Code: How to Understand the Open-Closed Principle with the Strategy Pattern
November 18, 2021 6 min read
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
.
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:
- 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. - Imagine the
TeamLeader
class changes the name of thecalculateTeamLeaderSalary
method tocalculateSalary
. 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. - 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:
The Code after the Strategy Pattern
The strategy pattern is behavioral and built on three pillars:
- Context: what will use/consume the strategy pattern.
- Strategy interface: the abstract entity responsible for the contract that must be implemented by the interested classes and injected into the context.
- 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:
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?
Finally, here's how an index file could consume our 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
- Design Patterns: Elements of Reusable Object-Oriented Software
- Clean Architecture
- The Clean Architecture (The Clean Code Blog)
TABLE OF CONTENTS