If there is one telltale sign of good software, it is the ability to change easily with time. Good code is easy to change, easy to maintain. And the SOLID principles show you how to write code that way.
The SOLID principles have been around long enough that it still surprises me when people say they don’t know them. Even amongst those who know them, people understand some rules better than others. S (single responsibility principle) is probably best known because it is easy to grasp. D (dependency injection) is also fairly mainstream by now. One of the less understood, though, is O (the Open/Closed principle).
The Open/Closed principle is there to help you whenever the business wants to add “one more type of X” - where X could be another business rule, another type of widget, whatever. It says that to handle the new X, you shouldn’t need to touch your existing code; just add new code. This makes for easier maintenance and testability.
The rest of this post walks through the evolution of a simple class into a design that adheres to the Open/Closed principle. It also shows how the principles work together to achieve good design.
Consider a simple RomanNumeral class that stores the string representation of a roman number. If the input to the constructor is not a valid roman number, an exception is thrown.
What rules determine if a Roman number is valid? Let’s say we know only 3 at first:
- It only contains roman numerals: I, V, X, C, L, D, M
- These numerals cannot appear in succession more than 3 times: I, X, C, M
- These numerals cannot appear more than once: V, L or D
One way to validate the input would be to check all the rules inside the IsValid() method:
What happens if our validation rules change? Say the business comes to you with a new rule:
- No numeral can appear more than 4 times total
So we modify IsValid() to include rule 4:
This violates Open/Closed; we had to modify existing code to accommodate a new type of validation. But… is it that bad? Not really. The code is fairly readable (although the intention is revealed by the comments, not by the code itself), and the change was fast and easy to make. As far as tradeoffs go, this isn’t terrible.
But now we get a request to add another validation rule, one that’s a bit more complicated:
- Symbols are placed from left to right in order of value
This isn’t something we can validate in a single line. We need to introduce the concept of value, and then check if our symbols are placed in order:
Our scope of changes is getting larger. To handle the new validation rule we added a new private method ValueOf() and modified IsValid(). The code is getting unweildy, and further requests will obviously make it more so.
At this point let’s step back and refactor things. First we will use Extract Method to improve the readability of IsValid() and isolate each validation rule to its own method.
How’s our SOLID score so far? Ignoring I and L, which don’t really apply here, we can say that:
- S: FAIL. A class should have one and only one reason to change. So far, our class has changed every time a new validation rule was added. You could argue that “the rules have changed” is a single reason, but that’s too broad. It’d be much nicer if each validation rule could be treated separately.
- D: FAIL. We haven’t taken advantage of an underlying abstraction here: a “Rule” is simply something that returns true or false depending on whether our input fails that rule or not. Our IsValid() method should just depend on this abstraction by having a “Set of Rules” passed in via dependency injection.
- O: FAIL. So far, each additional rule has required modifications to our class in two places: creating the new rule as a private method, then checking the new rule by invoking it from IsValid(). We’d like each rule to be included into our system without changing existing code.
This gives us a few design insights:
- We can define an abstraction called IValidationRule() that has a single Validate() method returning a boolean
- We can make each rule into its own class that implements IValidationRule(). Our last refactoring has already brought us close by extracting each rule into its own private method; all we need to do is promote it to a class.
- We can use dependency injection to find and inject all the implementations of IValidationRule() into our class. New rules would thus automatically be included without needing any change to our current code.
Putting all these things in place, our code now looks like this:
Result: our IsValid() method is down to 2 lines of code; the entire RomanNumeral class is down to its original size of 18 lines. We do have a number of new classes - all implementations of different validation rules. If new rules were added, we’d only need to add new classes corresponding to each; none of the existing code would need to change. Nirvana!
So is it ever okay to change existing code for new requirements?
Of course it is. This becomes necessary when the new requirement changes your understanding of the underlying business domain.
In our RomanNumeral example, let’s say our users want to add one more validation rule:
- In some specific cases, to avoid four characters being repeated in succession (such as IIII or XXXX), subtractive notation is allowed. For example, IV indicates “one less than five” and thus evaluates to 4. The following are valid subtractive symbols: IV (4), IX (9), XL (40), XC (90), CD (400) and CM (900).
If you read this closely, you’ll notice that this isn’t really a new requirement… it’s a change to an existing one. Requirement #6 changes our understanding of one of our domain objects: “Symbol.” We now understand that symbols aren’t always one character in length; they could be 1 or 2 characters long. So let’s make some changes.
The key change is to one validator: SymbolsArePlacedInOrderFromLeftToRight. This is the only place that the subtractive symbols come into play. Everywhere else we’ve simply replaced variable names called ‘symbol’ to ‘characters’ - since that is what they’re checking for.
The great thing is that this change was WAY less risky than any code that didn’t follow Open/Close might. It also gave us the freedom to implement the change in more than one way; by isolating the change, it allows for future refactoring. That’s the beauty of the principles.