SOLID Design Principles in C#: A Complete Example

SOLID is a set of five design principles introduced by Robert C. Martin in the year 2000 to make code more maintainable, flexible, and scalable. In this article, we will explore each of these principles and how they can be applied when using C#. We will also learn a real-world example to show how these principles can be implemented.

SOLID Design Principles

SOLID is a widely recognized acronym that represents a set of five fundamental design principles in software development, namely the Single Responsibility Principle, Open-Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle.

SOLID-Design-principles
SOLID Design Principles

These principles are crucial for ensuring the maintainability, scalability, and overall quality of software systems, and are commonly adopted by experienced software engineers to improve their development process.

The following are five SOLID principles:

  • S: Single Responsibility Principle (SRP)
  • O: Open-closed Principle (OCP)
  • L: Liskov substitution Principle (LSP)
  • I: Interface Segregation Principle (ISP)
  • D: Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that a class should have one and only one reason to change. This means that a class should only be responsible for one thing and should not be responsible for more than one thing. In C#, We can apply this principle by creating classes with a single, clear purpose and by separating different responsibilities into separate classes.

In order to implement the Single Responsibility Principle (SRP) in our software design, we must ensure that each class or module has a specific, single purpose.

This doesn’t mean that a class can only have one method or property, but rather that all members (methods, fields, or properties) within the class are related to a single responsibility or function. By following SRP in C#, we can create smaller and more organized classes that are easier to maintain.

Advantages of the Single Responsibility Principle (SRP) in C#:

The following are some advantages of the Single Responsibility Principle in C#.

(SRP) AdvantageDescription
Simplifies maintenance:By keeping a single class focused on a single responsibility, it becomes easier to understand the behavior of the class and make changes to it.
Increases cohesion:Classes that follow the SRP have a higher degree of cohesion, meaning that the methods and properties of the class are closely related to its responsibility. This makes the class more robust and less prone to errors.
Enhances reusability:Classes that follow the SRP are more reusable, as they can be easily reused in other parts of the system without requiring extensive modification.
Improves testability:Classes that follow the SRP are easier to test, as they have a single, well-defined responsibility that can be tested in isolation.
Enhances flexibility:Classes that follow the SRP are more flexible, as they can be easily extended or modified to accommodate new requirements without affecting other parts of the system.
Single Responsibility Principle

Example: SRP

For example, Let’s say we are creating a class that handles both customer data and order data. This class would be responsible for two separate things and would violate the SRP. To fix this issue, we could separate the customer and order data into two separate classes.

This is how the class might look:

interface ICustomer
{
    void SaveCustomerRecord();
    void ValidateCustomer();
}
interface IOrder
{
    void PrintOrder();
}
class Customer: ICustomer
{
    // Properties
    public string Name { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

    // Methods
    public void SaveCustomerRecord()
    {
        // Code to save the customer's information to a database
    }

    public void ValidateCustomer()
    {
        // Code to validate the customer's information before saving
    }
}
public class Order: IOrder
{
    public void PrintOrder()
    {
        // Code to print an invoice for the customer Order
    }
}

Code Explanation:

The above code example demonstrates the Single Responsibility Principle (SRP) by separating the responsibilities of different objects into different interfaces and classes. The ICustomer interface defines two methods, SaveCustomerRecord and ValidateCustomer, which are used to save and validate customer information respectively. The IOrder interface defines a single method called PrintOrder, which is used to print an invoice for a customer order.

The Customer class implements the ICustomer interface and provides the implementation for the SaveCustomerRecord and ValidateCustomer methods. It also includes properties for storing the customer’s name, address, and phone number. The class has only one reason to change, which is related to customer management functionality.

Similarly, the Order class implements the IOrder interface and provides the implementation for the PrintOrder method. It has only one reason to change, which is related to order management functionality.

By separating the responsibilities of different objects into different interfaces and classes, the code adheres to the Single Responsibility Principle, making it easier to understand, maintain, and extend. As the responsibilities are separated, it becomes easier to make changes to one class without affecting the other.

Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) states that a class or module should be open for extension, but closed for modification. This means that the class or module should be designed in a way that allows new functionality to be added without changing the existing code.

