Writing a Flexible Code Using SOLID

From a translator: Severin Peres published an article on the use of SOLID principles in programming for you. The information from the article will be useful for both beginners and programmers with experience.
If you are a developer, you probably heard about SOLID principles. They enable the programmer to write clean, well-structured and easy-to-maintain code. It is worth noting that in programming there are several approaches to how to properly perform this or that work. Different specialists have different ideas and understanding of the “right way”, it all depends on each person’s experience. Nevertheless, the ideas proclaimed in SOLID are accepted by almost all representatives of the IT community. They became the starting point for the emergence and development of many good development management practices.
Let's see what SOLID principles are and how they help us.
Skillbox recommends: Practical course "Mobile Developer PRO" .
We remind you: for all readers of “Habr” - a discount of 10,000 rubles when registering for any Skillbox course using the “Habr” promo code.
What is SOLID?
This term is an abbreviation, each letter of the term is the beginning of the name of a certain principle:
- S ingle Responsibility Principle. A module can have one and only one reason for change.
- The O pen / Closed Principle . Classes and other elements must be open for expansion, but closed for modification.
- The L iskov Substitution Principle . Functions that use the base type should be able to use subtypes of the base type without knowing it.
- The I nterface Segregation Principle . Software entities should not depend on methods that they do not use.
- The D ependency Inversion Principle . Upper level modules should not depend on lower level modules.
Principle of sole responsibility
The principle of single responsibility (SRP) states that each class or module in a program should be responsible for only one part of the functionality of this program. In addition, elements of this responsibility should be assigned to their class, and not distributed among unrelated classes. SRP's developer and lead evangelist, Robert S. Martin, describes responsibility as the cause of change. Initially, he proposed this term as one of the elements of his work, “Principles of Object-Oriented Design”. The concept included much of the pattern of connectivity that was previously defined by Tom Demarco.
The concept also included several concepts formulated by David Parnassus. The two main ones are encapsulation and information hiding. Parnassus argued that dividing a system into separate modules should not be based on an analysis of flowcharts or execution flows. Any of the modules should contain a specific solution that provides a minimum of information to customers.
By the way, Martin gave an interesting example with senior managers of the company (COO, CTO, CFO), each of whom uses specific software for business with a different purpose. As a result, any of them can implement changes in the software without affecting the interests of other managers.
Divine object
As usual, the best way to learn SRP is to see everything in action. Let's look at a section of a program that does NOT comply with the principle of shared responsibility. This is the Ruby code that describes the behavior and attributes of a space station.
Review the example and try to determine the following:
Responsibilities of the objects that are declared in the SpaceStation class.
Those who may be interested in the work of the space station.
class SpaceStation
def initialize
@supplies = {}
@fuel = 0
end
def run_sensors
puts "----- Sensor Action -----"
puts "Running sensors!"
end
def load_supplies(type, quantity)
puts "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
end
end
def report_supplies
puts "----- Supply Report -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "#{type} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
def load_fuel(quantity)
puts "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def report_fuel
puts "----- Fuel Report -----"
puts "#{@fuel} units of fuel available."
end
def activate_thrusters
puts "----- Thruster Action -----"
if @fuel >= 10
puts "Thrusting action successful."
@fuel -= 10
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
Actually, our space station is non-functional (I think that I will not receive a call from NASA in the near foreseeable future), but there is something to analyze.
So, the SpaceStation class has several different responsibilities (or tasks). All of them can be divided into types:
- Sensors
- supply (consumables);
- fuel;
- accelerators.
Despite the fact that none of the station employees is defined in the class, we can easily imagine who is responsible for what. Most likely, the scientist controls the sensors, the logistician is responsible for the supply of resources, the engineer is responsible for fuel supplies, and the pilot controls the accelerators.
Can we say that this program is not SRP compliant? Oh sure. But the SpaceStation class is a typical “divine object” that knows everything and does everything. This is the main anti-pattern in object-oriented programming. For a beginner, such objects are extremely difficult to maintain. So far, the program is very simple, yes, but imagine what will happen if we add new features. Perhaps our space station will need a medical center or meeting room. And the more features there are, the more SpaceStation will grow. Well, since this object will be connected with others, the maintenance of the entire complex will become even more complicated. As a result, we can disrupt the work of, for example, accelerators. If a researcher asks for changes in working with sensors, then this may well affect the communication systems of the station.
Violation of the SRP principle can give a short-term tactical victory, but in the end we will “lose the war”, servicing such a monster in the future will be very difficult. It is best to divide the program into separate sections of code, each of which is responsible for performing a specific operation. With this in mind, let's change the SpaceStation class.
Distribute Responsibility
Above, we identified four types of operations that are controlled by the SpaceStation class. When refactoring, we will keep them in mind. Updated code matches SRP better.
class SpaceStation
attr_reader :sensors, :supply_hold, :fuel_tank, :thrusters
def initialize
@supply_hold = SupplyHold.new
@sensors = Sensors.new
@fuel_tank = FuelTank.new
@thrusters = Thrusters.new(@fuel_tank)
end
end
class Sensors
def run_sensors
puts "----- Sensor Action -----"
puts "Running sensors!"
end
end
class SupplyHold
attr_accessor :supplies
def initialize
@supplies = {}
end
def load_supplies(type, quantity)
puts "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
end
end
def report_supplies
puts "----- Supply Report -----"
if @supplies.keys.length > 0
@supplies.each do |type, quantity|
puts "#{type} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
end
class FuelTank
attr_accessor :fuel
def initialize
@fuel = 0
end
def get_fuel_levels
@fuel
end
def load_fuel(quantity)
puts "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Fuel Action -----"
puts "Using #{quantity} units of fuel from the tank."
@fuel -= quantity
end
def report_fuel
puts "----- Fuel Report -----"
puts "#{@fuel} units of fuel available."
end
end
class Thrusters
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Thruster Action -----"
if @linked_fuel_tank.get_fuel_levels >= 10
puts "Thrusting action successful."
@linked_fuel_tank.use_fuel(10)
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
There are many changes, the program now looks definitely better. Now our SpaceStation class has become, rather, a container in which operations for dependent parts are initiated, including a set of sensors, a supply system for consumables, a fuel tank, and boosters.
For any of the variables now there is a corresponding class: Sensors; SupplyHold; FuelTank Thrusters.
There are several important changes to this version of the code. The fact is that individual functions are not only encapsulated in their own classes, they are organized in such a way as to become predictable and consistent. We group elements similar in functionality to follow the principle of connectivity. Now, if we need to change the principle of the system by switching from a hash structure to an array, just use the SupplyHold class, we won’t have to touch other modules. Thus, if the officer in charge of logistics changes something in his section, the remaining elements of the station will remain untouched. At the same time, the SpaceStation class will not even be aware of the changes.
Our space station officers are likely to be happy with the changes, because they can request the ones they need. Note that the code has methods such as report_supplies and report_fuel contained in the SupplyHold and FuelTank classes. What happens if the Earth asks to change the way reports are generated? You will need to change both classes, SupplyHold and FuelTank. But what if you need to change the way you deliver fuel and consumables? You may have to change all the same classes again. And this is a violation of the SRP principle. Let's fix it.
class SpaceStation
attr_reader :sensors, :supply_hold, :supply_reporter,
:fuel_tank, :fuel_reporter, :thrusters
def initialize
@sensors = Sensors.new
@supply_hold = SupplyHold.new
@supply_reporter = SupplyReporter.new(@supply_hold)
@fuel_tank = FuelTank.new
@fuel_reporter = FuelReporter.new(@fuel_tank)
@thrusters = Thrusters.new(@fuel_tank)
end
end
class Sensors
def run_sensors
puts "----- Sensor Action -----"
puts "Running sensors!"
end
end
class SupplyHold
attr_accessor :supplies
attr_reader :reporter
def initialize
@supplies = {}
end
def get_supplies
@supplies
end
def load_supplies(type, quantity)
puts "----- Supply Action -----"
puts "Loading #{quantity} units of #{type} in the supply hold."
if @supplies[type]
@supplies[type] += quantity
else
@supplies[type] = quantity
end
end
def use_supplies(type, quantity)
puts "----- Supply Action -----"
if @supplies[type] != nil && @supplies[type] > quantity
puts "Using #{quantity} of #{type} from the supply hold."
@supplies[type] -= quantity
else
puts "Supply Error: Insufficient #{type} in the supply hold."
end
end
end
class FuelTank
attr_accessor :fuel
attr_reader :reporter
def initialize
@fuel = 0
end
def get_fuel_levels
@fuel
end
def load_fuel(quantity)
puts "----- Fuel Action -----"
puts "Loading #{quantity} units of fuel in the tank."
@fuel += quantity
end
def use_fuel(quantity)
puts "----- Fuel Action -----"
puts "Using #{quantity} units of fuel from the tank."
@fuel -= quantity
end
end
class Thrusters
FUEL_PER_THRUST = 10
def initialize(fuel_tank)
@linked_fuel_tank = fuel_tank
end
def activate_thrusters
puts "----- Thruster Action -----"
if @linked_fuel_tank.get_fuel_levels >= FUEL_PER_THRUST
puts "Thrusting action successful."
@linked_fuel_tank.use_fuel(FUEL_PER_THRUST)
else
puts "Thruster Error: Insufficient fuel available."
end
end
end
class Reporter
def initialize(item, type)
@linked_item = item
@type = type
end
def report
puts "----- #{@type.capitalize} Report -----"
end
end
class FuelReporter < Reporter
def initialize(item)
super(item, "fuel")
end
def report
super
puts "#{@linked_item.get_fuel_levels} units of fuel available."
end
end
class SupplyReporter < Reporter
def initialize(item)
super(item, "supply")
end
def report
super
if @linked_item.get_supplies.keys.length > 0
@linked_item.get_supplies.each do |type, quantity|
puts "#{type} avalilable: #{quantity} units"
end
else
puts "Supply hold is empty."
end
end
end
iss = SpaceStation.new
iss.sensors.run_sensors
# ----- Sensor Action -----
# Running sensors!
iss.supply_hold.use_supplies("parts", 2)
# ----- Supply Action -----
# Supply Error: Insufficient parts in the supply hold.
iss.supply_hold.load_supplies("parts", 10)
# ----- Supply Action -----
# Loading 10 units of parts in the supply hold.
iss.supply_hold.use_supplies("parts", 2)
# ----- Supply Action -----
# Using 2 of parts from the supply hold.
iss.supply_reporter.report
# ----- Supply Report -----
# parts avalilable: 8 units
iss.thrusters.activate_thrusters
# ----- Thruster Action -----
# Thruster Error: Insufficient fuel available.
iss.fuel_tank.load_fuel(100)
# ----- Fuel Action -----
# Loading 100 units of fuel in the tank.
iss.thrusters.activate_thrusters
# ----- Thruster Action -----
# Thrusting action successful.
# ----- Fuel Action -----
# Using 10 units of fuel from the tank.
iss.fuel_reporter.report
# ----- Fuel Report -----
# 90 units of fuel available.
In this latest version of the program, responsibilities were split into two new classes, FuelReporter and SupplyReporter. They are both children of the Reporter class. In addition, we added instance variables to the SpaceStation class in order to initialize the necessary subclass if necessary. Now, if the Earth decides to change something else, then we will make changes to subclasses, and not to the main class.
Of course, some classes here still depend on each other. So, the SupplyReporter object depends on the SupplyHold, and the FuelReporter depends on the FuelTank. Of course, boosters should be connected to the fuel tank. But here everything looks logical, and making changes will not be especially difficult - editing the code of one object will not affect the other too much.
Thus, we created modular code where the responsibilities of each of the objects / classes are precisely defined. Working with such code is not a problem; its maintenance will be a simple task. The entire "divine object" we have converted to SRP.
Skillbox recommends:
- Two-year practical course "I am a PRO web developer . "
- Online course "C # Developer with 0" .
- Practical annual course "PHP-developer from 0 to PRO" .