Home SOLID 101 - Part 2
Post
Cancel

SOLID 101 - Part 2


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.

List<Shape> shapes = new()
{
    new Rectangle(10, 15),
    new Square(10),
    new EquilateralTriangle(10),
    new Circle(5)
};

shapes.ForEach(shape =>
  Console.WriteLine("Area: {0}", rectangle.CalculateArea())
);

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.

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 (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?

public void SetWidth(double newWidth)
{
    Width = newWidth;
}

Rectangle rectangle = new Square(5);
rectangle.SetWidth(10);

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:

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;
    }
  }
}

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.


Check the project on GitHub



This post is licensed under CC BY 4.0 by the author.