S.O.L.I.D. Design Principles.

Jayaprakash S TJayaprakash S T
14 min read

The solid principles are a set of best practices, transformed into a set of rules after dozens of years of cumulative development experience around the world done by software professionals. Those are the insights that senior developers reached after decades of developing complex, enterprise-grade software. Although popularized and named by Robert C. Martin they were known and used by lots of senior developers around the world

SOLID is a set of five design principles. These principles help software developers design robust, testable, extensible, and maintainable object-oriented software systems.

Each of these five design principles solves a particular problem that might arise while developing software systems. the purpose of those principles is to allow developers to write better software.

What We'll Cover

SOLID is an acronym that stands for:  (S) The Single Responsibility Principle.   (O) The Open/Closed Principle.  (L) The Liskov Substitution Principle.  (I) The Interface Segregation Principle.  (D) The Dependency-Inversion Principle.

In the coming sections, we’ll look at what each of those principles means in detail.

The SOLID design principles were introduced by Robert C. Martin, also known as "Uncle Bob", in his paper "Design Principles and Design Patterns" in 2000. But the acronym was coined later by Michael Feathers.

The SOLID Principles are a set of Object-Oriented design principles that have revolutionized how we write software. They are a collection of guidelines and best practices for writing clean, flexible, reusable, scalable, and maintainable code.

S.O.L.I.D. Design principles suggest that the Individual pieces/building blocks of software should be of solid quality and highly accurate in design. For e.g. build blocks of rockets or Formula 1 cars. The high-quality software should follow principles of SOLID design principles by Martin R Fowler. The solid principles depend on the following principles

1. Agile Software Development

2. You Aren’t Gonna Need it

3. Keep it simple stupid

4. Vertical slice

5. Big Ball of mud.

Design Patterns and Policies

Design Pattern is the solution to common problems encountered at the software design level where the reusability of code is very easy and flexible. Learning of Design Patterns and applying them in a production environment needs lots of practice in developing software and experiencing software programming. So many beginners overuse design patterns or use the wrong design pattern.

The object-oriented design implementation should be mostly composed of interfaces and abstract classes between two concrete implementations, this helps in loose coupling of dependency and software is less rigid for change in software requirements. Otherwise, it will be similar to removing the windscreen when we need to change the steering wheel of the car.

Hence interface-based programming is preferred over concrete class-based programming for high-quality development of the software.

What are SOLID Design Principles?

SOLID is an acronym that stands for:

  • (S) The Single Responsibility Principle.

  • (O) The Open/Closed Principle.

  • (L) The Liskov Substitution Principle.

  • (I) The Interface Segregation Principle.

  • (D) The Dependency-Inversion Principle.

I have briefly explained the 5 standard S.O.L.I.D. design principles below

THE SINGLE RESPONSIBILITY PRINCIPLE

A class should have only one reason to change. In other words, the Single Responsibility principle is defined as “THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE.”

public interface Socket
{
public void Connect(string pno);
public void Disconnect();
public void SendData(char c);
public char RecvData ();
}

Single Responsibility Principle applied to Socket Interface to have separated responsibilities, One responsibility is Connection to the port and Disconnection to the port. The second responsibility is the communication of Data with the server port.

Here’s an example in JavaScript:

class Animal {
    constructor(name) {
    this.name = name;
    }

    nomenclature() {
    console.log(`The name of the animal is ${this.name}`);
    }
}

let animal1 = new Animal('COW');
animal1.nomenclature(); // The name of the animal is COW

// Sound class
class Sound {
    constructor(name, soundMade) {
    this.name = name;
    this.soundMade = soundMade;
    }

    sound() {
    console.log(`${this.name} ${this.soundMade}s`);
    }
}

let animalSound1 = new Sound('COW', 'moows');
animalSound1.sound(); //COW moows

// Feeding class
class Feeding {
    constructor(name, feedingType) {
    this.name = name;
    this.feedingType = feedingType;
    }

    feeding() {
        console.log(`${this.name} is a/an ${this.feedingType}`);
    }
}

let animalFeeding1 = new Feeding('COW', 'herbivore');
animalFeeding1.feeding(); // COW is a/an herbivore

This way, each of the classes is doing only one thing:

  • the first one prints the name of the animal

  • the second prints the kind of sound it makes

  • and the third one prints its kind of feeding.

