Published on

Solid Principles in C#

Table of Contents

As developers, it's important to write clean and maintainable code that is easy to understand, modify, and extend. However, achieving this can be challenging without the right design principles in place.

The SOLID principles were introduced by Robert C. Martin (Uncle Bob) in the early 2000s as a set of guidelines for writing code that is flexible and easy to maintain.

In this blog post, we'll explore each of the five SOLID principles and how they can be applied using C# language to create high-quality code.

Single Responsibility Principle (SRP)

The first principle we'll explore is the Single Responsibility Principle (SRP). SRP states that a function, class, or module should have only one reason to change. In other words, a component should have only one responsibility. This makes it easier to maintain and modify the class because it's easier to understand its purpose.

Here's an example of an SRP violation:

public class Order
{
    public void AddOrder()
    {
        // Add order to the database
    }

    public void SendEmail()
    {
        // Send email to the customer
    }
}

The code snippet above demonstrates a violation of the Single Responsibility Principle in the Order class, which has been assigned two distinct responsibilities: adding orders to the database and sending order emails to customers. For instance, if we needed to update the implementation of the SendEmail() method to send promotional emails, we might have to modify the Order class, even though the change is unrelated to order management. This is a clear indication that the Order class has more than one responsibility, making it challenging to maintain and modify.

Here's how we can achieve SRP in C# code:

public class OrderRepository
{
    public void AddOrder()
    {
        // Add order to the database
    }
}

public class EmailService
{
    public void SendEmail()
    {
        // Send email to the customer
    }
}

In the above code, we have separated the responsibilities into two different classes: OrderRepository and EmailService. This makes the code easier to maintain and modify because each class has only one responsibility.

Determining the appropriate scope of responsibility can be challenging, as it is not always clear how large or small it should be. In some cases, like the example above, it is quite evident, but in other situations, it may not be immediately apparent.

For instance, the responsibilities of the Customer class in the Sales module would be different from those in the Shipping module. Attempting to use the same class for both would result in a violation of the SRP. It would be better to create two separate Customer classes, in each module, to adhere to the Single Responsibility Principle.

Open/Closed Principle (OCP)

The second principle we'll explore is the Open/Closed Principle (OCP). OCP states that a class should be open for extension but closed for modification. In other words, we should be able to add new functionality to a class without modifying its existing code.

When the OCP is followed, new features and functionalities can be added without modifying the existing code. This means that we can update our code without breaking existing code or creating unwanted side effects.

Here's an example of an OCP violation:

public class NotificationService
{
    public NotificationService()
    {
        // Initialize Email client
        // Initialize Sms client
    }

    public void SendEmail()
    {
        // Send email to the customer
    }

    public void SendSms()
    {
        // Send email to the customer
    }
}

The NotificationService class is an example of a violation of the Open-Closed Principle. To illustrate, suppose we wanted to add push notification functionality. We would have to modify the NotificationService class, which goes against the OCP principle.

Here's how we can achieve OCP:

public interface INotificationService
{
    void SendNotification();
}

public class EmailService : INotificationService
{
    public void SendNotification()
    {
        // Send email to the customer
    }
}

public class SmsService : INotificationService
{
    public void SendNotification()
    {
        // Send SMS to the customer
    }
}

public class PushNotificationService : INotificationService
{
    public void SendNotification()
    {
        // Send push notification to the customer
    }
}

In the above code, we have created an interface called INotificationService that defines a SendNotification() method. We have also created three classes that implement the interface: EmailService, SmsService, and PushNotificationService. Now, if we want to add new functionality, we can create a new class that implements the INotificationService interface without modifying the existing code as we have done with PushNotificationService.

Liskov Substitution Principle (LSP)

The third principle we'll explore is the Liskov Substitution Principle (LSP). LSP states that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. In other words, a subclass should be able to substitute its parent class without any adverse effects.

LSP protects us from making incorrect inheritance hierarchies.

Here's an example of violating LSP:

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

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

public class Square : Rectangle
{
    public override int Width
    {
        set { base.Width = value; base.Height = value; }
    }

    public override int Height
    {
        set { base.Width = value; base.Height = value; }
    }
}

In the above code, we have a Rectangle class and a Square class that inherits from Rectangle. However, we have overridden the Width and Height properties in the Square subclass in such a way that if we would try to substitute a Square object for a Rectangle, it will result in incorrect behavior.

Here's how we recreate inheritance hierarchies to achieve LSP:

public abstract class Shape
{
    public abstract int GetArea();
}

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

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

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

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

