SOLID Principles

SOLID Principles

SOLID is an acronym for five design principles — Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP).

These principles guide developers to create maintainable, flexible, and robust software systems. In this article, we will explore each of these principles in-depth, accompanied by practical code examples in C#.

1- Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change, meaning it should have a single responsibility. This principle helps in maintaining code that is easier to understand, test, and maintain.

Example:

public class SRP
{
    // Bad example violating SRP
    public class Client
    {
        public void AddClient()
        {
            // Code to add a client to the database
        }

        public void SendSMS()
        {
            // Code to send sms to the client
        }
    }

    // Good example following SRP
    public class ClientService
    {
        public void AddClient()
        {
            // Code to add a client to the database
        }
    }

    public class SMSService
    {
        public void SendSMS()
        {
            // Code to send sms to the client
        }
    }
}        

In the given code example, we have a class called Client that violates the Single Responsibility Principle (SRP). The Client class is responsible for both adding a client to the database and sending sms to the client. This violates the SRP because the class has multiple reasons to change — if there are changes in the database operations or sms sending logic, the Client class would need to be modified.

To address this issue and follow the SRP, we can refactor the code by separating the responsibilities into two distinct classes: ClientService and SMSService.

In the refactored code:

ClientService class is responsible for adding a client to the database. It contains a method called AddClient() that handles the database-related operations. By separating the database-related functionality into its own class, we adhere to the SRP, as the ClientService class now has a single responsibility.

SMSService class is responsible for sending sms to clients. It contains a method called SendSMS() that handles the sms sending logic. By moving the sms-related functionality into its own class, we separate it from the database operations and adhere to the SRP.

By splitting the responsibilities into separate classes, we achieve better separation of concerns, which leads to more maintainable and flexible code. Now, if there are changes in the database operations or sms sending logic, we only need to modify the respective class, minimizing the impact on other parts of the codebase.

2- Open-Closed Principle (OCP)

The OCP states that software entities (classes, modules, etc.) should be open for extension but closed for modification. It encourages the use of abstraction and inheritance to achieve this principle.

Example:

public class OCP
{
    // Bad example violating OCP
    public enum EmployeeType
    {
        Manager,
        Senior,
        Junior
    }

    public class Employee
    {
        public EmployeeType Type { get; set; }

        public double CalculateBonusForEmployee()
        {
            double bonus = 3000;

            switch (Type)
            {
                case EmployeeType.Manager:
                    return bonus * 10;
                case EmployeeType.Senior:
                    return bonus * 5;
                case EmployeeType.Junior:
                    return bonus * 2;
            }

            return bonus;
        }
    }


    // Good example following OCP
    public abstract class Employee
    {
        public abstract double CalculateBonusForEmployee();
    }

    public class Manager : Employee
    {
        public override double CalculateBonusForEmployee()
        {
            double bonus = 3000;
            return bonus * 10;
        }
    }

    public class Senior : Employee
    {
        public override double CalculateBonusForEmployee()
        {
            double bonus = 3000;
            return bonus * 5;
        }
    }

    public class Junior : Employee
    {
        public override double CalculateBonusForEmployee()
        {
            double bonus = 3000;
            return bonus * 2;
        }
    }
}        

In the bad example, we have a Employee class that contains a EmployeeType property and a method called CalculateBonusForEmployee(). The method calculates the bonus based on the employee type, but it violates the OCP because whenever a new employee type is introduced, we need to modify the existing class.

In the good example, we apply the OCP by introducing an abstract Employee class. This abstract class provides a common interface with the method, CalculateBonusForEmployee() which is declared as abstract. Then, we create three derived classes, Manager, Senior and Junior, which inherit from the Employee class and override the CalculateBonusForEmployee() method.

By using this approach, the code becomes open for extension. If we want to add a new employee type (e.g., MidSenior), we can create a new class that derives from Employee and implements its own bonus calculation logic without modifying the existing code. This promotes code reusability and maintains the closed nature of the original Employee class, following the principles of OCP.

3- Liskov Substitution Principle (LSP)

The LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, it ensures that derived classes can be used interchangeably with their base classes.

Example:

// Bad example violating LSP
public class Rectangle
{
    public int Width { get; set; }
    public int Height { get; set; }

    public virtual void SetWidth(int width)
    {
        Width = width;
    }

    public virtual void SetHeight(int height)
    {
        Height = height;
    }

    public int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : Rectangle
{
    public override void SetWidth(int width)
    {
        Width = width;
        Height = width;
    }

    public override void SetHeight(int height)
    {
        Width = height;
        Height = height;
    }
}

// Good example following LSP
public abstract class Shape
{
    public abstract int CalculateArea();
}

public class Rectangle : Shape
{
    public int Width { get; set; }
    public int Height { get; set; }

    public override int CalculateArea()
    {
        return Width * Height;
    }
}

public class Square : Shape
{
    public int SideLength { get; set; }

