Mastering SOLID Design Principles in C#

What is the purpose of using the solid design principle?

The SOLID design principles offer a comprehensive set of guidelines and best practices for object-oriented software development. Adhering to these principles can result in software that is more maintainable, scalable, and flexible.

Here are several compelling reasons to embrace SOLID design principles.

  • Enhanced maintainability: SOLID principles foster code maintainability by promoting modular and well-structured code. This approach simplifies tasks such as understanding, modifying, and extending the codebase, all without introducing unintended side effects.
  • Improved flexibility and extensibility: SOLID design encourages the development of flexible and extensible software. By following these principles, developers can seamlessly introduce new features, modify existing functionality, or replace components without causing a ripple effect throughout the entire system.
  • Increased readability and understandability: Code aligned with SOLID principles tends to be more readable and understandable. Each principle addresses specific aspects of clean code, such as single responsibility, clear dependencies, and logical organization, making the codebase more approachable for developers.
  • Effective scalability: As software systems grow in complexity, SOLID principles help manage this complexity by preventing the codebase from becoming a tangled web of dependencies. This scalability is crucial for handling larger projects and adapting to changing requirements.
  • Facilitated testability: SOLID principles support the creation of code that is easier to test. Components with single responsibilities and clear interfaces facilitate the writing of unit tests, enabling more comprehensive and reliable testing strategies.
  • Reduced code duplication: SOLID design promotes the creation of reusable components, reducing code duplication. The DRY (Don't Repeat Yourself) principle is inherent in SOLID, encouraging developers to create abstractions and share functionality across the codebase.
  • Encouraged dependency inversion: The Dependency Inversion Principle (DIP) encourages the use of abstractions and interfaces, reducing tight coupling between components. This makes it easier to replace implementations, switch dependencies, and introduce new features without affecting the rest of the system.
  • Simplified debugging and maintenance: Code following SOLID principles is less prone to bugs and easier to debug. The clear structure and separation of concerns make it simpler to identify and fix issues, reducing the time and effort required for maintenance.
  • Promotion of collaborative development: SOLID principles provide a common set of guidelines that facilitate collaborative development. When developers adhere to these principles, it promotes consistency and a shared understanding of best practices within a development team.
  • Effective future-proofing: Following SOLID principles helps future-proof your codebase by making it more adaptable to changes. As requirements evolve or new features are introduced, code structured according to SOLID principles is better equipped to handle modifications and enhancements.

Incorporating SOLID design principles in your software development practices leads to code that is more maintainable, scalable, readable, and adaptable to change. While applying these principles may require an initial investment in learning and application, the long-term benefits contribute to the overall success of software projects.

Exploring solid design principles and implementing them in a project

SOLID Design Principles represent five Design Principles used to make software designs more understandable, flexible, and maintainable. The Five SOLID Design Principles are as follows:

  • S stands for the Single Responsibility Principle: S stands for the Single Responsibility Principle also known as SRP. The Single Responsibility Principle states that Each software module or class should have only one reason to change. In other words, we can say that each module or class should have only one responsibility.
  • O stands for the Open-Closed Principle: O stands for the  Open-Closed Principle, also known as OSP. The Open-Closed Principle states that software entities such as modules, classes, functions, etc., should be open for extension but closed for modification.
  • L stands for the Liskov Substitution Principle: L stands for the Liskov Substitution Principle, also known as LSP. The Liskov Substitution Principle states that the object of a derived class should be able to replace an object of the base class without bringing any errors in the system or modifying the behavior of the base class. That means the child class objects should be able to replace parent class objects without changing the correctness or behavior of the program.
  • I stand for the Interface Segregation Principle​​​​​: I stand for the Interface Segregation Principle, also known as ISP: The Interface Segregation Principle states that Clients should not be forced to implement any methods they don’t use. Rather than one fat interface, numerous little interfaces are preferred based on groups of methods, with each interface serving one submodule.
  • D stands for Dependency Inversion Principle: D stands for Dependency Inversion Principle, also known as DIP. The Dependency Inversion Principle (DIP) states that high-level modules/classes should not depend on low-level modules/classes. Both should depend upon abstractions. Secondly, abstractions should not depend upon details. Details should depend upon abstractions.

Let us try to understand the All Solid design principle with examples.

Single responsibility principle

The Single Responsibility Principle gives us a good way of identifying classes at the design phase of an application, and it makes you think of all the ways a class can change. However, a good separation of responsibilities is done only when we have the full picture of how the application should work.

Suppose there exists a class named Employee, which serves as a representation of an employee within a company. At first, this class assumes the responsibility of handling both employee data (including name, ID, and department) and generating reports. However, this violates the Single Responsibility Principle (SRP) as it possesses multiple reasons for potential modification. Any alterations to the reporting logic or the management of employee data would necessitate modifications to the Employee class.

Single responsibility principle

To adhere to SRP, we can split this into two classes: The Employee class is tasked with managing employee data exclusively. The ReportingService class is solely responsible for producing reports. Each class now has a distinct responsibility, ensuring that modifications are isolated to the relevant class when a system aspect changes (e.g., reporting logic). This enhances maintainability, minimizes the chance of bugs, and simplifies code comprehension and analysis.

Reporting service

Open-Closed principle (OCP)

The Open-Closed Principle emphasizes that a class should be open for extension but closed for modification. This means that you should be able to add new functionality to a class without altering its existing code.

Without OCP

If you hard-code each discount type, you would end up with a switch or if-else chain, and each time you wanted to add a new customer type or discount rule, you’d modify the class. Let us see how we can implement the above example without following the Open-Closed Principle in C#:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    //WithoutOCP
    enum CustomerType
    {
        Regular,
        Premium,
        NewBie,
        Special,
        Normal
    }
    internal class DiscountCalculator
    {
        public double CalculateDiscount(double price, CustomerType customerType)
        {
            switch (customerType)
            {
                case CustomerType.Regular:
                    return price * 0.1;
                case CustomerType.Premium:
                    return price * 0.3;
                case CustomerType.NewBie:
                    return price * 0.05;
                case CustomerType.Special:
                    return price * 0.5;
                case CustomerType.Normal:
                    return price * 0.01;
                default:
                    return price;
            }
        }
    }
}

