SOLID Design Principles In C# Explained

SOLID Design Principles In C# Explained

18 Sep 2024
Beginner
501K Views
34 min read
Learn with an interactive course and practical hands-on labs

⭐ .NET Design Patterns Course: Design Patterns in C# Online Training

SOLID Principles Explained Using C#

SOLID principles are essential concepts in object-oriented programming and design, particularly in C#. These principles help developers create better manageable, intelligible, and flexible software systems. By following SOLID principles, you may improve code quality while lowering the risk of introducing errors.

In this Design Patterns tutorial, we will discuss the SOLID Principles in C#, which include the following: What are the SOLID Principles?,and when should they be used? We'll also go over instances of each SOLID principle in C#. So, let us begin by examining what SOLID Principles are.

Why Do We Need to Learn SOLID Design Principles?

  • As developers, we frequently begin designing applications with our previous expertise and knowledge.
  • However, in time, these apps may grow prone to bugs.
  • Each change request or new feature could involve changes to the app's appearance.
  • Even simple activities may demand tremendous effort and an overall understanding of the system.
  • It is critical to understand that change requests and new features are inherent in software development; they cannot be denied.
  • The actual issue is with the application's design. Poor design might make the system more complex and challenging to maintain and update.

To address the issues raised above and to help students, beginners, and professional software developers learn how to create robust software using SOLID principles in C#, I've chosen to launch a series of articles on SOLID creation Principles. These articles will offer real-world examples to help readers understand the material and learn quickly.

Read More:
Different Types of Design Patterns
Different Types of Software Design Principles

What are the main reasons for most unsuccessful applications?

The following are the fundamental causes for most Unsuccessful applications

  • Overloading Classes with Functionalities: We frequently add too many functionalities to a class, even if they are not linked. This creates large and ungainly courses that are difficult to manage.
  • Tight Coupling Between Software Components: When classes are closely connected and rely significantly on one another, changes to one class can have a cascading effect, affecting other classes and making the system fragile and challenging to adjust.

How to Avoid Unsuccessful Application Development Problems?

To achieve successful application development, we must consider the following critical factors:

  • Use the Appropriate Architecture: Choose the appropriate architectural pattern (e.g., MVC, Layered, 3-tier, MVP) that best meets the project's requirements. This decision has far-reaching implications for the application's scalability, maintainability, and structure.
  • Follow Design concepts: Stick to recognized design concepts like SOLID and ONION. These principles lay the groundwork for building clean, maintainable, and scalable code.
  • Choose the Right Design Patterns: Use the design patterns that are suited to the project's requirements. These include creational patterns (e.g., factory, singleton), structural patterns (e.g., adapter, composite), behavioral patterns (e.g., observer, strategy), dependency injection, and repository patterns. Correct application of these patterns can solve common design issues and improve code quality.

What are solid principles?

  • The SOLID design principles serve as the framework for dealing with common software design difficulties that arise in daily development.
  • These concepts have been demonstrated to be effective in developing software that is clear, versatile, and maintained.

Let’s discuss each principle with code examples.

1. Single Responsibility Principle (SRP)

  • In C#, the Single Responsibility Principle (SRP) suggests that a class should only have one cause to change, i.e., one task or responsibility.
  • This idea makes code more maintainable, intelligible, and versatile by requiring that each class handle a single functionality or problem.

Example

Before Applying SRP

 using System;
using System.IO;

public class Employee
{
    public string Name { get; set; }
    public int HoursWorked { get; set; }
    public decimal HourlyRate { get; set; }

    public void SaveToFile()
    {
        File.WriteAllText("employee.txt", $"Name: {Name}, Hours Worked: {HoursWorked}, Hourly Rate: {HourlyRate}");
        Console.WriteLine("Employee data saved to file.");
    }

    public decimal CalculateSalary()
    {
        return HoursWorked * HourlyRate;
    }
}

class Program
{
    static void Main(string[] args)
    {
        Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };
        employee.SaveToFile(); // Violates SRP
        Console.WriteLine("Salary: " + employee.CalculateSalary()); // Violates SRP
    }
}

Output

Employee data saved to file.
Salary: 800

Explanation

In this example, the Employee class is responsible for both saving the employee details to a file and calculating the salary, violating the SRP.

After Applying SRP


using System;
using System.IO;

// Class responsible for storing employee data
public class Employee
{
    public string Name { get; set; }
    public int HoursWorked { get; set; }
    public decimal HourlyRate { get; set; }
}

// Class responsible for saving employee data
public class EmployeeDataSaver
{
    public void SaveToFile(Employee employee)
    {
        File.WriteAllText("employee.txt", $"Name: {employee.Name}, Hours Worked: {employee.HoursWorked}, Hourly Rate: {employee.HourlyRate}");
        Console.WriteLine("Employee data saved to file.");
    }
}

