SOLID Principles made Easy [C#]

[SOLID Principles]

[Design Patterns]


1 - Single Responsibility Principle

The "S" in SOLID stands for the Single Responsibility Principle, which states that a class should have only one reason to change. In other words, a class should have only one responsibility or job. This helps to ensure that the code is more maintainable, testable, and extensible.

Below is a class that violates the Single Responsibility Principle:

public class Customer

{

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string EmailAddress { get; set; }

    

    public void SaveToDatabase()

    {

        // Code to save the customer to the database

    }

    

    public void SendEmail(string message)

    {

        // Code to send an email to the customer

    }

}

In this example, the Customer class has two responsibilities: saving the customer to the database and sending an email to the customer. This violates the Single Responsibility Principle because if we need to change how we save customers to the database, we may inadvertently break the code that sends emails to customers.

Below is a class that follows the Single Responsibility Principle:

public class Customer

{

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string EmailAddress { get; set; }

}


public class CustomerRepository

{

    public void SaveCustomer(Customer customer)

    {

        // Code to save the customer to the database

    }

}


public class CustomerEmailer

{

    public void SendEmailToCustomer(Customer customer, string message)

    {

        // Code to send an email to the customer

    }

}

In this example, we have three classes: Customer, CustomerRepository, and CustomerEmailer. The Customer class only has one responsibility, which is to represent a customer. The CustomerRepository class is responsible for saving customers to the database, and the CustomerEmailer class is responsible for sending emails to customers. By separating these responsibilities into separate classes, we can more easily maintain, test, and extend the code. If we need to change how we save customers to the database, we only need to modify the CustomerRepository class, and the Customer and CustomerEmailer classes remain unchanged.


2- Open-Closed Principle

The "O" in SOLID stands for the Open-Closed Principle, which states that classes should be open for extension but closed for modification. In other words, we should be able to extend the behavior of a class without modifying its source code.

Below is a class that violates the Open-Closed Principle:

public class Rectangle

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public int Area()

    {

        return Width * Height;

    }

}

In this example, the Rectangle class has a single responsibility of calculating the area of a rectangle. However, if we want to add support for other shapes, such as circles, we would need to modify the Rectangle class. This violates the Open-Closed Principle because we are modifying the source code of the Rectangle class instead of extending it.

Below is a class that follows the Open-Closed Principle:

public abstract class Shape

{

    public abstract int Area();

}


public class Rectangle : Shape

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public override int Area()

    {

        return Width * Height;

    }

}


public class Circle : Shape

{

    public int Radius { get; set; }

    

    public override int Area()

    {

        return (int)(Math.PI * Radius * Radius);

    }

}

In this example, we have an abstract Shape class that defines a method for calculating the area of a shape. We also have two concrete classes, Rectangle and Circle, that extend the Shape class and implement the Area() method. By using an abstract base class and concrete derived classes, we can extend the behavior of the Shape class without modifying its source code. If we want to add support for other shapes, we can simply create a new derived class that implements the Area() method. This approach makes our code more maintainable, testable, and extensible.


3 - Liskov Substitution Principle

The "L" in SOLID stands for the Liskov Substitution Principle, which states that objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program. In other words, a subclass should be able to be used in place of its superclass without any unexpected behavior.

Below is a class hierarchy that violates the Liskov Substitution Principle:

public class Rectangle

{

    public virtual int Width { get; set; }

    public virtual int Height { get; set; }

    

    public int Area()

    {

        return Width * Height;

    }

}


public class Square : Rectangle

{

    public override int Width 

    { 

        get => base.Width; 

        set 

        { 

            base.Width = value; 

            base.Height = value; 

        } 

    }

    

    public override int Height 

    { 

        get => base.Height; 

        set 

        { 

            base.Width = value; 

            base.Height = value; 

        } 

    }

}

In this example, we have a Rectangle class with a Width and Height property and an Area() method. We also have a Square class that inherits from Rectangle and overrides the Width and Height properties to always be equal. However, this violates the Liskov Substitution Principle because a Square is not a proper substitution for a Rectangle. If we were to create a method that takes a Rectangle parameter and passes in a Square object, the behavior of the method would be unexpected.

Below is a class hierarchy that follows the Liskov Substitution Principle:

public abstract class Shape

{

    public abstract int Area();

}