In C#, this principle can be applied by using interfaces, abstract classes, and polymorphism.

Advantages of the Open/Closed Principle in C#

The following are some advantages of the Open/Closed Principle in C#.

(OCP) AdvantageExplanation
Easier to extend:The Open/Closed Principle (OCP) allows us to add new functionality to a program by creating new classes that extend existing ones rather than modifying existing code. This makes it easier to add new functionality without risking breaking the existing features.
Reduced risk of introducing bugs:By not modifying existing code, there is a reduced risk of introducing bugs into the program. This makes the program more stable and less prone to errors.
Easier to maintain:The Open/Closed Principle makes it easier to maintain a system because new functionality can be added without the need to change existing code. This makes it easier to understand and update the system over time.
Better code organization:The Open/Closed Principle promotes better code organization by separating different functionality into different classes. This makes the code easier to understand, navigate, and maintain.
Improved reusability:The Open/Closed Principle promotes improved reusability by allowing existing classes to be extended and reused in new contexts. This makes it easier to reuse existing code and reduces the need to write new code.
Advantages of the Open/Closed Principle

Example: OCP

The following is an example of the Open/Closed Principle (OCP), where we are using an abstract class to extend the functionality of a class without modifying the class itself.

public abstract class Payment
{
    public abstract void makePayment();
}

public class CreditCardPayment : Payment
{
    public override void makePayment()
    {
        // Code to process credit card payment
    }
}

public class DebitCardPayment : Payment
{
    public override void makePayment()
    {
        // Code to process debit card payment
    }
}

public class EwalletPayment : Payment
{
    public override void makePayment()
    {
        // Code to process e-wallet payment
    }
}

public class PaymentProcessor
{
    public void ProcessPayment(Payment payment)
    {
        payment.makePayment();
    }
}

Code explanation:

In this example, we have an abstract Payment class that defines a single method, makePayment(). This method is meant to be overridden by subclasses. We have three subclasses, CreditCardPayment, DebitCardPayment, and EwalletPayment, which all override the makePayment() method to provide their own implementation of how to process the payment using the specific method.

The Open/Closed Principle states that a class should be open for extension but closed for modification. In this example, we can extend the functionality of the program by adding new payment methods (e.g. BankTransferPayment) without modifying the existing code. The PaymentProcessor class will work with the new payment method without any modification because it relies on the Payment abstract class and the makePayment() method which is defined in the Payment class and overridden by the new payment method class.

This way, we can add new payment methods and the PaymentProcessor class will work with them seamlessly, without modifying the existing code, this is the key point of the OCP principle.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. In other words, derived classes should be able to stand in for their base classes.

To adhere to this principle in C#, we need to ensure that our derived classes have the same method signatures as their base classes. Additionally, the behavior of the derived class should not violate any contracts established by the base class.

The Liskov Substitution Principle (LSP) says that if class (B) is derived from another class (A), then objects of class B should be able to replace objects of class A without affecting the correctness of the program. In simpler terms, it means that if a program is using a class (A), it should be able to use the child class (B) without any issues.

Advantages of the Liskov Substitution Principle (LSP)

Advantage of LSPExplanation
Promotes code reusability:Classes that conform to the Liskov Substitution Principle can be easily substituted for one another, making it easy to reuse code and reduce the amount of duplicate code.
Increases code maintainability:When you follow the Liskov Substitution Principle, the code becomes more predictable and understandable, resulting in easier maintenance and updates.
Enhances code flexibility:The Liskov Substitution Principle makes it possible to easily add new functionality to a system without affecting existing code, which makes the system more flexible.
Improves code robustness:The Liskov Substitution Principle helps to make code more robust and less prone to bugs by ensuring that objects behave as expected.
Advantages of the Liskov Substitution Principle

Example: LSP

The following is the full working example of the Liskov Substitution Principle (LSP) in C#:

using System;
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
{
    private int _side;
    public override int Width
    {
        get { return _side; }
        set { _side = value; }
    }

    public override int Height
    {
        get { return _side; }
        set { _side = value; }
    }
}

