Welcome to the five-part series of blog posts about SOLID Principles. In each part I will describe and analyze one of these principles. In the last part, expect summary of the entire series containing a few tips and thoughts.
Let’s start. What are SOLID Principles? There are five general design principles of object-oriented programming intended to make software more understandable, extendable, maintainable and testable.
- Single responsibility principle (SRP)
- Open/closed principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Today, more about second principle - Open/Closed Principle.
a class should be closed for modification but open for extensions.
In other words, we should extend class functionality without modifying its core behavior. We can achieve this by:
- using inheritance mechanism
- using composition technique
- applying Dependency Injection pattern
- applying Decorator pattern
- applying Strategy pattern
Of course, these are not the only solutions.
The benefit is that we do not have to worry about the code that uses source classes because we did not modify them, so their behavior must be the same.
As for caveats, we should follow common sense. In practice, we must be careful to avoid creating too many derived classes, although, small modifications in classes are usually harmless. And that is the main reason why we should always write tests for our code - to notice the undesirable behavior of our code.
Now, consider the following example. Suppose that we wanted to write a service object for validating and updating user data, unfortunately, validation might vary depending on some conditions. A class of such kind would look like this:
class UserCreateService def initialize(params) @params = params end def call return false unless valid? process_user_data end def valid? validator = assign_validator validator.new(params).validate end def assign_validator if some_condition AdvancedUserValidator else SimpleUserValidator end end def process_user_data ... end end
We even moved validation logic to separate classes but there is still problem with that code.
- Adding new validation logic will be painful - another “if” condition or even “switch” statement
- We must touch class that is not responsible for the validation because of validation logic change. It even sounds weird….
- Testing will be harder - we should cover both processing & validation (many cases) logic.
Here is one the possible solutions:
class UserCreateService def initialize(validator: UserValidator.new) @validator = validator end def call(params) return false unless validator.validate(params) process_user_data end attr_reader :validator def process_user_data ... end end
We have just passed our validator object to the service object via a constructor. What we used here is called Dependency Injection. If we choose a validator depending on some user’s attributes, we can create another class for deciding which class we should pick. If we choose a validator depending on context in which we are (various controllers etc.), we just pass the chosen validator to our service object.
Notice that at the same time we have fulfilled the Single Responsibility Principle (we moved extra responsibility to another class). Now, we do not have to modify the original class if we want to add another class for data validation. We just have to create a new validator class and pass it to an updating class in a desired place. That’s all.
The benefits are the same as in the case of the Single Responsibility Principle - code is cleaner, more maintainable and unit testing of such a solution will be much easier. We can test our updating service (just by mocking a validator object response) and validator classes in isolated environments. It is as simple as that.
We made super flexible solution. We are ready to add new validation rule sets without pain. It cost us more work (and time) but we’re happy and proud. So what if you never have to add a new way of validation? Answer is obvious: we wasted time (and probably a money). The decision must be made by the developer based on the requirements, experience and probability of future changes.
There is no silver bullet. In my opinion there are no bad and good solutions. There could be only worse and better solutions.
My philosophy is to stay pragmatic and try to not overcomplicate things more than we have to.
I will touch this topic once again in the end of the whole series.