Introduction

In the world of software development, maintaining clean and maintainable code is paramount. To achieve this, we often turn to design principles that guide us in writing robust and flexible software.

Among these principles, the SOLID principles are a set of five foundational guidelines that help us create code that is easy to understand, extend, and modify.

In this article, we’ll dive into each of the SOLID principles — Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP) — and explore how they can be applied to C# code. We’ll provide detailed explanations of each principle, followed by two examples for each: one violating the principle and another that resolves the issue.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) emphasizes that “a class should have only one reason to change”, or in other words, a single responsibility.

This principle aims to keep classes focused on doing one thing well. Because of confusion around the word “reason”, he has also clarified saying that the principle is about roles or actors. For example, while they might be the same person, the role of an accountant is different from a database administrator. Hence, each module should be responsible for each role.

By adhering to SRP, you can make your code more modular, easier to understand, and less prone to unexpected side effects.

Violating SRP

Consider a CustomerService class that handles both customer data storage and email notifications:

public class CustomerService
{
public void AddCustomer(Customer customer)
{
// Code to add a customer to the database
}

public void SendEmailToCustomer(Customer customer)
{
// Code to send an email to the customer
}
}

In this example, CustomerService violates SRP because it combines two distinct responsibilities—managing customer data and sending emails. If either of these responsibilities changes, the class requires modification, potentially causing unforeseen issues.

Resolving SRP

To adhere to SRP, we can separate the responsibilities into two classes:

public class CustomerRepository
{
public void AddCustomer(Customer customer)
{
// Code to add a customer to the database
}
}

public class EmailService
{
public void SendEmailToCustomer(Customer customer)
{
// Code to send an email to the customer
}
}

Now, the CustomerRepository class handles database operations, while the EmailService class manages email-related tasks. Each class has a single responsibility, making it more maintainable and less prone to unexpected changes.

Examples of violations:

  • Mixing Concerns: A class that handles both user authentication and database connection management within the same methods.
  • Bloated Method: A method that performs multiple unrelated tasks, such as calculating a total cost and sending an email, rather than having separate methods for each.
  • Logging Overload: A logging class that logs messages, formats them, and sends them via email, combining logging and email functionality.

Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) states that software entities (classes, modules, functions) should be open for extension but closed for modification.

This principle encourages you to design your code in a way that allows you to add new features or functionality without altering existing code.

Violating OCP

Imagine an AreaCalculator class that calculates the area of various shapes, initially designed for rectangles:

public class AreaCalculator
{
public double CalculateArea(Rectangle[] rectangles)
{
double totalArea = 0;
foreach (var rectangle in rectangles)
{
totalArea += rectangle.Width * rectangle.Height;
}
return totalArea;
}
}

In this example, the AreaCalculator class violates OCP because adding support for new shapes would require modifying the existing code, potentially introducing bugs.

Resolving OCP

To adhere to OCP, we can design our code to be open for extension by introducing an abstract Shape class and creating concrete shape classes that inherit from it:

public abstract class Shape
{
public abstract double CalculateArea();
}

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

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

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

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

Now, adding support for new shapes can be done by creating new classes that inherit from Shape, without modifying the existing AreaCalculator class. This adheres to the OCP, promoting code extensibility.

Examples of violations:

  • Hardcoded Rules: A payment processing class with hard-coded discount rules, requiring changes to the class’s code for each new discount.
  • Conditional Checks: A shape drawing application that uses long chains of if statements to determine how to draw each shape, instead of extending the application for new shapes without modifying existing code.
  • Limited Extension Points: A plugin system that only allows extensions defined in a specific assembly, preventing external plugins from extending the system.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) emphasizes that objects of a derived class must be substitutable for objects of the base class without affecting the correctness of the program.

This principle ensures that inheritance hierarchies maintain consistency and do not introduce unexpected behaviors.

Violating LSP

Consider a Bird base class with a Fly method, and a Ostrich subclass that overrides Fly with an exception:

public class Bird
{
public virtual void Fly()
{
Console.WriteLine("A bird can fly.");
}
}

public class Ostrich : Bird
{
public override void Fly()
{
throw new InvalidOperationException("Ostriches can't fly.");
}
}

In this example, the Ostrich class violates LSP because it alters the behavior of the base class’s Fly method, which is unexpected behavior for a subclass.

Resolving LSP

To adhere to LSP, we can use interfaces to create a common contract for both Bird and Ostrich classes:

public interface IFlyable
{
void Fly();
}