    public override int CalculateArea()
    {
        return SideLength * SideLength;
    }
}        

In the given code example, we have a class hierarchy consisting of a Rectangle class and a derived Square class that violates the Liskov Substitution Principle (LSP). The Square class inherits from the Rectangle class, which may seem intuitive at first since a square is a special case of a rectangle. However, this inheritance relationship leads to a violation of LSP.

In the bad example, the Rectangle class has separate SetWidth() and SetHeight() methods, allowing the width and height to be set independently. The Square class overrides these methods to ensure that both the width and height are always equal, maintaining the square’s property.

However, this violates the LSP because the behavior of the Square class is not substitutable for the behavior defined by the Rectangle class:

Rectangle rectangle = new Square();
rectangle.SetWidth(6);
rectangle.SetHeight(2);

int area = rectangle.CalculateArea(); // Expected result: 12, Actual result: 4        

In the good example, we adhere to the LSP by rethinking the class hierarchy and avoiding the inheritance relationship between Rectangle and Square. Instead, we introduce an abstract base class called Shape, which declares an abstract method CalculateArea().

In the refactored code:

Shape abstract class serves as a base class for different shapes and declares an abstract method CalculateArea(). By using an abstract class, we ensure that all derived shapes must implement the CalculateArea() method.

Rectangle class extends the Shape abstract class and overrides the CalculateArea() method. It introduces separate Width and Height properties and implements the area calculation specific to rectangles.

Square class also extends the Shape abstract class and overrides the CalculateArea() method. It introduces a SideLength property and implements the area calculation specific to squares.

By redesigning the class hierarchy, we avoid the inheritance relationship that violates the LSP. Each class now defines its own properties and implements the area calculation specific to its shape. The code becomes more cohesive and adheres to the LSP, allowing objects of the derived classes (e.g., Rectangle and Square) to be used interchangeably with objects of the base class (Shape).

4- Interface Segregation Principle (ISP)

The ISP states that clients should not be forced to depend on interfaces they do not use. It promotes the idea of segregating large interfaces into smaller and more specific ones.

Example:

public class ISP
{
    // Bad example violating ISP
    public interface IArtist
    {
        void Dance();
        void Sing();
        void Act();
    }

    public class NancyAjram : IArtist
    {
        public void Act()
        {
            // Nancy Ajram does not acting
            throw new NotImplementedException();
        }

        public void Dance()
        {
            // Nancy Ajram is a dancer
        }

        public void Sing()
        {
            // Nancy Ajram is a singer
        }
    }

    public class MonaWasif: IArtist
    {
        public void Act()
        {
            // Mona Wasif is an Actress
        }

        public void Dance()
        {
            // Mona Wasif does not dancing
            throw new NotImplementedException();
        }

        public void Sing()
        {
            // Mona Wasif does not singing
            throw new NotImplementedException();
        }
    }

    // Good example following ISP
    public interface IDancer
    {
        void Dance();
    }

    public interface ISinger
    {
        void Sing();
    }

    public interface IActor
    {
        void Act();
    }

    public class NancyAjram : IDancer, ISinger
    {
        public void Dance()
        {
            // Nancy Ajram is a dancer
        }

        public void Sing()
        {
            // Nancy Ajram is a singer
        }
    }

    public class MonaWasif : IActor
    {
        public void Act()
        {
            // Mona Wasif is an Actress
        }
    }
}        

In the given code example, we have an interface called IArtist that represents a artist with three methods: Dance(), Sing(), and Act(). This code violates the Interface Segregation Principle (ISP) because it forces implementers to provide implementations for methods that they don’t need or cannot support.

In the bad example, the IArtist interface is too broad and contains methods that are not applicable to all implementations. The NancyAjram class implements the IArtist interface but throws NotSupportedException for the Act() method since NancyAjram does not act. This violates the ISP because the NancyAjram class is forced to implement methods that are irrelevant to its functionality, leading to empty or exception-throwing implementations.

In the good example, we apply the ISP by splitting the IArtist interface into three separate interfaces: IDancer, ISinger, and IActor.

IDancer interface defines the Dance() method, which represents the common behavior of dancers.

ISiner interface defines the Sing() method, which represents the behavior of entities that can sing.

IActor interface defines the Act() method, which represents the behavior of entities that can act.

Then, we have the NancyAjram class, which implements two interfaces: IDancer and ISinger. NancyAjram are capable of dancing and singing, so they implement only two interfaces

Finally, we have the MonaWasif class, which only implements the IActor interface. MonaWasif does not sing or dance, so they do not need to implement the unnecessary methods.

By splitting the interfaces based on specific behaviour, we adhere to the ISP. Each class now implements only the interfaces that are relevant to its functionality, avoiding empty or exception-throwing implementations. This promotes better separation of concerns and allows for more precise client dependencies, ensuring that clients depend only on the interfaces they actually use.

5- Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. It encourages loose coupling between modules and facilitates easier unit testing and maintainability.Example:

// Bad example violating DIP
public class DataAccess
{
    public void SaveData(string data)
    {
        // Code to save data to a specific database
    }
}

public class UserService
{
    private readonly DataAccess _dataAccess;

