In this article, I will try to explain simply what the SOLID principles are. Are they essential for building quality software, and should we follow them?

By all means, principles in life are a good thing. They shape borders and do not let us make otherwise avoidable and stupid mistakes.  By definition, principles are fundamental truth or proposition that servers as the foundation for a system of belief or behavior or a chain of reasoning. Yes, you can read it once again 😊.

SOLID is an acronym. Each letter in SOLID stands for an object-oriented design principle. Initially, they were introduced by Martin Fowler. Following each of these principles, we strive for better software design.

SOLID principles:

  • S – Single responsibility principle
  • O – Open/closed principle
  • L – Liskov substitution principle
  • I – Interface segregation principle
  • D – Dependency inversion principle

Single responsibility principle

As simple as it sounds, a class or a module should have only one responsibility. Imagine you are developing a financial system. One of the useful functionalities you have created is a report generator. You are happy. The client is happy. Later during development, the client wants from you to develop a simple notification functionality – every morning, some of the users should receive a specific financial report.

What is the right thing to do? To extend the report generator with that functionality or to develop a new module responsible only for the notification’s management. An inexperienced developer would choose the first (the quick and dirty way), and that is wrong.

Why extending the report module is bad? Mixing responsibilities makes the code hard for change and support.  It is just a matter of time for new functionality to come and does not fit to current the model. What if later the client wants a notification to be sent when some financial operation is completed? OK, but our notifications work only if a report is present… You got the point.

Every module, class, and method should have only one responsibility. The code behind that responsibility should be encapsulated. Consider modules/classes/methods as black boxes. You have an input and an output, but no internal mechanisms are visible outside.

Open/closed principle

Modules, classes, methods should be open for extension but closed for modification.

Why is modification forbidden? Simply because we may have other code that already depends on our code. Imagine our financial system is running for some years, and it has integrations with other systems using REST APIs. Let’s say different mobile applications are using our data for their needs. What will happen if we change the interfaces of our APIs?

This principle is valid on many levels. What if a developer of a popular open-source library changes a couple of public methods? Or John, who is responsible for the development of notifications, changes a constructor of widely used class?

All these examples show us that not only should we not modify already used code, but we should strive to build code that is easy for an extension.

Some of the best practices will explore in a future article.

Liskov substitution principle

This principle comes from Barbara Liskov, and it says:

“In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e., objects of type S may substitute objects of type T) without altering any of the desirable properties of that program (correctness, a task performed, etc.)”

In other words, objects should be replaceable with their subtype instances without breaking the correctness of the program.

And let’s show the well know rectangle example.

There is a Rectangle class with two properties (width and height) and a method returning the area.

public class Rectangle
{
    protected int width;
    protected int height;

     public virtual int Width
     {
    get { return width; }
        set { width = value; }
     }
     public virtual int Height
     {
    get { return height; }
    set { height = value; }
     }

     public int Area()
     {
    return width * height;
     }
}

We also have a Square class that inherits the Rectangle class.

    public class Square : Rectangle
    {
        public override int Width
        {
            get { return width; }
            set
            {
                width = value;
                height = value;
            }
        }
        public override int Height
        {
            get { return height; }
            set
            {
                height = value;
                width = value;
            }
        }
    }

At first glance, everything seems fair, but it is not. With the overridden properties that put single value to both sides (because we want to create a Square), we break the correctness of the program.

If we want to create a Rectangle with a Square instance, the area will be calculated wrong.

Here is an example:

    public class Program
    {
        static void Main(string[] args)
        {
            Rectangle rectangle = new Square();

            rectangle.Width = 5;
            rectangle.Height = 4;
           
            int area = rectangle.Area();
            Console.WriteLine(area);        
        }
    }

Result: 16

In reality, this principle is very tricky to follow. Often you do not have all the information. More often, things change, and new behaviors that do not fit in the established hierarchies are introduced. In such cases, refactor. Don’t try to adapt to bad design.

Interface segregation principle

Behind that fancy wording stands a simple rule. If your class depends on some method (also properties, etc.) but does not need it, then it should not depend on it. See the following example.

From an architectural point of view, the example is terrible but shows very well the current problem we are trying to solve.

We have an interface defining methods for preparing a very tasteless soup 😊. It looks like this.

    public interface IPreparationSoup
    {
        List<Ingridient> Ingridients { get; set; }
        void PutIngridients(List<Ingridient> ingridients);
        void StrirrIngridients();
        void Boil(int timeInMinutes);

    }

And a class that implements that interface:

    public class ChickenSoupPreparation : IPreparationSoup
    {
        public List<Ingridient> Ingridients { get; set; }

        public ChickenSoupPreparation()
        {}

        public void Boil(int timeInMinutes)
        {
            //some cool implementation
        }

        public void PutIngridients(List<Ingridient> ingridients)
        {
            this.Ingridients = ingridients;
        }

        public void StrirrIngridients()
        {
            //some cool implementation
        }
    }

And here comes the problem. What if we want to prepare a gazpacho (gazpacho is a cold soup and does not need boiling)? If we create class GazpachoSoupPreparation class that implements ISoupPreparation, we are going to be forced to implement the Boil method. We do not need that method. It is possible to leave the implementation empty, but this is a very, very bad practice. If we allow this to happen, it will be just a matter of time for our design to get rigid, confusing, and hard for change.

So, we have to segregate our interface. Instead of having one interface (ISoupPreparation), we are going to split it and have two interfaces. We have to move the Boil method to a new interface. The name of the new interface is IBoilable, and it has just one method.

public interface IBoilable
{
   void Boil(int timeInMinutes)
}

We must change the ChickentSoupPreparation class, so it also implements IBoilable.

Now, if we want to prepare a gazpacho, we do not have to implement the Boil method just because we do not depend on IBoilable.

Whenever you are forced to use something that you don’t need to, enforce “divide and conquer”. Split big classes/interfaces to smaller ones and introduce new abstractions.

Dependency inversion principle

The last of the SOLID principles is all about decoupling system modules. The idea behind it is that high-level modules should not depend on low-level modules. All modules should depend on abstractions. And here is the Robert C. Martin (don’t confuse it with Martin Fowler) definition.

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

A simple implementation example. Let’s first see code that does not follow the principle.

We have a class for a temperature sensor. It is represented as follows:

public class Thermometer
{
    public Thermometer() {}
    public Decimal GetValue () {...}
}

Also, we have a machine that uses that sensor—an oven.

public class Oven
{  
    public Thermometer Thermometer {get; private set};
   
    Oven(Thermometer thermometer)
    {
        this.Thermometer = thermometer;
    }
}

And here we have their usage.

public class Program
{
    static void Main(string[] args)
    {
    var thermometer = new Thermometer();
    var oven = new Oven(thermometer);
    oven.Bake();
    }
}

At first glance, the code above doesn’t look that bad, but it is. This design is rigid. High-level modules/classes do not depend on abstractions but concrete implementations. What if we want to change the thermometer with some other sensor implementation? We can’t. The only way to do this is to change the code directly, but this is not OK because we break one more principle – open/closed principle.

According to the principle, both the high- and low-level modules should depend on abstractions. So, let’s introduce some abstractions.

public interface IThermometer
{
   decimal GetValue ();
}

public class Thermometer : IThermometer
{
   public Thermometer() {}
   public decimal GetValue () {...}
}

public interface IOven
{  
    public IThermometer Thermometer {get; private set};
}

public class Oven : IOven
{  
   public IThermometer Thermometer {get; private set};
   
   Oven(IThermometer thermometer)
   {
       this.Thermometer = thermometer;
   }
}

public class Program
{
    private IOven Oven {get; set;}

    static void Main(string[] args)
 
    {  
    this.Oven = oven;
    oven.Bake();
    }
}

What has changed? As you can see, we have introduced some abstractions using interfaces.  Now modules do not depend on each other’s concrete implementations. They depend on contracts defined by the interfaces. In the example above, we assume that an IoC container is used. The responsibility of the IoC container is to register possible implementations for a specific abstraction and resolve them when they are needed.

As you can see, we do not have any more manual creation of objects. Objects are created by the IoC container and injected through the constructor.  For example, if later we want to change the Thermometer, we should create another class that implements IThermometer and register it in the IoC container.

I hope you have noticed how automatically the second principle (open/closed principle) is honored. Our design is closed for change, but it is available for extension.     

Should we follow the SOLID principles?

Sure! SOLID principles are time tested, and they provide a good base for quality software development. But don’t use them blindly. Be sure to understand the reason behind their implementation. Also, never overengineer something just because you follow some best practice or principle.

Introduce additional complexity when you are absolutely sure that this will lead to future simplicity 😊.   

Write A Comment