That’s more code, but better readability and maintainability. A developer who didn’t write the code can come to it and understand what’s going on quicker than having it all in one class.

THE OPEN/CLOSED PRINCIPLE (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Modules that conform to the open-closed principle have two primary attributes.

They are “Open For Extension”.

They are “Closed for Modification”.

In most design scenarios, the Open Closed principle is at the core of object-oriented design. Adherence to this principle is what yields the greatest benefits of object-oriented methodology; i.e. reusability and maintainability.

Here the Render Interface is both an open and closed interface, as Render Implementation uses different implementations of render depending on the device on which the page is being displayed. So a Render instance uses RenderPage Interface which is closed w. r. t. adding new methods or changing the render methods but it is open to using the derivative of Display device instances.

Extension methods and static classes are examples of violations of SOLID principles. All third-party code should be encapsulated and isolated from developer code, so third-party code can be later replaced by other pieces of code as new versions of code are generated. Method parameters should be interface type not specific class type.

There are two types of Open-Closed Principle Implementation, namely

Meyers open-closed principle: Here developers are allowed to modify the class design only to fix the bugs and developers are prohibited from changing the behavior or state of the class.

Polymorphic open-closed principle: Apart from maintaining the behavior and state of the class developer should make all the variables private and avoid the Global variables.

Let us understand this principle with an example. Suppose we have implemented an 'Ricecooker' class.

public class Ricecooker {
private String make;
private String model;
private int temperature;
private int time;
//Constructors, getters & setters
}

We launch this app, but after some time, we want to add a steaming feature to this ricecooker. We can edit this ricecooker class, but it may mess up the entire codebase. So, the open-closed principle is against this. Instead, we should extend this class :

public class RiceCookerWithSteaming extends Ricecooker {
private String make;
private String model;
private int temperature;
private int time;
bool isSteamOn;
//Constructors, getters & setters
}

THE LISKOV SUBSTITUTION PRINCIPLE

Subtypes must be substitutable for their base types when inject into the functions.

In other words, we can define Liskov Substitution Principle as follows:

FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.

According to Barbara Liskov and Jeannette Wing, the Liskov substitution principle states that :

Simply put, it extends the Open/Closed principle by allowing you to replace parent class objects with subclass objects without destroying the application. This necessitates that all subclasses behave similarly to the parent class.

To accomplish this, the subclasses must follow the following rules :

The validation requirements for input parameters of the child class should not be tighter than those of the parent class.

The same rules apply to all output parameters that the parent class does.

In other words,

The Liskov Substitution Principle is one of the prime enablers of OCP. The substitutability of subtypes allows a module, expressed in terms of a base type, to be extensible without modification. That substitutability must be something that developers can depend on implicitly. Thus, the contract of the base type has to be well and prominently understood, if not explicitly enforced, by the code.

The term IS-A is too broad to act as a definition of a subtype. The true definition of a subtype is substitutable, where substitutability is defined by either an explicit or implicit contract. Functions that use pointers and reference base classes as parameters are substituted by subclass instances without knowledge of the functions that will process them. This substitution is applied through Polymorphism usually by the developer who is following Test Driven Design methodologies; One care must be that code implementing Liskov Substitution Principle should avoid Run Time Type Information.

E.g.1. Inheritance of Line and LineSegment is the case of LSP.

In the case of the Line and LineSegment, a simple solution illustrates an important tool of OOD. If we have access to both the Line and LineSegment classes, we can factor the common elements of both into an abstract base class LinearObject.

E.g.2. Peel method for Oranges is different than the Peel method for apples. But the Peel method is common and can be used as an interface method.

E.g.3. Similarly width method is common for Squares and Rectangles.

THE INTERFACE SEGREGATION PRINCIPLE

The I in SOLID design principles stands for interface segregation, which indicates that bigger interfaces should be divided into smaller ones. By doing so, we can ensure that implementing classes is only concerned with the methods that are relevant to them.

Clients must never be forced to implement an interface they do not use, nor should they be forced to rely on methods they do not use. For example, implementing a volume interface does not make sense for the Square or Circle classes, which are two-dimensional shapes.

Clients should not be forced to depend on methods they do not use. In other words Integration Segregation Principle is defined as follows:

CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE.

For e.g.

Public interface UserLogin {
Public string Username;
Public string Password
Public string role;
Public string authorization;
}

So for a user to log in you would require only a username and password and nothing more. So the above interface needs to be broken into related interfaces and then used as two instances for different responsibilities

Public interface userLogin {
Public string getusername();
Public string getpassword();
}
Public interface Authorization {

Public string role;

Public string authorization;

Public string group;

}
Public class user implements userLogin {

String Username {get; set ;}

String Password {get; set ;}

}

Public class Groups implements Authorization, UserLogin {

Public Username {get; set; }

Public string role {get; set; }

Public string authorization {get; set;}

Public string group {get; set;}

}

Fat classes cause bizarre and harmful couplings between their clients. When one client forces a change in the fat class, all the other clients are affected. Thus, clients should have to depend only on the methods that they call. This can be achieved by breaking the interface of the fat class into many client-specific interfaces. Each client-specific interface declares only those functions that its particular client or client group invokes. This breaks the dependence of the clients on methods that they don’t invoke and allows the clients to be independent of one another.

Here is another example

Example: Fat Interface

public interface IStudentRepository

{
void AddStudent(Student std);
void EditStudent(Student std);
void DeleteStudent(Student std);
void AddCourse(Course cs);
void EditCourse(Course cs);
void DeleteCourse(Course cs);
bool SubscribeCourse(Course cs);
bool UnSubscribeCourse(Course cs);
IList<Student> GetAllStudents();
IList<Student> GetAllStudents(Course cs);
IList<Course> GetAllCourse();
IList<Course> GetAllCourses(Student std);
}

public class StudentRepository : IStudentRepository
{
    public void AddCourse(Course cs)
    {
        //implementation code removed for better clarity
    }

    public void AddStudent(Student std)
    {
        //implementation code removed for better clarity
    }

    public void DeleteCourse(Course cs)
    {
        //implementation code removed for better clarity
    }

    public void DeleteStudent(Student std)
    {
    //implementation code removed for better clarity    
    }

    public void EditCourse(Course cs)    
    {    
    //implementation code removed for better clarity    
    }

    public void EditStudent(Student std)    
    {    
    //implementation code removed for better clarity    
    }

    public IList<Course> GetAllCourse()    
    {    
    //implementation code removed for better clarity    
    }

    public IList<Course> GetAllCourses(Student std)    
    {    
    //implementation code removed for better clarity    
    }

    public IList<Student> GetAllStudents()    
    {    
    //implementation code removed for better clarity    
    }

    public IList<Student> GetAllStudents(Course cs)
    {    
    //implementation code removed for better clarity    
    }

    public bool SubscribeCourse(Course cs)    
    {    
    //implementation code removed for better clarity
    }

    public bool UnSubscribeCourse(Course cs)    
    {    
    //implementation code removed for better clarity
    }

}

To apply ISP to the above problem, we can split our large interface IStudentRepository and create another interface ICourseRepository with all course-related methods, as shown below.

Example: Interfaces after applying ISP

public interface IStudentRepository
{
    void AddStudent(Student std);

    void EditStudent(Student std);

    void DeleteStudent(Student std);

    bool SubscribeCourse(Course cs);

    bool UnSubscribeCourse(Course cs);

    IList<Student> GetAllStudents();

    IList<Student> GetAllStudents(Course cs);
}

public interface ICourseRepository
{
    void AddCourse(Course cs);

    void EditCourse(Course cs);

    void DeleteCourse(Course cs);

    IList<Course> GetAllCourse();

    IList<Course> GetAllCourses(Student std);
}

Now, we can create two concrete classes that implement the above two interfaces. This will automatically support SRP and increase cohesion.

ISP is not specific to interfaces only but it can be used with abstract classes or any class that provides some services to the client code.

The ISP acknowledges that objects require non-cohesive interfaces, but it is advised that users of these objects should not know about them as a single class. Instead, users of these objects should know about abstract base classes that have cohesive interfaces; and are multiply inherited into the concrete class that describes the non-cohesive object.

THE DEPENDENCY-INVERSION PRINCIPLE

The D in SOLID design principles stands for the Dependency inversion principle. The fundamental goal of this approach is to decouple dependencies so that if class A changes, class B does not need to care or be aware of the changes. So, to summarize, high-level modules should not depend on low-level modules, both should depend on abstractions, and abstractions should not depend on details.

The Dependency Inversion Principle is an essential principle in software design. By inverting the dependency between high-level and low-level modules, we can create more modular, flexible, and maintainable code. Violating this principle can lead to code that is difficult to modify and extend and can cause problems down the road. By using abstraction to decouple high-level and low-level modules, we can make our code more modular and extensible.

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Abstractions should not depend upon details. Details should depend upon abstractions.

In the example of remote controlling the Television, the internal working of either remote or TV is not known to each other but each interacts.

The Remote instance will interact with the Universal TV interface and the TV instance interacts with the Universal Remote interface. So both remote models and TV models can be changed so there is a dependency between them.

Consider the software that might control the regulator of a furnace. The software can read the current temperature from an I/O channel and instruct the furnace to turn on or off by sending commands to a different I/O channel. The structure of the algorithm might look something like

const byte THERMOMETER = 0x86;

const byte FURNACE = 0x87;

const byte ENGAGE = 1;

const byte DISENGAGE = 0;

void Regulate(double minTemp, double maxTemp)
{
    for(;;)
    {
        while (in(THERMOMETER) > minTemp)    
          wait(1);    
          out(FURNACE,ENGAGE);    
        while (in(THERMOMETER) < maxTemp)
          wait(1);
          out(FURNACE,DISENGAGE);
    }
 }

    void Regulate(Thermometer t, Heater h, double minTemp, double maxTemp)
    {
        for(;;)
        {
            while (t.Read() > minTemp)
                wait(1);
                h.Engage();

            while (t.Read() < maxTemp)
                wait(1);
                h.Disengage();
        }
}

This shows that the Regulate function takes two arguments that are both interfaces. The Thermometer interface can be read, and the Heater interface can be engaged and disengaged. This is all the Regulate algorithm needs. Now it can be written as shown above.

This has inverted the dependencies such that the high-level regulation policy does not depend on any of the specific details of the thermometer or the furnace. The algorithm is nicely reusable. Passing a reference of Interface and instantiation happens inside a static class.

Another example

Suppose a high-level module or class significantly depends on low-level modules or classes. In that case, the code will have tight coupling, and changing one class can break another, which is problematic at the production level. So, always strive to make classes as loosely connected as possible, which you can do through abstraction.

interface ICourseService {
    getCourses(): Promise<ICourse>
}

class CourseService implements ICourseService{
    getCourses() {
    //...
    }
}

class CourseController {
    constructor(courseService: ICourseService) {
        this.courseService = courseService;
    }

    async get() {
        // ...
        const data = await courseService.getCourses()
        // ...
    }
}

We changed the CourseController class in such a way that it only refers to an abstraction of the CourseService (the interface ICourseService), not to a concrete class.

This article has introduced the concept of design principles used in object-oriented design and the architecture of the application. and I have tried to explain very briefly the design principle applied to the structure of classes and interfaces helps in keeping the software application flexible, robust, reusable, and developable.

I hope the article gives you a solid grasp of the SOLID principles. You can see that the SOLID design principles can help you create a software system that is free of bugs, maintainable, flexible, scalable, and reusable.

Here are some specific benefits of following the SOLID principles:

  • Reduced dependencies: By following the Single Responsibility Principle (SRP), software engineers can ensure that each class in the software has a single responsibility. This makes it easier to understand and change the code, and it also reduces the risk of introducing bugs.

  • Increased flexibility: The Open-Closed Principle (OCP) allows software engineers to extend the software without modifying existing code. This makes the software more flexible and adaptable to change.

  • Improved maintainability: The Liskov Substitution Principle (LSP) ensures that derived classes can be substituted for their base classes without affecting the behavior of the software. This makes the software easier to maintain and extend.

  • Enhanced readability: The Interface Segregation Principle (ISP) and the Dependency Inversion Principle (DIP) help to make the software more readable and understandable. This makes it easier for software engineers to understand and maintain the code.

Conclusion

The SOLID design principles are a set of five principles that aim to make object-oriented designs more understandable, flexible, and maintainable. By following these principles, software engineers can reduce dependencies between different parts of their code, making it easier to change one part of the code without affecting others. Overall, the SOLID design principles can help software engineers to create more reliable, bug-free, and maintainable software.

0
Subscribe to my newsletter

Read articles from Jayaprakash S T directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jayaprakash S T
Jayaprakash S T