public class Rectangle : Shape

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public override int Area()

    {

        return Width * Height;

    }

}


public class Square : Shape

{

    public int Side { get; set; }

    

    public override int Area()

    {

        return Side * Side;

    }

}

In this example, we have an abstract Shape class that defines a method for calculating the area of a shape. We also have two concrete classes, Rectangle and Square, that inherit from Shape and implement the Area() method. By using a common abstract base class, we ensure that objects of each subclass can be used interchangeably without affecting the correctness of the program. This makes our code more maintainable, testable, and extensible.


4- Interface Segregation Principle


The "I" in SOLID stands for the Interface Segregation Principle, which states that clients should not be forced to depend on interfaces they do not use. In other words, we should design interfaces that are specific to the needs of the clients that use them.

Below is a class that violates the Interface Segregation Principle:

public interface IShape

{

    int Width { get; set; }

    int Height { get; set; }

    int Area();

}


public class Rectangle : IShape

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public int Area()

    {

        return Width * Height;

    }

}


public class Square : IShape

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public int Area()

    {

        return Width * Height;

    }

}

In this example, we have an IShape interface that defines properties for Width and Height and a method for calculating the area of a shape. We also have two concrete classes, Rectangle and Square, that implement the IShape interface. However, this violates the Interface Segregation Principle because the Square class does not need the Height property since it is always equal to the Width. This forces clients that use the Square class to depend on the Height property even though they do not need it.

Below is a class that follows the Interface Segregation Principle:

public interface IShape

{

    int Area();

}


public interface IRectangle

{

    int Width { get; set; }

    int Height { get; set; }

}


public interface ISquare

{

    int Side { get; set; }

}


public class Rectangle : IShape, IRectangle

{

    public int Width { get; set; }

    public int Height { get; set; }

    

    public int Area()

    {

        return Width * Height;

    }

}


public class Square : IShape, ISquare

{

    public int Side { get; set; }

    

    public int Area()

    {

        return Side * Side;

    }

}

In this example, we have three interfaces, IShape, IRectangle, and ISquare, that define specific properties and methods. We also have two concrete classes, Rectangle and Square, that implement the appropriate interfaces. By designing specific interfaces for the needs of the clients that use them, we ensure that clients are not forced to depend on interfaces they do not use. This makes our code more maintainable, testable, and extensible.


5 - Dependency Inversion Principle

The "D" in SOLID stands for the Dependency Inversion Principle, which states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In other words, we should design our code so that modules are decoupled from each other and can be replaced with other implementations without affecting the overall system.

Below is a class that violates the Dependency Inversion Principle:

public class UserRepository

{

    private readonly Database _database;

    

    public UserRepository(Database database)

    {

        _database = database;

    }

    

    public void SaveUser(User user)

    {

        _database.Save(user);

    }

}


public class UserController

{

    private readonly UserRepository _userRepository;

    

    public UserController()

    {

        _userRepository = new UserRepository(new Database());

    }

    

    public void AddUser(string username, string email)

    {

        var user = new User(username, email);

        _userRepository.SaveUser(user);

    }

}

In this example, the UserController depends directly on the UserRepository, which in turn depends directly on the Database class. This violates the Dependency Inversion Principle because high-level modules (UserController) should not depend directly on low-level modules (UserRepository and Database).


Below is a class that follows the Dependency Inversion Principle:

public interface IUserRepository

{

    void SaveUser(User user);

}


public class DatabaseUserRepository : IUserRepository

{

    private readonly Database _database;

    

    public DatabaseUserRepository(Database database)

    {

        _database = database;

    }

    

    public void SaveUser(User user)

    {

        _database.Save(user);

    }

}


public class UserController

{

    private readonly IUserRepository _userRepository;

    

    public UserController(IUserRepository userRepository)

    {

        _userRepository = userRepository;

    }

    

    public void AddUser(string username, string email)

    {

        var user = new User(username, email);

        _userRepository.SaveUser(user);

    }

}

In this example, we have an IUserRepository interface that defines a SaveUser() method, and a concrete DatabaseUserRepository class that implements the interface. The UserController class depends only on the IUserRepository interface, which makes it decoupled from the implementation details of the repository. This approach ensures that high-level modules are not tightly coupled to low-level modules and allows us to swap out the concrete implementation with a different one without affecting the overall system. This makes our code more maintainable, testable, and extensible.