Listen
Copied RSS Feed

C#

Mastering SOLID Principles in C#: A Practical Guide

TL;DR: SOLID principles ensure clean, modular code for maintainability, scalability, and flexibility. They guide developers to build robust systems through Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles.

The SOLID principles are design principles that help create maintainable, scalable, and flexible software systems. These principles, coined by Robert C. Martin, provide guidelines for writing clean, modular, and loosely coupled code.

In this blog, you will explore each SOLID principle and discuss how to implement them in C# with code examples.

What are the SOLID principles?

Why should we use the SOLID design principles?

SOLID principles serve as foundational guidelines in software development, aiming to create robust and adaptable systems. By adhering to SOLID principles, developers foster:

  • Code maintainability: Promote clean, organized, and maintainable codebases. By adhering to these principles, developers ensure that each component of their code has a clear purpose and well-defined responsibilities. This clarity simplifies maintenance tasks, making it easier to understand, update, and modify code without causing unintended side effects elsewhere.
  • Future scalability: Allows for more adaptable and extensible software systems. Developers should design code that can be added to but not modified, allowing others to introduce new features or functionalities without altering existing code. This scalability is essential for accommodating future changes and expanding the system’s capabilities without compromising stability.
  • Flexibility in development: Encourages flexibility in development by providing a framework that supports iterative and incremental changes. This enables developers to introduce new functionalities, fix issues, or refactor codebases more efficiently, minimizing risks associated with unintended consequences or regressions.
  • Improved collaboration: Encourages a modular and structured approach to development. This organization enhances collaboration among team members as it leads to well-defined interfaces and clear responsibilities, making it easier for different team members to work on distinct parts of the codebase simultaneously.

Incorporating the SOLID design principles in software development leads to a more robust, adaptable, and maintainable codebase. Let’s discuss each principle with code examples.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) promotes clean, maintainable, and scalable software design. It states that a class should change for only one reason, meaning it should have a single responsibility.

Let’s consider a user creation process that involves validating and saving user data to a database.

Refer to the following code example to understand how SRP can be violated through combined handling validation and persistence.

public class UserCreator
{
    public void CreateUser(string username, string email, string password)
    {
        // Validation logic
        if (!ValidateEmail(email))
        {
            throw new ArgumentException("Invalid email format.");
        }
        // Business rules
        // Database persistence
        SaveUserToDatabase(username, email, password);
    }
    private bool ValidateEmail(string email)
    {
        // Validation logic
    }
    private void SaveUserToDatabase(string username, string email, string password)
    {
        // Database persistence logic
    }
}

Issue

In the previous code, the UserCreator class violates the SRP by combining multiple responsibilities, such as validation and database persistence. This can lead to a tightly coupled class, making it difficult to test and prone to unnecessary modifications.

Solution

To address this issue, we can apply SRP by refactoring the code to separate these responsibilities into individual classes.

Refer to the following refactored code example.

public class UserValidator
{
    public bool ValidateEmail(string email)
    {
        // Validation logic
    }
}
public class UserRepository
{
    public void SaveUser(string username, string email, string password)
    {
        // Database persistence logic
    }
}
public class UserCreator
{
    private readonly UserValidator _validator;
    private readonly UserRepository _repository;
    public UserCreator(UserValidator validator, UserRepository repository)
    {
        _validator = validator;
        _repository = repository;
    }
    public void CreateUser(string username, string email, string password)
    {
        if (!_validator.ValidateEmail(email))
        {
            throw new ArgumentException("Invalid email format.");
        }
        // Business rules
        _repository.SaveUser(username, email, password);
    }
}

After refactoring, the code demonstrates the implementation of SRP through the separation of responsibilities into three classes:

  • UserValidator: Validates the user’s email format.
  • UserRepository: Handles saving the user’s data to the database.
  • UserCreator: Coordinates the user creation process, leveraging the validator and repository classes for their specific responsibilities.

Benefits

