SOLID 101
Here we go again, talking about this widely repeated subject. Why are so many internet articles bringing the same explanation about these five famous principles? I consider it necessary to repeat it once again, especially after several years as a developer, and I can safely affirm that such a fundamental and straightforward guideline is neglected almost everywhere, even by developers with years in the field, so I decided to share my humble take on this.
A SOLID base
As developers, we (should) aim to write good software that can survive changes in business and technology. If companies recognized the importance of this, they could save a lot of money by avoiding the never-ending cycle that forces them to remake entire projects when it becomes impossible to maintain and support new features.
Why have I used the Egyptian Pyramids for the cover of the article? Well, pyramids have always fascinated me. They’re among the most beautiful examples of engineering, precision, and resilience humankind has ever built. Besides all the controversial efforts put into making such a project a reality, no one can deny that that they only survived through wars and erosion because of their solid foundation.
Also, pyramids are a perfect fit to illustrate SOLID principles; a typical practical implementation is to demonstrate them by interchanging objects representing geometric shapes. I applied all five SOLID principles for crafting this 101, and I’ll cover them individually in detail.
What does SOLID mean?
Robert C. Martin (aka Uncle Bob) formalized these principles in an article published in the year 2000. The SOLID acronym itself was later coined by Michael Feathers. They are nothing more than 5 principles of OOD (object-oriented design), which are techniques for building a software solution based on object concepts, that can be implemented using an OOP (object-oriented programming) language, such as C#.
The 5 SOLID principles are:
S - Single-responsibility principle
A class should have a single responsibility.
O - Open-closed principle
Classes should be open for extension, but closed for modification.
L - Liskov Substitution principle
If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.
I - Interface segregation principle
Clients should not be forced to depend on methods that they do not use.
D - Dependency Inversion principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
SRP - Single-responsibility principle
Don’t build Swiss army knives!
Following the order of the acronym, let’s start with SRP (Single-responsibility principle), but notice how one principle naturally leads to the next.
The main goal of this program is to render different geometric shapes and display their calculated area. It’s as simple as that. So, one can think of writing some ShapeRenderer class containing multiple methods for rendering different shapes: one for a Square, another for a Triangle, another for a Circle, and so on. Also, these methods can calculate the shape area and display it all. Does it look straightforward? Building this Swiss army knife can be tempting but will only be scalable in the short run. Know that many corporate systems are being written like this as you read this article. Now, think about how this could evolve or change to adhere to new requirements. It can become a massive ball of mud very quickly.
SRP is objective and clear: A class should have a single responsibility. The best way to adhere to this principle is to assign duties to specialized shapes and renderer classes. With that mindset, I put the knowledge for calculating areas into the shapes CalculateArea() method and Render() into the renderers.
Notice that a rectangle has attributes like Height and Width, while a circle has Radius. Hence, their area is calculated differently, as is how they’re rendered.
From a mathematical perspective, a square is a rectangle. Making it inherit from a rectangle looks like an obvious approach, but in OO design, this can be problematic and violates LSP. We’ll see why further ahead. For now, knowing that the square and rectangle have different fields and need different renderers, I kept them separated with single responsibilities.
All other classes and services in the project have a single and well-defined responsibility.
OCP - Open-closed principle
Closing a door, opening a window
We saw that although having multiple specialized shapes, they all inherit from the Shape base class (any given shape is one Shape) and share a common but polymorphic method CalculateArea().
The OCP principle states that classes should be open for extension, but closed for modification. We achieve that through extension, and that’s why Shape is an abstract class. CalculateArea() must also be abstract to define that it has no standard implementation, only a common specification. Each shape overrides it with its own logic. The Shape class is closed for modification but open to extension. We can inherit any new shape from the base class without touching it when we need to support a new area calculation.
An alternative to inheritance would be implementing the IShape interface for all shapes with no need for the Shape base class, like I did for all shape renderers. Inheritance for this use-case doesn’t bring any shared implementation; it only establishes a hierarchical structure. However, I wanted to show you can still achieve OCP with a coupled structure like that. This is important because it’s widespread to deal with highly coupled codebases due to abused inheritance, making maintenance hard and adding regression throughout the whole application when changing a core class nobody wants to touch. Either way, having the interface specify the contract for the base class is good practice for loose coupling and allows more flexibility.
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 altering the behavior expected of the supertype.
As seen above, 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: it behaves differently depending on the specific type of shape being operated on. 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.
List<Shape> shapes =
[
new Rectangle(10, 15),
new Square(10),
new EquilateralTriangle(10),
new Circle(5)
];
shapes.ForEach(shape =>
Console.WriteLine("Area: {0}", shape.CalculateArea())
);Remember why I didn’t make a square inherit from a rectangle? Let’s see how it would violate LSP with this hypothetical example.
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public virtual double CalculateArea()
{
return Width * Height;
}
}
public class Square : Rectangle
{
public Square(double side) : base(side, side)
{
}
// No need to override CalculateArea() as it's already inherited.
}
List<Rectangle> rectangles = new()
{
new Rectangle(10, 15),
new Square(10), // Using Square as if it's a Rectangle
};
rectangles.ForEach(rectangle =>
Console.WriteLine("Area: {0}", rectangle.CalculateArea())
);In this altered version, Square inherits from Rectangle. While this might seem logical at first (a square is a special case of a rectangle where all sides are equal), it violates LSP. The inheritance relationship implies you can set different Width and Height values for a square, which is misleading. Substituting a square for a rectangle in code that expects to set those independently would lead to unexpected behavior.
You could assume that using private set for Width and Height and forcing the assignment through the constructor would mitigate the issue. But what if Rectangle suddenly adds a method like this?
public void SetWidth(double newWidth)
{
Width = newWidth;
}
Rectangle rectangle = new Square(5);
rectangle.SetWidth(10);You’d end up with a Square measuring Width=10 and Height=5, which is wrong. This is precisely the kind of unexpected behavior that violates LSP.
ISP - Interface segregation principle
Divide to conquer
ISP (Interface segregation principle) states that clients (classes) shouldn’t be forced to depend upon interfaces they don’t use. This principle is related to SRP in that creating a class to do more than it is supposed to violates its single responsibility. ISP, however, focuses on achieving that through properly segregating interfaces.
In SOLID 101, each shape implements its own dedicated interface. Because all shapes inherit from Shape, which implements IShape, they are all forced to implement CalculateArea(). I’ve done this as part of the intended design, but it’s not always the case, and that’s exactly why ISP matters.
A Rectangle has Height and Width, a Triangle has its SideLength, and a Circle has a Radius. If all these different attributes were defined within IShape, that bloated interface would require all shapes to carry unnecessary properties. The same goes for methods.
C# doesn’t support multiple inheritance. A class can, however, implement multiple interfaces, achieving a similar design. Always segregate your interfaces to define your classes’ exact capabilities.
DIP - Dependency Inversion principle
Let’s plug and play!
Finally, DIP (Dependency Inversion principle). It states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. By high-level modules, think of classes acting as orchestrators that implement some logic by depending on other detail-oriented (low-level) classes.
Using a hypothetical example, imagine a ShapeRenderer (higher-level) class designed to render shapes but depending directly on concretions of (lower-level) classes like RectangleRenderer, CircleRenderer, and TriangleRenderer:
public class ShapeRenderer
{
private RectangularRenderer _rectangularRenderer;
private CircularRenderer _circularRenderer;
private TriangularRenderer _triangularRenderer;
public ShapeRenderer()
{
_rectangularRenderer = new RectangularRenderer();
_circularRenderer = new CircularRenderer();
_triangularRenderer = new TriangularRenderer();
}
public void Render(Shape shape)
{
switch (shape)
{
case Rectangle:
_rectangularRenderer.RenderRectangle(shape);
break;
case Circle:
_circularRenderer.RenderCircle(shape);
break;
case EquilateralTriangle:
_triangularRenderer.RenderTriangle(shape);
break;
default:
break;
}
}
}This ShapeRenderer is tightly coupled to the specialized renderers. Any change to a low-level module forces a change to ShapeRenderer as well. The switch statement also clearly violates OCP, which is closely related to DIP.
DIP also states that abstractions must not depend on details, but details must depend on abstractions. This ShapeRenderer knows every implementation detail of its dependencies. To fix it, we need to decouple the classes and make both high-level and low-level depend on abstractions.
After refactoring, IShapeRenderer is an interface that any renderer can implement to provide a Render(IShape shape) method:
… and so on.
ShapeRendererFactory returns the correct renderer instance based on the shape type:
The only place in the codebase you need to touch to register a new shape type is ShapeRendererExtensions, the static class that registers types to the IServiceCollection provider.
With all that in mind, review your own 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 codebase.
Final thoughts
Look at how many SOLID principles I combined and how clear and meaningful the code becomes at the end. The SOLID principles are our best guidance for designing object-oriented solutions. As with every guide, best practice, or design pattern, use them smartly to avoid unnecessary complexity. It’s also normal to miss one or another principle along the way, and that’s ok as long as you can identify design flaws and refactor when the opportunity comes. The more you practice, the better you’ll get at it.
