In the previous article, I covered the two first SOLID
principles, SRP
, and OCP
. Take it if you still need to read it! The principles are complementary to each other. I will continue and finish it with the remaining LSP
, ISP
and DIP
.
LSP - Liskov substitution principle
They’re all shapes, after all
This principle was formally introduced to the world by Barbara Liskov in 1987. It states that a given instance of a subtype should behave the same way as its supertype. In practice, you could replace an object of a supertype with one of its subtypes without breaking the program or modifying the behavior of the supertype.
As seen in the previous article, an abstract
supertype named Shape
was created to serve as the base class for all shapes. Since each shape overrides CalculateArea()
, the method is polymorphic, meaning it behaves differently depending on the specific type of shape being operated on. Each shape type overrides it to provide its own implementation. Polymorphism is a key aspect of LSP as it allows different subclasses to be substituted for their base class without altering the program’s behavior.
Remember from Part I when I mentioned why I didn’t make a square inherit from a rectangle? Let’s check how it would violate LSP
with this hypothetical example.
In this altered version, square inherits from rectangle. While this might seem logical at first (since a square is a special case of a rectangle where all sides are equal), it violates the Liskov Substitution Principle. While in this implementation you cannot set different width and height values for a square, the inheritance relationship implies that such behavior is possible, which is misleading and violates expectations. Also, substituting a square for a rectangle in some scenarios that expect to set different width and height for a rectangle would lead to unexpected behavior if using a square instead.
You could assume that having private set
modifiers for Width and Height and forcing the assignment through the constructor would mitigate the issue, ensuring that a square would have the same values for both Width and Height, but what if rectangle suddenly implements a method like this?
Then you would end up with a Square measuring Width=10
and Hight=5
, which is wrong. This is precisely the kind of unexpected behavior that can occur when violating the Liskov Substitution Principle
ISP - Interface segregation principle
Divide to conquer
ISP
states that clients (classes) shouldn’t be forced to depend upon interfaces they don’t use. This principle is somehow related to SRP
in that creating a class to do more than it is supposed to violate its single responsibility and meaning to exist. ISP
, however, focuses on achieving that by properly segregating interfaces
, which a class might or not implement.
For example, with SOLID 101
, you can notice that each one of these shapes is implementing an interface respectively. Still, because all shapes inherit from the Shape
class, which implements the IShape
interface, it forces all of them to implement the CalculateArea()
method. I’ve done this as part of the design I intended, but it’s not always the case, and that’s why ISP
is so important.
A Rectangle
has Height and Width, a Triangle
has Sides, and a Circle
has a Radius. If all these different attributes were defined within IShape
interface, that bloated interface would require all the shapes to carry unnecessary attributes. The same goes for methods.
As you may know, C# doesn’t support multiple inheritances
. However, a class can implement numerous interfaces and achieve a similar design. Always segregate your interfaces to define your classes’ exact capabilities.
DIP -Dependency Inversion principle
Let’s plug and play!
Finally, the last of the five SOLID
principles,DIP
. It firstly states that High-level modules should not depend on low-level modules. Instead, both should depend on abstractions. By high-level modules, we can think, for instance, of classes acting as orchestrators of actions for implementing any logic, and to do so, they depend on other detail-oriented
classes or low-level modules.
Using a hypothetical example within the context of SOLID 101
, imagine a ShapeRenderer
(higher-level) class designed to render the shapes, but depending on concretions of (lower-level) classes such as RectangleRenderer
, CircleRenderer
, TriangleRenderer
directly, like this:
Looking at the example above, we can tell that this ShapeRenderer and the specialized renderers composing it have a high-coupling
or dependency, which can be very concerning because, if for any reason we make changes on the low-level modules, it will require changes on the ShapeRenderer as well for not breaking it once it depends on concretions to work. Also, the switch statement inside the Render method clearly indicates violating the OCP - Open-closed principle
, which is closely related to the Dependency Inversion Principle.
DIP
also states that an abstraction must not depend upon details, but the details must depend upon abstractions. Abstractions differ from concretions. When abstracting something in programming, we want to hide the implementation details from the consumer. Instead, this ShapeRenderer class knows the details of its dependencies from their methods.
That all combined violates DIP
, and to fix it we need to refactor it aiming:
- Loose coupling between the classes.
- Make both high-level and low-level depend on abstractions.
After changing many points, I now have IShapeRenderer
interface where many renderers can implement to have their Render(IShape shape)
method similarly, like below:
… and so on.
Furthermore, ShapeRendererFactory
can return the instance of the renderer according to the shape type:
With all that combined, the only place in the codebase where you need to change to register a new shape type is the ShapeRendererExtensions
static class, which registers types to the IServiceCollection
provider.
Now that you acknowledge this principle, review your projects and look for new
keyword occurrences, dangerous inheritance, and poor composition. The more you find, the higher your chances of having a high-coupling code.
Final thoughts
Look at how many SOLID principles I could combine so far and how clear and meaningful the code becomes at the end. The SOLID principles are our best guidance for designing an excellent object-oriented solution. As with every guide, best practice, or design pattern, you must consider using it smartly to avoid unnecessary complexity. It’s also normal if we miss one or another principle, and that’s ok as long as you can identify design flaws and refactor the code when you can. The more you practice it, the better you’ll get at it.