SOLID Design Principles in C# .NET: Building Robust and Maintainable Software

SOLID Design Principles in C# .NET: Building Robust and Maintainable Software

The SOLID design principles in C# are an essential framework for crafting computer programs that are highly comprehensible, adaptable, and scalable. Coined by Robert C. Martin, these design principles are widely embraced within the realm of object-oriented programming to facilitate software development with ease and extendibility. SOLID is short for:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

In this post, we will go into each of these SOLID principles and show how to use them with examples in C# .NET.

1. Single Responsibility Principle (SRP):

The Single Responsibility Principle implies that a class should have a solitary reason to change, implying that it should have a only one responsibility. This design principle fosters robust cohesion by guaranteeing that a class excels at a singular task, thus amplifying code reusability, maintainability and testability. This is first principle of the SOLID in C#.

Example:

Let’s consider a simple scenario where we have a class User that is responsible for both storing user data and calculating their salaries.

public class User
{
    public int UserId { get; set; }
    public string UserName { get; set; }
    public decimal UserSalary { get; set; }

    public void CalculateUserSalary()
    {
        // Complex salary calculation logic.
    }

    // Other methods related to employee data.
}

In this example, the User class violates the SRP because it handles two distinct responsibilities – storing employee data and calculating salaries. To adhere to the SRP, we should split the functionality into two separate classes, one for storing employee data and another for salary calculation.

public class User
{
    public int UserId { get; set; }
    public string UserName { get; set; }
}

public class SalaryCalculator
{
    public decimal CalculateUserSalary(User user)
    {
        // Complex salary calculation logic.
    }
}

By doing so, we improve the maintainability and flexibility of our code. If there are changes in salary calculation logic, we only need to modify the SalaryCalculator class without affecting the User class.

2. Open/Closed Principle (OCP):

The OCP suggests that classes should be open for extension but closed for modification. It means that you should be able to extend the behavior of a class without modifying its existing code. This principle encourages the use of inheritance and polymorphism to achieve this.

Example:

Consider a scenario where you have a Shape class and various shape classes that derive from it.

public class Shape
{
    public virtual double Area()
    {
        throw new NotImplementedException();
    }
}

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

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

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

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

In this example, the Shape class is open for extension, as you can create new shapes by deriving from it (e.g., Triangle, Square, etc.), without modifying the existing Shape class. This adheres to the OCP.

3. Liskov Substitution Principle (LSP):

The LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, subclasses should be able to replace their parent classes seamlessly without causing unexpected behavior.

Example:

Consider a scenario where we have a Bird class and a Ostrich class that derives from it.

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

public class Ostrich : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException("Ostriches cannot fly.");
    }
}

The Ostrich class violates the LSP because it throws an exception when trying to fly, which is not expected from a bird. A better approach would be to create a separate IFlyable interface and have only flying birds implement it.

public interface IFlyable
{
    void Fly();
}

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

public class Ostrich : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Ostriches cannot fly.");
    }
}

Now, the Ostrich class can still replace the Bird class, but it provides a proper implementation for the Fly method that reflects the behavior of an ostrich.

4. Interface Segregation Principle (ISP):

The ISP states that a client should not be forced to depend on interfaces it does not use. It promotes the idea of having small, specific interfaces rather than large, monolithic interfaces.

Example:

Consider a scenario where you have a Printer class that can print documents and perform scanning.

public interface IPrinter
{
    void Print();
    void Scan();
}

public class Printer : IPrinter
{
    public void Print()
    {
        // Print logic.
    }

    public void Scan()
    {
        // Scanning logic.
    }
}

In this example, the IPrinter interface is too general, and some clients might only need printing functionality. To adhere to the ISP, we should split the IPrinter interface into two separate interfaces – IPrinter and IScanner.

public interface IPrinter
{
    void Print();
}

public interface IScanner
{
    void Scan();
}

public class Printer : IPrinter
{
    public void Print()
    {
        // Print logic.
    }
}

public class Scanner : IScanner
{
    public void Scan()
    {
        // Scanning logic.
    }
}

By doing this, clients can now depend only on the interfaces they require, which results in a more flexible and maintainable design.

5. Dependency Inversion Principle (DIP):

The DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. It emphasizes the use of interfaces or abstract classes to decouple high-level modules from the implementation details of low-level modules.

Example:

Consider a scenario where a User class has a direct dependency on a Logger class.

public class Logger
{
    public void Log(string message)
    {
        // Logging logic.
    }
}

public class User
{
    private Logger logger = new Logger();

    public void AddUser()
    {
        // Business logic.
        logger.Log("User added.");
    }
}

In this example, the User class is tightly coupled with the Logger class, which makes it difficult to change the logging mechanism or use a different logger.

To adhere to the DIP, we should introduce an abstraction (interface) for the logger and inject it into the User class through constructor injection.

public interface ILogger
{
    void Log(string message);
}

public class Logger : ILogger
{
    public void Log(string message)
    {
        // Logging logic.
    }
}

public class User
{
    private readonly ILogger logger;

    public User(ILogger logger)
    {
        this.logger = logger;
    }

    public void AddUser()
    {
        // Business logic.
        logger.Log("User added.");
    }
}

By doing this, we can easily swap the logger implementation without changing the User class, making it more flexible and loosely coupled.

Conclusion:

The SOLID principles are fundamental guidelines that help developers build well-structured, maintainable, and scalable software. By understanding and applying these principles, you can create code that is easier to understand, modify, and extend, resulting in robust and high-quality C# .NET applications. When combined with other best practices, SOLID design principles contribute significantly to the development of efficient and reliable software systems. Remember, adhering to these principles is a continuous journey, and it’s essential to continuously refactor and improve your codebase as you gain more insights and experience. Happy coding!

2 thoughts on “SOLID Design Principles in C# .NET: Building Robust and Maintainable Software

    1. SRP doesn’t restrict a class to just one function, but to one responsibility.
      So If multiple functions serve the same end goal, they can still align with SRP.

Leave a Reply

Your email address will not be published. Required fields are marked *