// Class responsible for salary calculations
public class SalaryCalculator
{
    public decimal CalculateSalary(Employee employee)
    {
        return employee.HoursWorked * employee.HourlyRate;
    }
}

class Program
{
    static void Main(string[] args)
    {
        Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };

        // Saving employee data to file using a dedicated class
        EmployeeDataSaver dataSaver = new EmployeeDataSaver();
        dataSaver.SaveToFile(employee);

        // Calculating salary using a dedicated class
        SalaryCalculator salaryCalculator = new SalaryCalculator();
        Console.WriteLine("Salary: " + salaryCalculator.CalculateSalary(employee));
    }
}

Output

Employee data saved to file.
Salary: 800

Explanation

In this example,

  • The "Employee" class only stores employee data.
  • "EmployeeDataSaver"is responsible for saving the data and adhering to SRP.
  • "SalaryCalculator" is responsible for calculating the salary.

2. Open Closed Principle (OCP)

  • The Open/Closed Principle (OCP) of C# specifies that a class should be available for extension but closed for modification.
  • This means that you should be able to add new functionality or behavior to a class without changing the old code, which is often accomplished through interfaces, abstract classes, and inheritance.
  • OCP helps to construct maintainable and scalable systems by limiting changes to current code when new features are implemented.

Example

Before applying for OCP

 using System;

public class Rectangle
{
    public double Width { get; set; }
    public double Height { get; set; }
}

public class Circle
{
    public double Radius { get; set; }
}

public class Shape
{
    public double CalculateArea(object shape)
    {
        if (shape is Rectangle rectangle)
        {
            return rectangle.Width * rectangle.Height;
        }
        else if (shape is Circle circle)
        {
            return Math.PI * circle.Radius * circle.Radius;
        }
        throw new ArgumentException("Unknown shape!");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Shape shapeCalculator = new Shape();
        Rectangle rectangle = new Rectangle { Width = 5, Height = 10 };
        Circle circle = new Circle { Radius = 3 };

        Console.WriteLine("Rectangle Area: " + shapeCalculator.CalculateArea(rectangle)); // Violates OCP
        Console.WriteLine("Circle Area: " + shapeCalculator.CalculateArea(circle));      // Violates OCP
    }
}

Output

 Rectangle Area: 50
Circle Area: 28.274333882308138

Explanation

In this example,

  • If you need to add a new shape (e.g., triangle), you must modify the Shape class, violating OCP.
  • Every time you add new shapes, you risk breaking existing code.

After applying OCP


using System;

// Base class or interface for shapes
public abstract class Shape
{
    public abstract double CalculateArea();
}

// Rectangle class extending the base class
public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }

    public override double CalculateArea()
    {
        return Width * Height;
    }
}

// Circle class extending the base class
public class Circle : Shape
{
    public double Radius { get; set; }

    public override double CalculateArea()
    {
        return Math.PI * Radius * Radius;
    }
}

// Now we can add new shapes without modifying the existing code
public class Triangle : Shape
{
    public double Base { get; set; }
    public double Height { get; set; }

    public override double CalculateArea()
    {
        return 0.5 * Base * Height;
    }
}

class Program
{
    static void Main(string[] args)
    {
        // Using polymorphism to handle different shapes
        Shape rectangle = new Rectangle { Width = 5, Height = 10 };
        Shape circle = new Circle { Radius = 3 };
        Shape triangle = new Triangle { Base = 4, Height = 6 };

        Console.WriteLine("Rectangle Area: " + rectangle.CalculateArea());
        Console.WriteLine("Circle Area: " + circle.CalculateArea());
        Console.WriteLine("Triangle Area: " + triangle.CalculateArea());
    }
}

Output

 Rectangle Area: 50
Circle Area: 28.274333882308138
Triangle Area: 12

Explanation

In this example,

  • Shape is an abstract base class that defines the CalculateArea method.
  • Rectangle, Circle, and Triangle classes extend the base class and provide their own implementations of the CalculateArea method.
  • You can add more shapes in the future by simply creating a new class (e.g., Triangle) without modifying the existing classes.
Read More:
Implementation of Dependency Injection In C#
.Net Design Patterns Interview Questions, You Must Know!

3. Liskov Substitution Principle (LSP)

  • The Liskov Substitution Principle (LSP) argues that objects of a superclass should be interchangeable with objects of a subclass without affecting the program's validity.
  • In other words, subclasses must increase the functionality of the parent class while maintaining its behavior, guaranteeing that derived classes can be used interchangeably with their base class without introducing unanticipated problems.
  • This idea contributes to a consistent and trustworthy interface.

Example

Before applying LSP


