Note: this article is published on 06/26/2024
This series of articles will discuss Software Design Principles.
Introduction
The SOLID are long-standing principles used to manage most of the software design problems you encounter in daily programming process.
Whether in designing or developing the application, one can leverage the following advantages of SOLID principles to write code in the right way.
SOLID is a subset of principles promoted by Robert.C.Martin. SOLID is a mnemonic acronym, and each of the letters in it stands for:
Or
We will discuss What SOLID is in this article, and demo the principles in code in the next article: Design Principle (1-1): SOLID in Code Demo. The content of this article will be
- S – Single Responsibility Principle --- SRP
- O – Open/Closed Principle --- OCP
- L – Liskov Substitution Principle --- LSP
- I – Interface Segregation Principle --- ISP
- D – Dependency Inversion Principle --- DIP
1. S - Single Responsibility Principle
The S in solid stands for the “Single Responsibility Principle”. What does this mean? Robert Martin defines a responsibility as “a reason to change”.
“If you can think of more than one motive for changing a class, then that class has more than one responsibility.” i.e.
Each software module should have one and only one reason to change.
When a class has more than one responsibility, there are also more reasons to change that class.
Robert Martin uses the example of a rectangle class that’s responsible for calculating its area and drawing itself on a screen:
In this diagram the Rectangle is used by two different applications — the Graphical Application and the Geometry Application. The problem here is that the Geometry Application never uses the draw() method, but the Graphical Application might use both draw() and area() (and definitely uses draw()).
Robert Martin suggests that this design violates the Single Responsibility Principle and comes with a few nasty problems.
- We have to include the GUI in the Geometry Application, even though we don’t use it, this seems like a large pointless dependency.
- If changes in Graphical Application affect the Rectangle class, this means we would have to rebuild, retest and redeploy the Geometry Application. What if we forget to do this? Our Geometry application might break in unexpected ways.
How can we avoid these problems?
Robert Martin suggests splitting the Rectangle up into two separate classes: a Geometric Rectangle and a Rectangle:
2. O - Open/Closed Principle
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Let us look at an example of how the open-closed principle can be applied. The UML diagram below depicts the relationship between a graphic editor and the shapes it can draw.
When we want to add a new shape for the graphic editor to be able to draw, the method drawShape in the GraphicEditor class has to be modified. Such modification is not desirable as it is changing the existing code in order to add a new functionality. This makes the code hard to maintain as the code has to keep changing with more shapes added. More importantly, it could break the program as new changes may lead to undesirable consequences on other classes that also interact with the GraphicEditor class.
To resolve the issue, we could introduce a layer of abstraction, which is the Shapeinterface, between the GraphicEditor class and the different shape classes that will implement this interface, as shown below.
When a new shape is added, such as an oval, there is no need to modify the code in the GraphicEditor class. Instead, we can create a new class for the oval shape that just has to implement the Shape interface with its own draw method defined.
This implementation method aligns with the open-closed principle as we no longer have to modify any existing code, but instead, we are just extending the code by adding new shape classes that implement the Shape interface whenever new shapes are introduced for the graphic editor to draw.
3. L - Liskov Substitution Principle
Subtypes must be substitutable for their base type.
This statement is equivalent to the following statements that I have gotton from different articles:
- Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. [ref]
- Subclass should behave in a way that is indistinguishable from the superclass. [ref]
- Child class objects should be able to replace parent class objects without compromising application integrity. [ref]
- Derived class objects can replace objects of the base class without modifying its behavior. [ref]
The Liskov principle is most difficult one for me to understand, probably because I did not catch the point until I saw a statement from one article [ref] that
- Child class is a parent class.
I can get the equivalent to this as
- Subclass is a Superclass.
- Derived class is a base class.
then I got the point.
In this example:
- Vehicle, defined as with
- startEngine()
- doMovement()
- fly()
whereas, under this definition:
- car IS NOT a Vehicle --- breaking the Liskov Principle
- Bicycle IS NOT a Vehicle --- breaking the Liskov Principle
- Airplane IS a Vehicle --- following the Liskov Principle
At this point, the following structure is not correct:
it should be redesigned as
4. I - Interface Segregation Principle
Clients should not be forced to depend on methods they do not use.
Example: - `Shape` is an interface with method area() and volume(), - `Square` and `Rectangle` classes are implementing `Shape` interface. - But problem is they don't need the method volume() since they are 2D shapes. It violates ISP. - Hence we need to have a separate interface for 3DShapes and then extend from them.
5. D - Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstraction. The direction of dependency within the application should be in the direction of abstraction, not implementation details.
Most applications are written such that compile-time dependency flows in the direction of runtime execution, producing a direct dependency graph. such as,
- Run Time: if class A calls a method of class B and class B calls a method of class C, then
- Compile time: class A will depend on class B, and class B will depend on class C
Shown as:
Applying the dependency inversion principle allows A to call methods on an abstraction that B implements, making it possible for A to call B at run time, but for B to depend on an interface controlled by A at compile time (thus, inverting the typical compile-time dependency). At run time, the flow of program execution remains unchanged, but the introduction of interfaces means that different implementations of these interfaces can easily be plugged in.
Dependency inversion is a key part of building loosely coupled applications, since implementation details can be written to depend on and implement higher-level abstractions, rather than the other way around. The resulting applications are more testable, modular, and maintainable as a result. The practice of dependency injection is made possible by following the dependency inversion principle.