Mastering SOLID Principles with C#: Building Maintainable Code

Introduction: SOLID is an acronym representing five principles that assist developers in building maintainable and scalable systems. In this tutorial, we will explore the SOLID principles with practical C# examples.

S - Single Responsibility Principle (SRP): SRP states that a class should have only one reason to change. This ensures that a class is focused on what it should do and does not take on responsibilities that it shouldn't.

Example:

public class Logger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

public class Calculator
{
    private readonly Logger _logger;

    public Calculator(Logger logger)
    {
        _logger = logger;
    }

    public int Add(int a, int b)
    {
        _logger.Log("Adding numbers.");
        return a + b;
    }
}

Here, the Calculator class is only concerned with calculations and the Logger the class handles logging.

O - Open/Closed Principle (OCP): OCP states that a class should be open for extension but closed for modification. This can be achieved by using interfaces, abstract classes, or virtual methods.

Example:

public interface IShape
{
    double Area();
}

public class Rectangle : IShape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public double Area()
    {
        return Width * Height;
    }
}

public class Circle : IShape
{
    public double Radius { get; set; }
    public double Area()
    {
        return Math.PI * Radius * Radius;
    }
}

L - Liskov Substitution Principle (LSP): The Liskov Substitution Principle (LSP) 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 simple terms, if class B is a subclass of class A, then we should be able to replace A with B without disrupting the behavior of the program.

One common scenario where LSP is violated involves overriding the base class methods in a way that's not compatible with the behavior expected from the base class.

Example: Let's consider an example involving shapes:

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; }
    }
}

This example involves a Rectangle class and a Square class that inherits from Rectangle. It seems logical, as a square is a special kind of rectangle. However, this violates the Liskov Substitution Principle. A square has all sides equal, so setting either the width or the height should set both, while a rectangle doesn't have this constraint.

If you use a Square object in a context where a Rectangle object is expected, this could lead to unexpected behavior.

csharpCopy codeRectangle rectangle = new Square();
rectangle.Width = 5;
rectangle.Height = 10;
Console.WriteLine(rectangle.Area()); // This will output 100 instead of 50.

Solution: To comply with LSP, we could use a more general type for both classes, like Polygon, and then use separate properties and methods that make sense for each derived class without making assumptions.

public interface IPolygon
{
    int Area();
}

public class Rectangle : IPolygon
{
    public int Width { get; set; }
    public int Height { get; set; }

    public int Area()
    {
        return Width * Height;
    }
}

public class Square : IPolygon
{
    public int Side { get; set; }

    public int Area()
    {
        return Side * Side;
    }
}

In this example, both Rectangle and Square implement the IPolygon interface. This approach respects the Liskov Substitution Principle and avoids unexpected behavior.

I - Interface Segregation Principle (ISP): ISP states that a class should not be forced to implement interfaces it doesn't use. Interfaces should be small and focused.

Example:

public interface IPrinter
{
    void Print();
}

public interface IFax
{
    void Fax();
}

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

D - Dependency Inversion Principle (DIP): DIP encourages dependence on abstractions rather than concretions. This makes the system more flexible and adaptive to change.

Example:

public interface IMessageSender
{
    void SendMessage(string message);
}

public class EmailSender : IMessageSender
{
    public void SendMessage(string message)
    {
        // Send email
    }
}

public class Notification
{
    private readonly IMessageSender _messageSender;

    public Notification(IMessageSender messageSender)
    {
        _messageSender = messageSender;
    }

    public void Notify(string message)
    {
        _messageSender.SendMessage(message);
    }
}

Conclusion: The SOLID principles are essential for creating maintainable, scalable, and robust systems. With practical application in C#, you can write cleaner and more efficient code that stands the test of time.