    public UserService()
    {
        _dataAccess = new DataAccess();
    }

    public void CreateUser(string username, string password)
    {
        // Code to create a user

        _dataAccess.SaveData("User created: " + username);
    }
}

// Good example following DIP
public interface IDataAccess
{
    void SaveData(string data);
}

public class DataAccess : IDataAccess
{
    public void SaveData(string data)
    {
        // Code to save data to a specific database
    }
}

public class UserService
{
    private readonly IDataAccess _dataAccess;

    public UserService(IDataAccess dataAccess)
    {
        _dataAccess = dataAccess;
    }

    public void CreateUser(string username, string password)
    {
        // Code to create a user

        _dataAccess.SaveData("User created: " + username);
    }
}        

In the given code example, we have a UserService class that depends directly on the DataAccess class, violating the Dependency Inversion Principle (DIP).

In the bad example, the UserService class has a tight coupling with the DataAccess class. It creates an instance of DataAccess directly within its constructor, which tightly binds the UserService to the specific implementation of the data access logic. This violates the DIP because the UserService is dependent on a concrete implementation rather than an abstraction.

In the good example, we refactor the code to adhere to the DIP by introducing an interface called IDataAccess that abstracts the data access functionality. The DataAccess class implements this interface.

IDataAccess interface defines a contract for data access operations and contains the SaveData() method.

DataAccess class implements the IDataAccess interface and provides the concrete implementation for saving data to a specific database.

UserService class now depends on the IDataAccess interface instead of the concrete DataAccess class. The dependency is injected through the constructor, allowing for easier substitution of different implementations of IDataAccess. This constructor injection adheres to the DIP because the UserService depends on an abstraction (IDataAccess) rather than a specific implementation.

By introducing the IDataAccess interface and injecting it into the UserService class, we achieve loose coupling and higher flexibility. The UserService class is no longer tightly coupled to a specific implementation of data access, making it easier to switch or extend the data access logic without modifying the UserService class itself.

This adherence to the DIP allows for easier unit testing, better separation of concerns, and promotes the use of interfaces and abstractions to achieve more maintainable and extensible code.

Conclusion

The SOLID principles help make software that's easy to work with and improve over time. When developers follow these principles, they can write code that's simpler to read, test, and keep in good shape.

Keep in mind that these principles aren't strict rules; they're more like helpful ideas that can be adjusted to fit the needs of a particular project. With the examples we've given, you can begin using these principles in your code and make your software better.

Hamza Sourani

Expert MERN Stack Developer @ AirRetailer | React.js & JavaScript Pro

1 年

Good explanation of SOLID principle ?? ?? ??

要查看或添加评论,请登录

Nadim Attar的更多文章

  • Task.WhenEach

    Task.WhenEach

    Are you ready to take your asynchronous programming to the next level? With the release of .NET 9, we now have – a…

  • Exploring the Exciting New Features in C# 13

    Exploring the Exciting New Features in C# 13

    C# continues to evolve, making development more efficient and expressive. With the upcoming release of C# 13, several…

  • Understanding the IGNORE_DUP_KEY Feature in SQL Server

    Understanding the IGNORE_DUP_KEY Feature in SQL Server

    When working with databases, maintaining data integrity is critical. SQL Server offers various tools to ensure this…

  • C# Discriminated Unions (Dunet)

    C# Discriminated Unions (Dunet)

    Dunet is a simple source generator for discriminated unions in C#. This is the nuget of this library: https://www.

  • New keyed service dependency in .NET 8

    New keyed service dependency in .NET 8

    What’s a keyed service ? The "keyed" registration approach involves registering dependencies using both their type and…

  • HttpHandler

    HttpHandler

    After a long hiatus from posting articles, today I am presenting to you a great post discussing why we need to…

  • Types of Transactions in SQL Server

    Types of Transactions in SQL Server

    Transactions are like safety nets for databases in SQL Server. They help keep data safe and consistent by making sure…

  • Dependency Injection Lifetimes in .Net Core

    Dependency Injection Lifetimes in .Net Core

    There are three lifetimes available with the Microsoft Dependency Injection container: transient, singleton, and…

  • Implementation of Dependency Injection Pattern in C#

    Implementation of Dependency Injection Pattern in C#

    Dependency Injection (DI) is a method in software design that helps us create code that's not too closely connected…

  • FileZilla - The free FTP solution

    FileZilla - The free FTP solution

    FileZilla is a free, open source file transfer protocol (FTP) software tool that allows users to set up FTP servers or…

社区洞察

其他会员也浏览了