By separating the concerns, we achieve a more maintainable and testable codebase. Each class has a single responsibility, allowing for more straightforward modification and extension in the future.

Open/Closed Principle (OCP)

The Open-Closed Principle (OCP) says that software entities should be open for extension but closed for modification. It allows for adding new functionality without modifying existing code.

Let’s consider a scenario where a file-exporting service initially supports exporting data to CSV files.

Refer to the following code example to understand how OCP can be violated and how to correct it using C#.

public class FileExporter
{
    public void ExportToCsv(string filePath, DataTable data)
    {
        // Code to export data to a CSV file.
    }
}

Issue

In this example, the FileExporter class directly implements the functionality for exporting data to CSV files. However, if we later want to support exporting data to other file formats like Excel or JSON, modifying the FileExporter class would violate the OCP.

Solution

To use the OCP, we must design our file-exporting service domain to be open for extension.

Refer to the following refactored code example.

public abstract class FileExporter
{
    public abstract void Export(string filePath, DataTable data);
}
public class CsvFileExporter : FileExporter
{
    public override void Export(string filePath, DataTable data)
    {
        // Code logic to export data to a CSV file.
    }
}
public class ExcelFileExporter : FileExporter
{
    public override void Export(string filePath, DataTable data)
    {
        // Code logic to export data to an Excel file.
    }
}
public class JsonFileExporter : FileExporter
{
    public override void Export(string filePath, DataTable data)
    {
        // Code logic to export data to a JSON file.
    }
}

In the improved implementation, we introduce an abstract FileExporter class that defines the common behavior for all file export operations. Each specific file exporter (CsvFileExporter, ExcelFileExporter, and JsonFileExporter) inherits from the FileExporter class and implements the Export method according to the particular file format export logic.

Applying the OCP allows for adding new file exporters without modifying old ones, making it easier to add new features by introducing subclasses of the FileExporter base class.

Benefits

This approach enhances code flexibility, reusability, and maintainability. Your code can seamlessly handle new requirements and changes without introducing bugs or disrupting the existing functionality.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is a concept that guarantees the smooth substitution of objects of derived classes for objects of their base classes. Its fundamental rule asserts that objects of the base class must be interchangeable with objects of any of its derived classes, without impacting the accuracy of the program.

Refer to the following code example to understand how LSP can be violated and how to correct it using C#.

public abstract class Vehicle
{
    public abstract void StartEngine();
    public abstract void StopEngine();
}
public class Car : Vehicle
{
    public override void StartEngine()
    {
        Console.WriteLine("Starting the car engine.");
        // Code to start the car engine
    }
    public override void StopEngine()
    {
        Console.WriteLine("Stopping the car engine.");
        // Code to stop the car engine
    }
}
public class ElectricCar : Vehicle
{
    public override void StartEngine()
    {
        throw new InvalidOperationException("Electric cars do not have engines.");
    }
    public override void StopEngine()
    {
        throw new InvalidOperationException("Electric cars do not have engines.");
    }
}

Issue

In this example, we have a Vehicle class that represents a generic vehicle. It has abstract methods, StartEngine() and StopEngine(), for starting and stopping the engine. We also have a Car class that inherits from Vehicle and provides the necessary implementation for the engine-related methods.

However, when we introduce a new type of vehicle, such as an ElectricCar, which doesn’t have an engine, we encounter a violation of the LSP. In this case, attempting to call the StartEngine() or StopEngine() methods on an ElectricCar object would result in exceptions because electric cars do not have engines.

Vehicle car = new Car();
car.StartEngine(); // Outputs "Starting the car engine."
Vehicle electricCar = new ElectricCar();
electricCar.StartEngine(); // Throws InvalidOperationException

Solution

To address this violation, we need to ensure the correct substitution of objects. One approach is to introduce an interface called IEnginePowered that represents vehicles with engines.

Refer to the following refactored code example.