In the above code, we have created an abstract Shape class that defines a GetArea() method and our Rectangle and Square class inherits from it. If we try to substitute a Shape object for a Rectangle or Square object, the behavior will remain correct.

Interface Segregation Principle (ISP)

The fourth principle we'll explore is the Interface Segregation Principle (ISP). ISP states that clients should not be forced to depend on interfaces they do not use. In other words, a class should not have to implement unnecessary methods just to satisfy an interface.

Example of ISP violation:

public interface IEventListener
{
    void OnClick();
    void OnScroll();
    void OnDrag();
}

public class Window : IEventListener
{
    public void OnClick()
    {
        // Do something on click
    }

    public void OnScroll()
    {
        // Do something on scroll
    }

    public void OnDrag()
    {
        // Do something on drag
    }
}

Suppose we provide the above interface to our clients for implementation. If a client only requires a Click Listener, they would still need to implement the Scroll and Drag Listener methods, even though they are not relevant to their use case. This violation of the Interface Segregation Principle (ISP) makes the implementation less efficient and more difficult to maintain.

Here's how we can achieve ISP in C# code:

public interface IClickListener
{
    void OnClick();
}

public interface IScrollListener
{
    void OnScroll();
}

public interface IDragListener
{
    void OnDrag();
}

public class Window : IClickListener
{
    public void OnClick()
    {
        // do something on click
    }
}

In the above code, we have created three interfaces: IClickListener, IScrollListener, and IDragListener. Each interface has only one method that is relevant to its purpose. This allows clients to use only the necessary interfaces, without having to implement unnecessary methods.

Dependency Inversion Principle (DIP)

The final principle we'll explore is the Dependency Inversion Principle (DIP). DIP states that high-level modules should not depend on low-level modules directly but instead they should have a contract between them. High-level modules should use that contract and low-level modules need to fulfill that contract. A contract could be an interface or an abstract class.

We create an abstraction layer between the two modules and details (low-level modules) depend on abstraction whereas abstractions (contract) should not depend on details.

It helps us easily replace a low-level module by introducing a new implementation for the contract. Thus our code becomes more loosely coupled.

Here's an example of violating DIP:

public class Customer
{
    private readonly SqlDatabase _database;

    public Customer()
    {
        _database = new SqlDatabase();
    }

    public List<Customer> GetCustomers()
    {
        return _database.GetItems<Customer>("SELECT * FROM Customers");
    }
}

public class SqlDatabase
{
    public List<T> GetItems<T>(string sql)
    {
        // Execute SQL statement and return list of items
    }
}

In the above code, the Customer class has a dependency on the SqlDatabase class, which violates the DIP principle. This makes the Customer class tightly coupled to the SqlDatabase class, making it difficult to modify or test.

Here's how we can achieve DIP in C# code:

public interface ICustomerRepository
{
    List<Customer> GetCustomers();
}

public class Customer
{
    private readonly ICustomerRepository _repository;

    public Customer(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public List<Customer> GetCustomers()
    {
        return _repository.GetCustomers();
    }
}

// Option 1
public class SqlDatabase : ICustomerRepository
{
    public List<T> GetItems<T>(string sql)
    {
        // Execute SQL statement and return list of items
    }

    public List<Customer> GetCustomers()
    {
        return GetItems<Customer>("SELECT * FROM Customers");
    }
}

// Option 2
public class CustomerRepository : ICustomerRepository
{
    private readonly SqlDatabase _database;

    public CustomerRepository(SqlDatabase database)
    {
        _database = database;
    }

    public List<Customer> GetCustomers()
    {
        return _database.GetItems<Customer>("SELECT * FROM Customers");
    }
}

In the above code, we have created an ICustomerRepository interface which acts as the contract between the two classes. I have shown two options for implementing the contract and I prefer the second, as the first option might lead to a violation of SRP and OCP if we need to support more types.

Now our Customer class has become loosely coupled with database classes. We can easily unit-test the Customer class by providing an in-memory implementation of ICustomerRepository. Also, in the same way, if we need to change the database implementation for some reason we could create a new implementation of ICustomerRepository and just use that without modifying any existing code.

Conclusion

In conclusion, the SOLID principles are a set of guidelines that helps us to create code that is easier to modify, test, and extend. In this blog post, we have explored each of the five SOLID principles, including examples of how to implement each principle in C# code.

Remember, SOLID principles are not rules set in stone, but guidelines to be interpreted, applied, and adapted as necessary. By following these principles, we can create code that is not only easy to maintain and test but is also flexible and adaptable to changing requirements.