With OCP

We can define a strategy pattern to adhere to OCP. Each discount type will have its class, and adding a new discount would mean adding a new class without modifying the existing ones. Let us see how we can implement the above example following the Open-Closed Principle in C#.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal interface IDiscountStrategy
    {
        double CalculateDiscount(double price);
    }
}

The first class is “Regular. cs”

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Regular : IDiscountStrategy
    {
        public double CalculateDiscount(double price)
        {
            return price * 0.1;
        }
    }
}

The second class for customer type Premium is “Premium. cs”

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Premium : IDiscountStrategy
    {
        public double CalculateDiscount(double price)
        {
            return price * 0.3;
        }
    }
}

The class for the new custom-type Special is “Special. cs”

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Special : IDiscountStrategy
    {
        public double CalculateDiscount(double price)
        {
            return price * 0.5;
        }
    }
}

Change in implementation

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Program
    {
        static void Main(string[] args)
        {
            // Without OCP
            DiscountCalculator discountCalculator = new DiscountCalculator();
            double result = discountCalculator.CalculateDiscount(200, customerType: CustomerType.Premium);
            // With OCP
            Regular regular = new Regular();
            Console.WriteLine(regular.CalculateDiscount(100).ToString());
            Special special = new Special();
            Console.WriteLine(special.CalculateDiscount(1000).ToString());
        }
    }
}

Liskov substitution principle (LSP)

The Liskov Substitution Principle says that the object of a derived class should be able to replace an object of the base class without bringing any errors in the system or modifying the behavior of the base class. That means child class objects should be able to replace parent class objects without compromising application integrity.

Let us first understand one example without using the Liskov Substitution Principle in C#. We will see the problem if we are not following the Liskov Substitution Principle, and then we will see how we can overcome such problems using the Liskov Substitution Principle. In the following example, first, we create the Apple class with the method GetColor. Then, we create the Orange class, which inherits the Apple class and overrides the GetColor method of the Apple class. The point is that an Orange cannot be replaced by an Apple, which results in printing the color of the apple as Orange, as shown in the example below.

Liskov substitution principle

Object

Output: Orange

As you can see in the above example, Apple is the base class, and Orange is the child class, i.e., there is a Parent-Child relationship. So, we can store the child class object in the Parent class Reference variable, i.e., Apple apple = new Orange(); and when we call the GetColor, i.e., apple.GetColor(), then we are getting the color Orange, not the color of an Apple. That means the behavior changes once the child object is replaced, i.e., Apple stores the Orange object. This is against the LSP Principle. The Liskov Substitution Principle states that even if the child object is replaced with the parent, the behavior should not be changed. So, in this case, if we are getting the color Apple instead of Orange, it follows the Liskov Substitution Principle. That means there is some issue with our software design.

Let us see how to overcome the design issue and make the application follow the Liskov Substitution Principle using C# Langauge.