public abstract class Vehicle
{
    // Common vehicle behavior and properties.
}
public interface IEnginePowered
{
    void StartEngine();
    void StopEngine();
}
public class Car : Vehicle, IEnginePowered
{
    public void StartEngine()
    {
        Console.WriteLine("Starting the car engine.");
        // Code to start the car engine.
    }
    public void StopEngine()
    {
        Console.WriteLine("Stopping the car engine.");
        // Code to stop the car engine.
    }
}
public class ElectricCar : Vehicle
{
    // Specific behavior for electric cars.
}

In this corrected design, the Car class implements the IEnginePowered interface along with the Vehicle class. The Vehicle class will include common vehicle properties and behavior for both. This design provides the necessary implementation for the engine-related methods. Also, the ElectricCar class does not implement the IEnginePowered interface because it does not have an engine.

IEnginePowered car = new Car();
car.StartEngine(); // Outputs "Starting the car engine."

Vehicle electricCar = new ElectricCar();
// electricCar.StartEngine(); // This line won't compile because ElectricCar does not implement IEnginePowered

We can substitute objects of the Car or ElectricCar class where instances of the IEnginePowered are expected. The ElectricCar class does not need to implement engine-related methods.

Benefits

Using the LSP, we ensured that the program remained accurate and consistent when substituting objects of derived classes for objects of their base class.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) says to create smaller, specialized interfaces that cater to clients’ specific needs. It discourages large interfaces that include unnecessary methods, so that clients are not burdened with functionality they don’t require.

Refer to the following example to understand how ISP can be violated and how to correct it using C#.

public interface IOrder
{
    void PlaceOrder();
    void CancelOrder();
    void UpdateOrder();
    void CalculateTotal();
    void GenerateInvoice();
    void SendConfirmationEmail();
    void PrintLabel();
}
public class OnlineOrder : IOrder
{
    // Implementation of all methods.
}
public class InStoreOrder : IOrder
{
    // Implementation of all methods.
}

Issue

In the previous example, we have an IOrder interface that contains methods for placing an order, canceling an order, updating an order, calculating the total, generating an invoice, sending a confirmation email, and printing a label.

However, not all client classes implementing this interface require or use all these methods. This violates ISP, since clients are forced to depend on methods they don’t need.

By following the ISP, we can refactor the code by segregating the interface into smaller, more focused interfaces.

public interface IOrder
{
    void PlaceOrder();
    void CancelOrder();
    void UpdateOrder();
}
public interface IOrderProcessing
{
    void CalculateTotal();
}
public interface IInvoiceGenerator
{
    void GenerateInvoice();
}
public interface IEmailSender
{
    void SendConfirmationEmail();
}
public interface ILabelPrinter
{
    void PrintLabel();
}
// Implement only the necessary interfaces in client classes.
public class OnlineOrder : IOrder, IOrderProcessing, IInvoiceGenerator, IEmailSender
{
    // Implementation of required methods.
}
public class InStoreOrder : IOrder, IOrderProcessing, ILabelPrinter
{
    // Implementation of required methods.
}

Solution

By segregating the interfaces, we now have smaller, more focused interfaces that clients can choose to implement based on their specific needs. This approach eliminates unnecessary dependencies and allows for better extensibility and maintainability. Clients can implement only the interfaces they require, resulting in cleaner code that is easier to understand, test, and modify.

Benefits

Using the ISP in C# enables us to create interfaces tailored to specific client requirements. By avoiding the violation of ISP, we can build more flexible, modular, and maintainable code. Breaking down large interfaces into smaller, cohesive ones reduces coupling and improves code organization.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) focuses on decoupling high-level modules from low-level modules by introducing an abstraction layer, with the use of interfaces or abstract classes and reducing direct dependencies between classes.

Refer to the following example where a UserController class depends directly on a Database class for data storage.

public class UserController
{
    private Database database;
    public UserController()
    {
        this.database = new Database();
    }
    // ...
}

Issue