using System;

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("The bird is flying.");
    }
}

public class Penguin : Bird
{
    // Penguin cannot fly, so overriding Fly method with wrong behavior
    public override void Fly()
    {
        throw new NotSupportedException("Penguins can't fly.");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Bird myBird = new Bird();
        myBird.Fly(); // Works fine

        Bird myPenguin = new Penguin();
        myPenguin.Fly(); // Violates LSP, throws an exception
    }
}

Output

 Please pass correct input parameters!

Explanation

In this example, we have a Bird class where all birds are expected to fly. However, the Penguin class throws an exception in the Fly() method because penguins can't fly, violating LSP.

After applying LSP

 using System;

// Base class for all birds
public abstract class Bird
{
    public abstract void Move();
}

// Derived class for flying birds
public class FlyingBird : Bird
{
    public override void Move()
    {
        Fly();
    }

    public virtual void Fly()
    {
        Console.WriteLine("This bird is flying.");
    }
}

// Derived class for non-flying birds
public class NonFlyingBird : Bird
{
    public override void Move()
    {
        Walk();
    }

    public virtual void Walk()
    {
        Console.WriteLine("This bird is walking.");
    }
}

// Subclass for sparrow, a flying bird
public class Sparrow : FlyingBird
{
    public override void Fly()
    {
        Console.WriteLine("The sparrow is flying.");
    }
}

// Subclass for penguin, a non-flying bird
public class Penguin : NonFlyingBird
{
    public override void Walk()
    {
        Console.WriteLine("The penguin is waddling.");
    }
}

class Program
{
    static void Main(string[] args)
    {
        // Now substitution works correctly
        Bird sparrow = new Sparrow();
        sparrow.Move();  // Output: "The sparrow is flying."

        Bird penguin = new Penguin();
        penguin.Move();  // Output: "The penguin is waddling."
    }
}

Output

 The sparrow is flying.
The penguin is waddling.

Explanation

In this example,

  • We have an abstract Bird class that defines a general behavior (Move()), which is then specialized in subclasses.
  • FlyingBird and NonFlyingBird are derived from Bird. Each subclass handles its own behavior: FlyingBird uses Fly(), and NonFlyingBird uses Walk().
  • The substitution now works correctly: you can replace Bird with either Sparrow or Penguin, and they behave as expected, adhering to LSP.

4. Interface Segregation Principle (ISP)

  • The Interface Segregation Principle (ISP) argues that no client should be compelled to rely on methods that it does not use.
  • This entails creating small, particular interfaces rather than one huge, general-purpose interface.
  • ISP contributes to a more modular and maintainable codebase in which clients only implement the capabilities they require.

Example

Before applying ISP


using System;

// Interface with methods not all workers need
public interface IWorker
{
    void Work();
    void Eat();  // Not all workers eat
}
public class Human : IWorker
{
    public void Work()
    {
        Console.WriteLine("Human is working.");
    }

    public void Eat()
    {
        Console.WriteLine("Human is eating.");
    }
}
public class Robot : IWorker
{
    public void Work()
    {
        Console.WriteLine("Robot is working.");
    }

    // Robot doesn't eat, but we are forced to implement Eat() anyway
    public void Eat()
    {
        throw new NotImplementedException("Robots don't eat.");
    }
}
class Program
{
    static void Main(string[] args)
    {
        IWorker human = new Human();
        human.Work();  // Output: Human is working.
        human.Eat();   // Output: Human is eating.

        IWorker robot = new Robot();
        robot.Work();  // Output: Robot is working.
        robot.Eat();   // Throws exception: NotImplementedException: Robots don't eat.
    }
}

After applying ISP

 using System;
// Separate interface for working
public interface IWorkable
{
    void Work();
}

// Separate interface for eating
public interface IEatable
{
    void Eat();
}

// Humans can work and eat
public class Human : IWorkable, IEatable
{
    public void Work()
    {
        Console.WriteLine("Human is working.");
    }

    public void Eat()
    {
        Console.WriteLine("Human is eating.");
    }
}

// Robots only work, they don't eat
public class Robot : IWorkable
{
    public void Work()
    {
        Console.WriteLine("Robot is working.");
    }
}

class Program
{
    static void Main(string[] args)
    {
        // Human can work and eat
        IWorkable humanWorker = new Human();
        humanWorker.Work();  // Output: Human is working.

        IEatable humanEater = new Human();
        humanEater.Eat();  // Output: Human is eating.

        // Robot can only work, no need for Eat() method
        IWorkable robotWorker = new Robot();
        robotWorker.Work();  // Output: Robot is working.
    }
}

Output

 Human is working.
Human is eating.
Robot is working.

Explanation