public class Bird : IFlyable
{
public void Fly()
{
Console.WriteLine("A bird can fly.");
}
}

public class Ostrich : IFlyable
{
public void Fly()
{
Console.WriteLine("An ostrich can't fly.");
}
}

By introducing the IFlyable interface, both Bird and Ostrich classes adhere to LSP, as they provide consistent and substitutable behavior for the Fly method.

Examples of violations:

  • Override with No Effect: A subclass that overrides a base class method but leaves the method empty, effectively doing nothing.
  • Throwing Unexpected Exceptions: A subclass that overrides a base class method but throws an exception that the base class method does not declare.
  • Incompatible Return Types: A subclass that overrides a base class method but returns values of different types, breaking the expected contract.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) suggests that clients should not be forced to depend on interfaces they do not use.

This principle encourages the creation of smaller, more focused interfaces tailored to specific client requirements.

Violating ISP

Suppose we have an interface called IVehicle for vehicles, which includes methods for driving and flying:

public interface IVehicle
{
void Drive();
void Fly();
}

In this case, the IVehicle interface violates ISP because not all vehicles can both drive and fly. For example, a bicycle can only be driven, and an airplane can only fly. Implementing the IVehicle interface in classes for these vehicles would force them to provide unnecessary and irrelevant methods.

Resolving ISP

To adhere to ISP, we should create more specific interfaces tailored to each type of vehicle:

public interface IDrivable
{
void Drive();
}

public interface IFlyable
{
void Fly();
}

Now, vehicles can implement the relevant interfaces based on their capabilities:

public class Bicycle : IDrivable
{
public void Drive()
{
// Code to drive a bicycle
}
}

public class Airplane : IFlyable
{
public void Fly()
{
// Code to fly an airplane
}
}

By introducing separate interfaces (IDrivable and IFlyable), we adhere to ISP, ensuring that clients are not forced to implement methods they do not need. This promotes a more granular and flexible approach to interface usage and class design.

Examples of violations:

  • Big Fat Interfaces: An interface with dozens of methods, where implementing classes must provide empty implementations for methods they don’t need.
  • Client Forced to Implement Irrelevant Methods: An interface meant for file handling with methods like ReadWrite, and Delete, but a client class that implements it must provide empty implementations for methods like Resize and Compress.
  • Monolithic Service Contracts — A service interface used by multiple clients, where each client only needs a subset of the methods, causing unnecessary dependencies for clients.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions.

This principle promotes decoupling between components, making the codebase more adaptable to changes and facilitating unit testing.

Violating DIP

Consider a Switch class that directly depends on the concrete LightBulb class:

public class LightBulb
{
public void TurnOn()
{
// Code to turn on the light bulb
}

public void TurnOff()
{
// Code to turn off the light bulb
}
}

public class Switch
{
private readonly LightBulb bulb;

public Switch()
{
bulb = new LightBulb();
}

public void Toggle()
{
if (bulb.IsOn)
bulb.TurnOff();
else
bulb.TurnOn();
}
}

In this example, the Switch class violates DIP by directly depending on a concrete implementation, making it inflexible and tightly coupled.

Resolving DIP

To adhere to DIP, we can introduce an abstraction (interface) for the switchable device and inject it into the Switch class:

public interface ISwitchable
{
void TurnOn();
void TurnOff();
}

public class LightBulb : ISwitchable
{
public void TurnOn()
{
// Code to turn on the light bulb
}

public void TurnOff()
{
// Code to turn off the light bulb
}
}

public class Switch
{
private readonly ISwitchable device;

public Switch(ISwitchable device)
{
this.device = device;
}

public void Toggle()
{
if (device.IsOn)
device.TurnOff();
else
device.TurnOn();
}
}

By introducing the ISwitchable interface and injecting it into the Switch class, we adhere to DIP, promoting loose coupling and enabling flexibility in choosing different switchable devices.

Examples of violations:

  • High-Level Code Directly Depends on Low-Level Code — A high-level module that directly accesses a low-level database module, creating tight coupling.
  • Concrete Instantiation in High-Level Code — A service that instantiates concrete dependencies within its high-level module, rather than relying on dependency injection.
  • Lack of Abstraction — A class that relies on specific implementations of external services without using interfaces or abstractions, making it difficult to replace or test components.

Conclusion

The SOLID principles — Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle — are essential guidelines for writing clean, maintainable, and extensible C# code.

By understanding and applying these principles, you can enhance the quality of your software, making it more modular, adaptable, and less prone to errors and unexpected behavior.