Published on

SOLID Principles in C# with Examples

Introduction

The SOLID principles are a set of guidelines for writing clean, maintainable, and scalable object-oriented code. These principles were introduced by Robert C. Martin (a.k.a. Uncle Bob) and have become a cornerstone of software development practices. In this article, we will explore each SOLID principle and provide examples in C#.

1. Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have a single responsibility or purpose. Let's consider an example:

// Wrong Example
class Customer
{
    public void Save()
    {
        // Code for saving customer data to the database
    }

    public void SendEmail()
    {
        // Code for sending a welcome email to the customer
    }
}

In the above example, the Customer class violates the SRP because it has two responsibilities: saving customer data and sending emails. To correct this, we can split the responsibilities into separate classes:

// Corrected Example
class CustomerRepository
{
    public void Save(Customer customer)
    {
        // Code for saving customer data to the database
    }
}

class EmailService
{
    public void SendWelcomeEmail(Customer customer)
    {
        // Code for sending a welcome email to the customer
    }
}

By separating the responsibilities, we adhere to the SRP and achieve better code organization and maintainability.

2. Open/Closed Principle (OCP)

The Open/Closed Principle states that classes should be open for extension but closed for modification. In other words, you should be able to extend the behavior of a class without modifying its source code. Let's consider an example:

// Wrong Example
class PaymentProcessor
{
    public void ProcessPayment(Payment payment)
    {
        if (payment.Type == PaymentType.CreditCard)
        {
            // Code for processing credit card payment
        }
        else if (payment.Type == PaymentType.PayPal)
        {
            // Code for processing PayPal payment
        }
        // More payment types...
    }
}

In the above example, the PaymentProcessor class violates the OCP because whenever a new payment type is introduced, we need to modify the class to handle the new type. To correct this, we can use polymorphism and abstraction:

// Corrected Example
interface IPaymentProcessor
{
    void ProcessPayment(Payment payment);
}

class CreditCardPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(Payment payment)
    {
        // Code for processing credit card payment
    }
}

class PayPalPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(Payment payment)
    {
        // Code for processing PayPal payment
    }
}

// Usage
IPaymentProcessor paymentProcessor = GetPaymentProcessor(payment.Type);
paymentProcessor.ProcessPayment(payment);

By introducing an interface and implementing it in separate classes, we can extend the payment processing behavior without modifying the PaymentProcessor class.

3. Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. Let's consider an example:

// Wrong Example
class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
}

class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }

    public override int Height
    {
        get => base.Height;
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}

In the above example, the Square class inherits from the Rectangle class, but the implementation violates the Liskov Substitution Principle because a square should not allow independent width and height changes without breaking the behavior of a rectangle.

To correct this, we can redefine the relationship between Square and Rectangle by applying proper constraints:

// Corrected Example
abstract class Shape
{
    public abstract int Area();
}

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

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

class Square : Shape
{
    public int SideLength { get; set; }

    public override int Area()
    {
        return SideLength * SideLength;
    }
}

In the corrected example, we use an abstract base class Shape and define the Area method as an abstract member. The Rectangle and Square classes inherit from Shape and provide their own implementations of Area. This ensures that the behavior of each shape is correctly maintained without violating the Liskov Substitution Principle.

Now, you can use the Area method to calculate the area of different shapes:

Shape rectangle = new Rectangle { Width = 10, Height = 5 };
int rectangleArea = rectangle.Area(); // Output: 50

Shape square = new Square { SideLength = 7 };
int squareArea = square.Area(); // Output: 49

By adhering to the Liskov Substitution Principle, we ensure that objects of the superclass (Shape) can be replaced with objects of its subclasses (Rectangle, Square) without affecting the correctness of the program.

4. Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. It promotes the idea of smaller, cohesive interfaces tailored to specific client needs. Let's consider an example:

// Wrong Example
interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

class Programmer : IWorker
{
    public void Work()
    {
        // Code for programming tasks
    }

    public void Eat()
    {
        // Code for eating
    }

    public void Sleep()
    {
        // Code for sleeping
    }
}

In the above example, the IWorker interface violates the ISP because clients that only need to perform specific tasks, such as Work, are forced to implement unnecessary methods like Eat and Sleep. To correct this, we can segregate the interface into smaller interfaces:

// Corrected Example
interface IWorker
{
    void Work();
}

interface IEater
{
    void Eat();
}

interface ISleeper
{
    void Sleep();
}

class Programmer : IWorker
{
    public void Work()
    {
        // Code for programming tasks
    }
}

class Chef : IWorker, IEater
{
    public void Work()
    {
        // Code for cooking tasks
    }

    public void Eat()
    {
        // Code for eating
    }
}

class Janitor : IWorker, ISleeper
{
    public void Work()
    {
        // Code for cleaning tasks
    }

    public void Sleep()
    {
        // Code for sleeping
    }
}

// Usage
IWorker programmer = new Programmer();
programmer.Work();

IWorker chef = new Chef();
chef.Work();
((IEater)chef).Eat();

IWorker janitor = new Janitor();
janitor.Work();
((ISleeper)janitor).Sleep();

In the corrected example, we have segregated the IWorker interface into smaller interfaces based on specific behaviors (IWorker, IEater, ISleeper). Each class can implement the necessary interfaces based on their requirements. This promotes better interface design and allows clients to depend only on the interfaces they actually use.

5. Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This principle promotes loose coupling and flexibility in the system. Let's consider an example:

// Wrong Example
class UserManager
{
    private UserRepository _userRepository;

    public UserManager()
    {
        _userRepository = new UserRepository();
    }

    public void CreateUser(User user)
    {
        _userRepository.Save(user);
    }
}

In the above example, the UserManager class violates the DIP because it directly depends on a concrete implementation (UserRepository) instead of an abstraction. To correct this, we introduce an abstraction and use dependency injection:

// Corrected Example
interface IUserRepository
{
    void Save(User user);
}

class UserRepository : IUserRepository
{
    public void Save(User user)
    {
        // Code for saving user to the database
    }
}

class UserManager
{
    private IUserRepository _userRepository;

    public UserManager(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public void CreateUser(User user)
    {
        _userRepository.Save(user);
    }
}

// Usage
IUserRepository userRepository = new UserRepository();
UserManager userManager = new UserManager(userRepository);
userManager.CreateUser(new User());

In the corrected example, we introduce the IUserRepository interface as an abstraction. The UserManager class depends on the abstraction through constructor injection, allowing for loose coupling. This enables us to easily swap implementations and promotes testability and maintainability.

Conclusion

The SOLID principles provide guidelines for writing maintainable and scalable code. By adhering to these principles, you can achieve better separation of concerns, extensibility, flexibility, and easier maintenance. In this article, we explored the Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle with examples in C#. Applying these principles can significantly improve the quality of your codebase and make it more robust and adaptable.