In the previous example, the UserController tightly couples with the concrete Database class, creating a direct dependency. If we decide to alter the database implementation or introduce a new storage mechanism, we will need to modify the UserController class, which violates the Open-Closed Principle.

Solution

To address this issue and adhere to the DIP, we must invert the dependencies by introducing an abstraction that both high-level and low-level modules depend on. Typically, this abstraction is defined using an interface or an abstract class.

Let’s modify the previous example to align with the DIP.

Refer to the following refactored code example.

public interface IDataStorage
{
    // Define the contract for data storage operations.
    void SaveData(string data);
    string RetrieveData();
}
public class Database : IDataStorage
{
    public void SaveData(string data)
    {
        // Implementation for saving data to a database.
    }
    public string RetrieveData()
    {
        // Implementation for retrieving data from a database.
    }
}
public class UserController
{
    private IDataStorage dataStorage;
    public UserController(IDataStorage dataStorage)
    {
        this.dataStorage = dataStorage;
    }
    // ...
}

In this updated version, we introduce the IDataStorage interface that defines the contract for data storage operations. The Database class implements this interface, providing a concrete implementation. Consequently, the UserController class now relies on the IDataStorage interface rather than the concrete Database class, resulting in it being decoupled from specific storage mechanisms.

This inversion of dependencies facilitates easier extensibility and maintenance. We can introduce new storage implementations, such as a file system or cloud storage, by simply creating new classes that implement the IDataStorage interface, without modifying the UserController or any other high-level modules.

Benefits

By applying the DIP, we achieve a more flexible and modular design, enabling us to evolve and adapt our systems more easily over time.

Advantages of using the SOLID principles

SOLID principles offer a set of guidelines that significantly impact the quality, maintainability, and scalability of software systems. Embracing these principles brings several key advantages to software development:

  • Enhanced maintainability: Following SOLID principles results in code that is easier to understand, update, and maintain. The principles encourage clear code organization, making it simpler to identify and modify specific functionalities without impacting the rest of the system. This ultimately reduces the chances of introducing bugs during maintenance.
  • Improved extensibility: By adhering to SOLID principles, code becomes more flexible and open for extension without requiring modifications to existing, functional code. This extensibility is particularly beneficial when adding new features or accommodating changing requirements, as it minimizes the risk of unintentionally breaking existing functionalities.
  • Facilitates reusability: SOLID principles promote the creation of modular, loosely coupled components. This modularity allows for greater code reuse across different parts of the system or in entirely new projects, leading to more efficient development processes.
  • Supports testability: Codes that follow SOLID principles tend to be more testable. The principles advocate for smaller, more focused units of code with clear responsibilities, making it easier to write unit tests and ensure proper functionality.
  • Reduces technical debt: Implementing SOLID principles from the start reduces the accumulation of technical debt. It encourages clean code practices, preventing issues such as code smells, tight coupling, and unnecessary complexity. Consequently, it saves time and effort that might otherwise be spent refactoring or fixing problematic code later in the development lifecycle.

GitHub reference

For more details, refer to the project on SOLID Principles in C# GitHub demo.

Conclusion

Thanks for reading! In this article, we explored each SOLID principle and discussed how to implement them in C# with code examples. We examined how separating responsibilities, allowing for extension without modification, ensuring correct substitution, segregating interfaces, and inverting dependencies can lead to better software design.

By following the SOLID principles, developers can write more modular, reusable, and easier-to-understand code. These principles contribute to creating robust and adaptable software systems capable of evolving and effectively handling changing requirements.

For existing customers, the new version of Essential Studio® is available for download from the License and Downloads page. If you are not yet a Syncfusion customer, you can try our 30-day free trial to check out our available features.

For questions, you can contact us through our support forum, support portal, or feedback portal. We are always happy to assist you!

Related resources

Meet the Author

A. Yohan Malshika

I am a Software Engineer who loves to explore technologies and familiar with ASP.NET Core, ASP.NET MVC, Angular, Azure, Flutter, and Blazor. I also love to write tech blogs and share knowledge with others.