class LSPExample
{
    static void Main(string[] args)
    {
        Rectangle rect = new Rectangle();
        rect.Width = 10;
        rect.Height = 5;
        Console.WriteLine("Area of rectangle = " + rect.Area());

        Square sq = new Square();
        sq.Width = 5;
        Console.WriteLine("Area of square = " + sq.Area());

        // Liskov Substitution Principle
        rect = sq;
        Console.WriteLine("Area of rectangle = " + rect.Area());

        Console.ReadKey();
    }
}
// Output:
// Area of rectangle = 50
// Area of square = 25
// Area of rectangle = 25

Code explanation:

In the above example, we have a ‘Rectangle‘ class and a ‘Square‘ class that inherits from it. The ‘Rectangle‘ class has a ‘Width‘ and a ‘Height‘ property, and a method ‘Area()‘ that calculates the area of the rectangle.

The Square class overrides the Width and Height properties by using a private field _side, to ensure that a square object always has the same width and height. It also inherits the Area() method from the Rectangle class, so it doesn’t need to override it.

In the Main method, we can see that we can create an instance of Rectangle and Square classes, set their properties, and call the Area() method on them.

Then, we can see the Liskov Substitution Principle in action, by assigning the Square object to a Rectangle variable, this way, the program still functions correctly, and the Area() method on the rectangle variable will still return the correct area of the square.

The Liskov Substitution Principle states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program.

In this example, we can use an object of the Square class in any place where an object of the Rectangle class is expected. The Square class respects the contract of the Rectangle class by having the same properties and methods, and it guarantees that the width and height of a square are equal, so the Area() method will work correctly.

This way, we can substitute a Square object for a Rectangle object, and the program will still function correctly, and this is the key point of the Liskov Substitution Principle

We should keep in mind that LSP is one of the SOLID principles and it’s important to follow all of them together to make the code more maintainable, extensible and testable.

Interface Segregation Principle (ISP)

The Interface Segregation Principle states that no client should be forced to implement interfaces it does not use. In other words, we should not create large, monolithic interfaces that contain methods that are not relevant to all clients.

To adhere to this principle in C#, we should create smaller, more specific interfaces that only contain the methods relevant to specific clients.

Advantages of Interface Segregation Principle (ISP)

Advantage of ISPExplanation
Reduces Complexity:Interfaces that are specific to certain clients reduce the complexity of implementing and using those interfaces.
Increases Reusability:Interfaces that are specific to certain clients can be reused by other clients that have similar needs.
Increases Flexibility:Interfaces that are specific to certain clients can be easily modified or extended to meet the needs of those clients without affecting other clients that use the interface.
Increases Maintainability:Interfaces that are specific to certain clients are easier to maintain and update, as changes to the interface will only affect the clients that use it.
Advantages of Interface Segregation Principle (ISP)

Example: ISP

The following is an example of the Interface Segregation Principle in C#:

interface ICar
{
    void Drive();
    void FillFuel();
    void ChangeOil();
    void CheckTirePressure();
}

interface IElectricCar : ICar
{
    void ChargeBattery();
}

interface IGasCar : ICar
{
    void FillGas();
}

class ElectricCar : IElectricCar
{
    public void Drive()
    {
        // code to drive the electric car
    }

    public void ChargeBattery()
    {
        // code to charge the electric car's battery
    }

    // Electric car does not have to implement the FillFuel, ChangeOil or CheckTirePressure methods
}

class GasCar : IGasCar
{
    public void Drive()
    {
        // code to drive the gas car
    }

    public void FillGas()
    {
        // code to fill the gas tank
    }

    public void ChangeOil()
    {
        // code to change the oil
    }

    public void CheckTirePressure()
    {
        // code to check the tire pressure
    }
}

Code explanation:

In this example, we have two types of cars: electric cars and gas cars. Both types of cars have a Drive() method, but they have different ways of being maintained. Electric cars need to be charged, while gas cars need to be filled with fuel and oil changed.

Instead of having a single interface ICar with all the methods for both types of cars, we have separated the interface into IElectricCar and IGasCar which contain only the methods that are relevant to each car.