Let’s modify the previous example to follow the Liskov Substitution Principle using C# Language. First, we need a generic base Interface, i.e., IFruitColors, which will be the base class for both Apple and Orange. Now, you can replace the apple variable can be replaced with its subtypes, either Apple or Orage, and it will behave correctly. In the code below, we created the super IFruit as an interface with the GetColor method. Then, the Apple and Orange classes were inherited from the Fruit class and implemented the GetColor method.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal interface IFruitColors
    {
        string GetColor();
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Orange : IFruitColors
    {
        public string GetColor()
        {
            return "Orange";
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Apple : IFruitColors
    {
        public string GetColor()
        {
            return "Red";
        }
    }
}

Now check implementation

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SolidDesignPrinciple
{
    internal class Program
    {
        static void Main(string[] args)
        {
            // Without LSP
            Apple apple = new Orange();
            Console.WriteLine(apple.GetColor());
            // With LSP
            IFruitColors apple = new Orange();
            Console.WriteLine(apple.GetColor());
            apple = new Apple();
            Console.WriteLine(apple.GetColor());
        }
    }
}

Now, run the application, and it should give the output as expected. Here, we follow the LSP as we can change the object with its subtype without affecting the behavior.

Interface Segregation Principle (ISP)

Here are the steps to implement interface segregation in C#:

  • Identify responsibilities: Create interfaces based on specific responsibilities or behaviors instead of using large, monolithic interfaces.
  • Keep interfaces small: Each interface should focus on a single responsibility and include only the necessary methods related to that responsibility.
  • Avoid fat interfaces: Do not require implementing classes to provide implementations for methods they do not need.
  • Prefer composition over inheritance: Instead of using interfaces with a wide range of methods, consider combining smaller interfaces to achieve the desired functionality.
  • Client-specific interfaces: Customize interfaces to meet the specific requirements of the clients (implementing classes) to reduce coupling and enhance cohesion.

The Interface Segregation Principle states that a client should not be forced to implement interfaces it doesn’t use. This promotes smaller, specific interfaces rather than large, monolithic ones.

Requiring a class

In the above example, To start, we commence with an initial interface called IMachine, which includes functions for printing, scanning, and faxing. When employing this interface, a class is required to implement all of these methods. Nevertheless, if only one method out of the three is needed, this approach can become burdensome. By adhering to the Interface Segregation Principle in C#, you ensure that your interfaces stay concise and focused, thereby promoting the creation of codebases that are easier to handle and adjust.

Please have a look at the following image. As you can see in the image below, we have split that big interface into three small interfaces. Each interface now has some specific purpose.

Interface segmentation

In the below example, we can observe distinct interfaces for printers, scanners, and fax machines, each with its specific role. The MultiFunctionPrinter class incorporates both the IPrinter and IScanner interfaces, whereas the SimplePrinter class solely implements the IPrinter interface. This division of interfaces guarantees that classes solely offer implementations for the methods they truly require, thereby adhering to the Interface Segregation Principle.

Simple printer

Dependency inversion principle (DIP)

The Dependency Inversion Principle advocates that high-level modules should not depend on low-level modules. Both should depend on abstractions. This promotes decoupling and flexibility in your code.

First, both should depend upon abstractions. Secondly, abstractions should not rely upon details. Finally, details should depend upon abstractions.

Example

A high-level module is a module that depends on other modules. In our example, CustomerBusinessLogic depends on the DataAccess class, so CustomerBusinessLogic is a high-level module and DataAccess is a low-level module. So, as per the first rule of DIP, CustomerBusinessLogic should not depend on the concrete DataAccess class, instead, both classes should depend on abstraction.

High level module

The BusinessLogic class represents the high-level module in the given example, while the DataAccess class represents the low-level module.

To elaborate, the BusinessLogic class is considered the high-level module because it encompasses the higher-level functionality of data processing. It contains the business logic of the application and orchestrates the overall process of data processing. Its responsibility lies in coordinating the application's logic as a whole.

On the other hand, the DataAccess class is regarded as the low-level module as it encapsulates the lower-level functionality of data access. It provides methods for retrieving data from a data source. This class solely focuses on data access operations and does not concern itself with higher-level business logic.

In this design, the BusinessLogic class directly depends on the DataAccess class, resulting in tight coupling between the two classes. Any changes made in the DataAccess class can directly impact the BusinessLogic class, which violates the principle of separation of concerns. Consequently, this tight coupling makes the system less flexible and maintainable.

The Dependency Inversion Principle (DIP) transforms tightly coupled classes into loosely coupled classes, as illustrated in the following process.

Tightly coupled classes

In C#, one can transform tightly coupled classes into loosely coupled classes by applying the Dependency Inversion Principle (DIP) along with methods like dependency injection and inversion of control (IoC) containers. Below are the steps to accomplish this:

  • Identify dependencies: Recognize the dependencies existing between classes. Tightly coupled classes typically directly instantiate and utilize concrete implementations of other classes.
  • Define abstractions: Introduce interfaces or abstract classes to establish abstractions for the dependencies. These abstractions serve as agreements that specify how the classes will communicate with each other.
  • Inject dependencies: Instead of classes internally creating instances of their dependencies, inject the dependencies into the classes externally. This can be done through constructor injection, method injection, or property injection.
  • Utilize dependency injection (DI) containers: Employ a DI container to automatically handle the creation and injection of dependencies. DI containers streamline the process of setting up dependencies and managing the object graph.

Implementation of DIP

Implementation of DIP

Explanation

We have introduced an abstraction called IDataAccess to represent the dependency on data access operations. BusinessLogic now relies on the IDataAccess abstraction instead of the specific implementation of DataAccess. The dependency on IDataAccess is injected into the BusinessLogic class through constructor injection. Within the Main method, we instantiate a DataAccess object (which is a concrete implementation of IDataAccess) and provide it to the BusinessLogic constructor, following the Dependency Inversion Principle. This refactoring ensures that BusinessLogic is no longer tightly coupled to DataAccess, resulting in code that is more flexible, maintainable, and compliant with DIP. Consequently, modifications made to the DataAccess class will not have a direct impact on BusinessLogic, allowing us to easily substitute different data access implementations without altering BusinessLogic.


Similar Articles