In this example,

  • We now have two separate interfaces: IWorkable for the Work() method and IEatable for the Eat() method.
  • The Human class implements both IWorkable and IEatable because humans can both work and eat.
  • The Robot class implements only IWorkable because robots can only work, but they don’t eat.

5. Dependency Inversion Principle (DIP)

  • The Dependency Inversion Principle (DIP) suggests that high-level modules should not rely on low-level modules but on abstractions.
  • Furthermore, abstractions should not be dependent on details but rather the reverse.
  • This principle encourages decoupling by ensuring that code is based on interfaces or abstract classes, which improves flexibility and maintenance.

Example

Before applying DIP

 using System;
public class EmailService
{
    public void SendEmail(string message)
    {
        Console.WriteLine("Sending email: " + message);
    }
}

public class UserService
{
    private readonly EmailService _emailService;
    public UserService()
    {
        _emailService = new EmailService();
    }
    public void NotifyUser(string message)
    {
        _emailService.SendEmail(message);
    }
}
class Program
{
    static void Main(string[] args)
    {
        UserService userService = new UserService();
        userService.NotifyUser("Hello, user!");  // Sends email: Hello, user!
    }
}

Output

 Sends email: Hello, user!

Explanation

In this example,

  • A UserService directly depends on the EmailService. If we want to change the way notifications are sent (e.g., via SMS), we'd need to modify the UserService class, which violates the Dependency Inversion Principle.

After applying DIP

 using System;

// Abstraction (interface) for sending notifications
public interface INotificationService
{
    void Send(string message);
}

// Concrete implementation: EmailService
public class EmailService : INotificationService
{
    public void Send(string message)
    {
        Console.WriteLine("Sending email: " + message);
    }
}

// Concrete implementation: SMSService
public class SMSService : INotificationService
{
    public void Send(string message)
    {
        Console.WriteLine("Sending SMS: " + message);
    }
}

// High-level module (UserService) depends on abstraction (INotificationService)
public class UserService
{
    private readonly INotificationService _notificationService;

    // Dependency injection via constructor
    public UserService(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public void NotifyUser(string message)
    {
        _notificationService.Send(message);
    }
}

class Program
{
    static void Main(string[] args)
    {
        // Using EmailService
        INotificationService emailService = new EmailService();
        UserService emailUserService = new UserService(emailService);
        emailUserService.NotifyUser("Hello via Email!");  // Output: Sending email: Hello via Email!

        // Using SMSService
        INotificationService smsService = new SMSService();
        UserService smsUserService = new UserService(smsService);
        smsUserService.NotifyUser("Hello via SMS!");  // Output: Sending SMS: Hello via SMS!
    }
}

Output

 Sending email: Hello via Email!
Sending SMS: Hello via SMS!

Explanation

In this example,

  • INotificationService: Defines Send() method, implemented by EmailService and SMSService.
  • UserService: Depends on INotificationService to send notifications, allowing flexibility.
  • Program: Uses EmailService and SMSService with UserService to send messages via email or SMS.
Read More:
Top 50 Java Design Patterns Interview Questions and Answers
Most Frequently Asked Software Architect Interview Questions and Answers
Summary
The SOLID principles of Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion are critical for developing stable and scalable C# systems. They help developers build software with clear roles, expandability without modification, interchangeable components, focused interfaces, and decoupled dependencies, resulting in resilient and flexible codebases. To master design patterns, enroll in ScholarHat's Master Software Architecture and Design Certification Training.

FAQs

The SOLID principles in C# seek to improve code maintainability, scalability, and flexibility by encouraging good design practices and decreasing dependencies.

MVC follows the "S" of the SOLID principles by separating responsibilities. The model includes state information. The view includes items that interact with the user. The controller ensures that the sequence of steps is followed correctly.

Every developer should be familiar with the SOLID concepts of single responsibility, open/closed, Liskov substitution, interface segregation, and dependency inversion.

Design patterns are proven solutions for recurring situations. Design principles are recommendations that might assist you in structuring and constructing your software. That's all. You do not have to use every pattern and concept that exists.
Share Article
About Author
Sachin Gandhi (Author and Certified Scrum Master)

He has over 13 years of experience in Microsoft .Net applications development. He comes with expertise in different domains like health care, banking and custom application domains. He has proven skills in cutting-edge technologies and business processes. He has always been interested in learning new technologies and sharing the same with his team members. He has proven track record of managing and leading teams both onsite and offshore. He is always ready to contribute and bring his personal approach Microsoft. the success of the project.

Sachin holds a Masters Degree in Computer Applications from North Gujarat University and a Post Graduate Diploma degree in Financial Management from NMIMS, Mumbai. Furthermore, Sachin is Certified Scrum Master. He is working as Tech Lead with a proven track record in Information technology and service industry. 

Accept cookies & close this