This way, the ElectricCar class only needs to implement the IElectricCar interface and GasCar class only needs to implement the IGasCar interface, which makes the code more maintainable, flexible and easy to change in the future. It also ensures that the client (in this case, the ElectricCar and GasCar classes) are not forced to depend on unnecessary methods, which also makes it more efficient.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In other words, we should not create tight coupling between classes.

To adhere to this principle in C#, we can use dependency injection to achieve loose coupling.

By injecting dependencies, we can ensure that our classes are not tightly coupled to one another. This makes it easy to change or replace a dependency without affecting the rest of the system.

Advantage of Dependency Inversion Principle (DIP)

Advantage (DIP)Description
Decouples high-level and low-level modules:The Dependency Inversion Principle (DIP) promotes the use of interfaces, which allows for decoupling between high-level and low-level modules. This means that changes to one module will not affect the other, making the code more flexible and maintainable.
Increases flexibility and maintainability of code:Using DIP, We can write and design the code in such a way that dependencies are managed through interfaces, rather than concrete classes. This means that the code is more flexible and maintainable because it is easier to replace or update specific parts of the system without affecting the entire system.
Improves testability of code:The DIP allows for the use of mock objects in place of concrete dependencies, which makes it easier to test the code. This is because mock objects can be used to simulate the behavior of the actual dependencies, without the need for the actual dependencies to be present.
Enables the use of different implementations:The DIP promotes the use of interfaces, which allows for multiple implementations of a given interface. This means that the code is more flexible and can be adapted to different situations.
Promotes the use of interfaces:The DIP promotes the use of interfaces as a more stable form of abstraction than concrete classes. Interfaces are less likely to change than concrete classes, which makes the code more stable and maintainable.
Advantage of the Dependency Inversion Principle

Example: DIP

The following is an example of the Dependency Inversion Principle in C#:

interface IDataAccess {
    void Read();
    void Write();
}

class DataAccess : IDataAccess {
    public void Read() {
        // Implementation for reading data
    }

    public void Write() {
        // Implementation for writing data
    }
}

class BusinessLogic {
    private IDataAccess _dataAccess;

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

    public void ReadData() {
        _dataAccess.Read();
    }

    public void WriteData() {
        _dataAccess.Write();
    }
}

Code explanation:

The above example illustrates the Dependency Inversion Principle by using an interface (IDataAccess) to define the methods for accessing data, and a concrete class (DataAccess) to implement those methods. The BusinessLogic class, which contains the logic for reading and writing data, depends on the IDataAccess interface rather than the concrete DataAccess class. This allows for greater flexibility and ease of testing, as the BusinessLogic class can be tested using a mock implementation of the IDataAccess interface.

The Dependency Inversion Principle (DIP) is about inverting the way a high-level module depend on low-level modules. In the example above, the BusinessLogic class is a high-level module, it doesn’t depend on the concrete DataAccess class, it depends on the IDataAccess interface which is a low-level module. This allows to change the implementation of DataAccess class without affecting the BusinessLogic class.

Conclusion:

In this post, we have explored the SOLID design principles and how to apply them in C#. By adhering to these principles, we can write maintainable and scalable code that is easy to understand and modify.

  • Single Responsibility Principle: It ensures that a class should have one and only one reason to change, meaning that a class should have only one job.
  • Open-Closed Principle: A class should be open for extension but closed for modification, meaning that a class should be able to have new behavior added to it, but its existing behavior should not be changed.
  • Liskov Substitution Principle: Derived classes should be able to replace their base classes without affecting the correctness of the program, meaning that objects of a superclass should be able to be replaced with objects of a subclass without altering the desirable properties of the program.
  • Interface Segregation Principle: Many client-specific interfaces are better than one general-purpose interface, meaning that it is better to have many small interfaces that are customized to the needs of specific clients, rather than having one large interface that tries to serve all clients.
  • Dependency Inversion Principle: It states that High-level modules should not depend on low-level modules, but both should depend on abstractions, meaning that the design should avoid depending on concrete classes, and instead depend on abstractions or interfaces.

References: csharpcorner-SOLID Principles In C#

Articles you might also like:

Don’t keep this post to yourself, share it with your friends and let us know what you think in the comments section.

5 3 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments