SOLID principles are 5 guidelines to follow when designing object-oriented software. They are basically best practices that enable us to create more manageable, understandable and flexible software. Thanks to the SOLID principles, as our applications grow in size, we can keep their complexity under control and save ourselves a lot of headaches.
Let’s look at these principles in detail.
The word SOLID is an acronym whose meaning is:
- S: Single responsibility principle.
- O: Open-closed principle.
- L: Liskov substition principle.
- I: Interface segregation principle.
- D: Dependency inversion principle.
S – Single responsibility principle
The single responsibility principle states that each class should have one and only one responsibility, fully encapsulated within it. In other words, each class should have one and only one function, and for that reason it should have only one reason to change.
For example, take the Book class that models a book. Below, I report both a version in C# and in LabVIEW.
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
public int Pages { get; set; }
// Contructors
}
Now, let’s imagine that we need to display the book data (on a console for the C# version and with a Message Box for the LabVIEW version). We then add the ShowInfo method to the Book class. We get this:
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
public int Pages { get; set; }
public Book(string title, string author, int pages)
{
this.Title = title;
this.Author = author;
this.Pages = pages;
}
public void PrintInfo()
{
Console.WriteLine($"{ this.Title }, {this.Author}");
}
}
However, this approach breaks the principle of single responsibility. In fact, the Book class now has two functionalities/responsibilities: modeling the book entity and displaying information on the screen. If we pause to think about the possible evolutions of this simple class, we will find that there are two reasons to change it:
- Changing the data model, that is if the data modeled by the Book class changes. Think, for example, of having to add other attributes such as the ISBN and publication date.
- Changing the output modes for the book data, e.g., if new output modes need to be added or existing ones need to be changed.
To return to the single responsibility principle, we should implement a separate class that deals only with the output of the book data. Let us therefore add the class BookPrinter:
public class BookPrinter
{
public void ShowInfoInConsole(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
public void ShowInfoInAnotherWay(Book book)
{
// Another way to output book info
}
}
In this way, not only have we developed a class that relieves the book of its output management duties, but we can also leverage the BookPrinter class to send the book information to other outputs such as files, e-mail, or other.
Adherence to the single responsibility principle brings the following benefits:
- There is less coupling. A class with single functionality allows fewer dependencies.
- Small, well-organized classes make the code easier to maintain.
- Classes are easier to extend with new functionalities because unrelated code is not in the same class.
- Dependencies between classes are easier to manage because the code is better grouped.
- Classes are smaller thus improving code readability.
- Debugging is easier because with small, compact classes it is easier to identify bugs in the code.
- Onboarding a new team member is easier because the code is well organized and easy to understand.
O – Open/closed principle
The open/closed principle states that classes should be open to extensions but closed to modifications. Modification means changing the code of an existing class and extension means adding new functionalities to it. Not modifying existing code avoids the potential introduction of new bugs. Of course, the only exception to the rule is when fixing bugs in the existing code.
So, according to what this principle states, we should be able to add new functionalities without touching the existing code for the class. This is because whenever we modify existing code, we run the risk of creating potential bugs. We should therefore, if possible, avoid touching code that is already tested and in use.
But how do we add new functionalities without touching the class? Usually with the help of interfaces and abstract classes.
Let’s go back to the previous example. The BookPrinter class does not respect the open/closed principle. This is because, if we wanted to change how to output the data of the Book class, we would have to change the BookPrinter class itself. This is the BookPrinter class as we left it:
public class BookPrinter
{
public void ShowInfoInConsole(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
public void ShowInfoInAnotherWay(Book book)
{
// Another way to output book info
}
}
How to refactor the BookPrinter class to also satisfy the open/closed principle? We can make the BookPrinter class an interface and then create a class that handles console output and satisfies the new BookPrinter interface.
The new BookPrinter interface defines the Send method used to output the book data. In addition, we have the new BookConsole class (BookMsgBox for the LabVIEW example) that satisfies the BookPrinter interface. Here is the code:
public interface BookPrinter
{
public void Send(Book book);
}
public class BookConsole : BookPrinter
{
public void Send(Book book)
{
Console.WriteLine($"{book.Title}, {book.Author}");
}
}
If the need to write book data to a text file arises in the future, the existing classes will not be changed, but a new BookLog class, that also satisfies the BookPrinter interface, will be created :
public class BookLog : BookPrinter
{
public void Send(Book book)
{
// Log book data on text file
}
}
The use of the BookPrinter interface thus allows the software to be extended with new functionalities without changing the existing classes and entities. Simply put, the program does not depend on the concrete class but on the BookPrinter interface. Thus, it is possible to modify the behavior of the software by adding classes that satisfy the aforementioned interface. The operation of instantiating the concrete class can be done through a dependency injection mechanism.
L – Liskov substitution principle
The Liskov substitution principle states that objects should be able to be replaced with subtypes of their own, without altering the behavior of the program using them. This means that since class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A, and the method should not give any strange output in that case.
This is the expected behavior, because when we use inheritance we assume that the child class inherits everything the superclass has. The child class extends the behavior but never restricts it. Therefore when a class does not obey this principle, it leads to bugs that are difficult to detect.
Let’s look at an example. We have a base class Bird and the child class Mockingbird. The Mockingbird class overrides the Fly method:
public class Bird
{
public virtual void Fly() { }
}
public class Mockingbird : Bird
{
public override void Fly()
{
Console.WriteLine("I'm flying!");
}
}
But what if we also had the Kiwi class (which does not fly)?
public class Kiwi : Bird
{
public override void Fly()
{
throw new Exception("I cannot fly");
}
}
This violates Liskov’s substitution principle because using the Kiwi class within the program could create malfunctions and unexpected behaviors.
In our case, one possible approach to comply with Liskov’s principle is to insert the intermediate class FlyingBirds:
public class Bird { }
public class FlyingBird : Bird
{
public virtual void Fly() { }
}
public class Mockingbird : FlyingBird
{
public override void Fly()
{
Console.WriteLine("I'm flying!");
}
}
public class Kiwi : Bird { }
With this new architecture, if the client program uses an instance of the Bird class, it cannot use the Fly() method. In this case passing the MockingBird or Kiwi class does not create unexpected behaviors. On the other hand, if the client program uses the FlyingBirds object, even if the MockingBird object is passed to it, the program should work the same way. In this case, the Kiwi object cannot be passed because it is not a subclass of FlyingBirds.
I – Interface segregation principle.
The interface segregation principle states that many specific interfaces are better than one generic interface. According to this principle, classes should not be forced to implement a function they do not need. Thus, a class should not depend on methods that it does not use. It is therefore preferable for interfaces to be numerous, specific and small (consisting of a few methods) rather than few, general and large. This approach allows each class to depend on a minimal set of methods, namely those belonging to the interfaces it actually uses. According to this principle, an object should typically implement numerous interfaces, one for each role that the object plays in different contexts or in different interactions with other objects.
Suppose we implement an ordering service where the customer can order the first course, second course, and dessert. We then decide to put all the methods for ordering in the OrderService interface only:
public interface OrderService
{
public void OrderStarter(string order);
public void OrderSecondCourse(string order);
public void OrderDessert(string order);
}
Now suppose we have a special promotion for ordering only the first course. We then create the StarterOnlyService class:
public class StarterOnlyService : OrderService
{
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: { order }");
}
public void OrderSecondCourse(string order)
{
throw new Exception("No second course in StarterOnlyService");
}
public void OrderDessert(string order)
{
throw new Exception("No dessert in StarterOnlyService");
}
}
The StarterOnlyService class supports only the ordering of the first course but, by implementing the OrderService interface, we are forced to implement the remaining methods (which generate an exception) as well. It seems rather obvious that this solution violates the principle of interface segregation.
We can then refactor our ordering system by implementing a specific interface for each ordering mode:
public interface OrderStarter
{
public void OrderStarter(string order);
}
public interface OrderSecondCourse
{
public void OrderSecondCourse(string order);
}
public interface OrderDessert
{
public void OrderDessert(string order);
}
The StarterOnlyService class will implement only the interfaces it really needs to implement. In this case the OrderStarter interface only:
public class StarterOnlyService : OrderStarter
{
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: { order }");
}
}
In the event, however, that we need to handle full ordering, we will have a second class that will implement all the three interfaces:
public class OrderFull : OrderStarter, OrderSecondCourse, OrderDessert
{
public void OrderDessert(string order)
{
Console.WriteLine($"Received order of dessert: {order}");
}
public void OrderSecondCourse(string order)
{
Console.WriteLine($"Received order of second course: {order}");
}
public void OrderStarter(string order)
{
Console.WriteLine($"Received order of started: {order}");
}
}
In this way, with small and specific interfaces, we respected the principle of interface segregation.
D – Dependency inversion principle.
The dependency inversion principle refers to the decoupling of software modules. According to this principle, high-level modules must not depend on low-level modules. Both must depend on abstractions. Abstractions, in turn, should not depend on details but it is the details that depend on abstractions.
This sounds like a complicated concept but really the gist is that classes should depend on interfaces or abstract classes instead of concrete classes and functions. Let’s look at an example.
Suppose we have a testbench application that saves the results of performed tests on a mySQL database. To do this, we create the TestReport class and the mySQLDatabase class:
public class TestReport
{
private MySQLDatabase database;
public TestReport(MySQLDatabase db)
{
this.database = db;
}
public void SaveReport()
{
this.database.Save();
}
}
public class MySQLDatabase
{
public void Save()
{
// Save data
}
}
Everything works fine, but this code violates the dependency inversion principle because our high-level TestReport class depends on the low-level MySQLDatabase module. This also violates the open-closed principle because if we wanted a different kind of database or more generally persistency we would have to modify a little bit all the classes involved.
To solve this problem and respect the dependency inversion principle, we use an abstraction. We therefore create a Persistency interface. The MySQLDatabase class will satisfy this interface. At the same time the TestReport class will depend on the Persistency abstraction and no longer on the concrete MySQLDatabase class. Let us see its new structure:
public class TestReport
{
private Persistency persistency;
public TestReport(Persistency persistency)
{
this.persistency = persistency;
}
public void SaveReport()
{
this.persistency.Save();
}
}
public interface Persistency
{
public void Save();
}
public class MySQLDatabase : Persistency
{
public void Save()
{
Console.WriteLine("Saved in mySQL DB");
}
}
With this approach, due to the use of the abstraction provided by the Persistency interface, there is no longer direct coupling between the TestReport and MySQLDatabase classes.
Conclusions
In this article, we have seen the SOLID design principles. We then looked at each principle, each with a related example. Within common sense, my suggestion is to keep these principles in mind when designing, writing and refactoring code so that the code is much cleaner and extensible.
I would say that is all for now. I would like to thank you for taking the time to read this article and I hope that the concepts outlined are sufficiently clear. In case of doubts, requests or simple curiosities, you can find me here.
See you in the next post.