SOLID principles, a crucial set of guidelines in the world of programming, were collected, elaborated, and popularized by Robert C. Martin approximately two decades ago. The initial articles on SOLID principles started circulating in the late 90s, with a more comprehensive coverage provided in Martin's book, "Agile Software Development: Principles, Patterns, and Practices", published in 2002.

SOLID is an acronym formed from the first letters of each principle:

The beauty of SOLID lies in its approachability, providing concrete principles that build upon the abstract concepts of coupling and cohesion. Through practical methods, SOLID allows developers to strive towards reducing coupling and increasing cohesion.

While the principles are straightforward in their formulation, they are incredibly profound in their application. Simply understanding the definition is not enough to fully incorporate these principles into our work. To truly embrace and apply these principles effectively, we need to engage in thoughtful discussions and deliberate practice.

Understanding each principle individually is beneficial, but the real power of SOLID principles emerges when they are applied collectively as a cohesive unit.

The Single-Responsibility Principle

“A class should have only one reason to change”

Robert C. Martin acknowledges that this principle is very similar to the concept of cohesion, which has been discussed extensively by numerous authors since the late 70s. However, Martin's formulation presents a slight deviation. Instead of focusing on the cohesion of a class and applying various analyses to compute a cohesion score, Martin asserts that if a class has more than one reason to change, it is overloaded and should be divided into two classes, each with a distinct responsibility.

While this concept may sound straightforward, it's arguably the most challenging principle to implement in practice. Predicting future changes is a complex task, and it's not always practical to anticipate every change. It's also important not to expend effort isolating aspects of a class that are unlikely to change. Therefore, predicting potential future changes is critical, albeit difficult, and most of us may not get it right most of the time. It's not uncommon for someone to question, a year later, why a certain part of the class wasn't separated out, asserting a violation of the Single-Responsibility Principle.

The key is to recognize when you're modifying a class in more than one way and to separate the changing parts. One method to identify frequently changing classes is to use your source control history to generate a heat map of your code. Classes that frequently change over time are likely overloaded and could also be violating the Open-Closed Principle.

// Before: A class having multiple responsibilities
public class User
{
    public void Create(string userName)
    {
        // Code to add user
    }

    public void LogError(string error)
    {
        // Code to log error
    }
}

// After: Segregation of responsibilities into two classes
public class User
{
    public void Create(string userName)
    {
        // Code to add user
    }
}

public class Logger
{
    public void LogError(string error)
    {
        // Code to log error
    }
}

Figure 1: Single Responsibility Example in C#

In the example, User and Logger classes each have one responsibility. The User class is responsible for user-related operations and the Logger class is responsible for logging errors. This separation makes each class easier to maintain and understand.

The Open/Closed Principle

“Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.”

Ideally, the Open/Closed Principle suggests that for every functionality addition, we should create a new class or function, and existing code should remain unaltered. While this might not be entirely practical, it's a useful guideline to aspire to. Adhering to this principle allows us to enhance a system's functionality without disturbing existing features or triggering cascading changes. If we can reassemble components in diverse ways to achieve different results without rewriting the components themselves, we've attained a high degree of flexibility.

Modules that abide by the Open/Closed Principle usually exhibit two key attributes:

  • Open for extension: This implies that the module can be extended to alter its behavior.
  • Closed for modification: Despite the module's extendability, its source code doesn't need to be altered. Ideally, we should be able to extend the module's behavior without the necessity to recompile the binary.

It's worth noting that this principle is unattainable in a procedural language, as it requires some form of abstractions and polymorphism. A classic example of this principle in action is a switch statement dependent on an enum or a variable. This switch can typically be replaced with a class hierarchy employing polymorphism to execute the correct method – an approach that aligns with the Open/Closed Principle. Here, new behavior can be introduced by adding a class with a new polymorphic method.

As with the Single-Responsibility Principle, it's impossible to always predict how a class will need to be extended. The aim is to leverage our experience to foresee the most probable ways a class/module might need to be extended, ensuring it's prepared for such extensions. For instance, a deserializer might need to accommodate new types of byte sources. To meet this requirement, it should be designed to allow for the integration of an adaptable strategy.

public interface ISortingStrategy
{
    void Sort(int[] array);
}

public class BubbleSortStrategy : ISortingStrategy
{
    public void Sort(int[] array)
    {
        // Implementation of Bubble Sort
    }
}

public class QuickSortStrategy : ISortingStrategy
{
    public void Sort(int[] array)
    {
        // Implementation of Quick Sort
    }
}

public class SortingContext
{
    private ISortingStrategy _sortingStrategy;

    public SortingContext(ISortingStrategy sortingStrategy)
    {
        this._sortingStrategy = sortingStrategy;
    }

    public void SetStrategy(ISortingStrategy sortingStrategy)
    {
        this._sortingStrategy = sortingStrategy;
    }

    public void Sort(int[] array)
    {
        _sortingStrategy.Sort(array);
    }
}  

Figure 2: Open/Closed Example in C#

In the SortingContext example, new sorting algorithms can be added without modifying the existing SortingContext class. This is achieved by creating new classes that implement the ISortingStrategy interface. This way, the SortingContext class adheres to the Open/Closed Principle.

The Liskov Substitution Principle

“Subtypes must be substitutable for their base types”

The Liskov Substitution Principle (LSP) is often unknowingly violated by those who use inheritance. For instance, it's not uncommon to inherit from a base class and provide overrides that merely throw a NotImplementedException. This is a clear violation of LSP, as the subtype is not substitutable for the base type under all circumstances.

Despite this, there are scenarios where this approach is expedient. A prime example is the ADO.NET library. Not all database providers implement every feature defined by ADO.NET. While most providers support a majority of the features, dissecting each feature into separate interfaces would be rather challenging. In such cases, it's more pragmatic to occasionally throw NotImplementedExceptions, rather than strictly adhering to LSP.

To comply with LSP, one strategy is to segregate interfaces, i.e., follow the Interface Segregation Principle. This way, your subtypes will only need to implement the interfaces that are directly applicable.

public class Bird
{
    public virtual void Fly()
    {
        // Code to fly
    }
}

public class Sparrow : Bird
{
    public override void Fly()
    {
        // Code to fly
    }
}

public class Ostrich : Bird
{
    public override void Fly()
    {
        // Ostrich cannot fly, so this method should not be here
        // Violates LSP if implemented
    }
}          

Figure 3: Liskov Substitution Principle Example in C#

The initial example demonstrates a violation of the Liskov Substitution Principle. In the Bird class hierarchy, the Ostrich class inherits from the Bird class and overrides the Fly method. However, ostriches cannot fly in reality, hence this design is flawed. If the Fly method is called on an object of type Ostrich, the behavior is undefined or incorrect, thereby violating the LSP, which states that subtypes must be substitutable for their base types.

The Interface Segregation Principle

“Clients should not be forced to depend on methods they do not use”

The Interface Segregation Principle (ISP) accepts that cohesive classes are an ideal, not always a reality. There may be circumstances where a class with a large set of methods on its interface is necessary, such as a façade that adapts to various back-end systems. However, ISP suggests that this extensive interface should be divided into sections based on the needs of the different types of clients that will connect to it.

In other words, if one type of client uses a certain set of methods, and another type of client uses a different set, these methods should be split into two separate interfaces. This allows each client type to depend on a specific abstract interface containing only the methods they need, rather than depending on the comprehensive interface.

Segregating interfaces is an effective strategy for decoupling client classes. If two different client classes depend on a single concrete API, changes made to the API for one client class can inadvertently impact the other, even if the changes don't affect the methods needed by that class. This interdependence indirectly couples the two client classes through the API. However, if the API is divided into two separate interfaces, each client class can depend on a different one. As a result, changes to the concrete implementations will only affect the client using the relevant interface, effectively decoupling the two client classes.

// Before: Interface having methods which aren't always needed
public interface IPrinter
{
    void Print();
    void Fax();
    void Scan();
}

// After: Segregating interface into specific interfaces
public interface IPrinter
{
    void Print();
}

public interface IFax
{
    void Fax();
}

public interface IScanner
{
    void Scan();
}

Figure 4: Interface Segregation Principle in C#

In the example, the initial IPrinter interface has multiple responsibilities - printing, faxing, and scanning. This violates the Interface Segregation Principle as any class implementing this interface would be forced to implement all these methods, even if it does not need them. To adhere to the ISP, the IPrinter interface is segregated into three smaller, specific interfaces - IPrinter, IFax, and IScanner, each having a single responsibility. This ensures that the classes can implement only the interfaces they require, reducing the unnecessary burden.

The Dependency-Inversion Principle

“A. High-level modules should not depend on low-level modules. Both should depend on abstractions.
B. Abstractions should not depend on details. Details should depend on abstractions.”

The Dependency-Inversion Principle (DIP) is perhaps the most widely implemented principle among the SOLID principles. This concept is ingrained in every software "container" and is increasingly integrated into frameworks like ASP.NET MVC.

The essence of DIP is to invert the conventional dependencies that arise during coding. For instance, if you're building a user interface and need to access a database through a data layer, you might typically instantiate a data layer object and call it directly. This is what Robert C. Martin refers to as the traditional procedural method. However, the well-factored Object-Oriented method, according to Martin, is for the user interface to depend on an abstraction of the data layer, with the concrete implementation provided to it, inverting the traditional dependency.

Implementing the Dependency-Inversion Principle has numerous benefits:

  • It reduces the coupling between concrete classes, which allows each to change independently.
  • It enables new data layer implementations without affecting the user interface (the Open/Closed Principle).
  • It facilitates the insertion of decorators or other classes, even at runtime or depending on configuration. For example, a LoggingDataLayer could be created that employs the original data layer implementation and adds logging to it.
  • Depending on abstractions makes our classes less brittle as abstractions change less frequently than implementation details.
  • It facilitates independent testing of each class. For instance, there's no need for a concrete data layer to test the user interface.
// Before: High-level module depends on a low-level module
public class Notification
{
    private Email _email;
    public Notification()
    {
        _email = new Email();
    }

    public void PromotionalNotification()
    {
        _email.SendEmail();
    }
}

// After: Both modules depend on an abstraction
public interface IMessage
{
    void SendMessage();
}

public class Email : IMessage
{
    public void SendMessage()
    {
        // Send email
    }
}

public class Notification
{
    private IMessage _message;
    public Notification(IMessage message)
    {
        this._message = message;
    }

    public void PromotionalNotification()
    {
        _message.SendMessage();
    }
}

Figure 5: Dependency-Inversion Principle Example in C#

In the initial example, the high-level module Notification directly depends on the low-level module Email, violating the Dependency Inversion Principle. To rectify this, an abstraction IMessage is introduced that both high-level and low-level modules depend on. The Email class implements this interface, and Notification now communicates with Email indirectly, via the IMessage interface. This way, Notification isn't dependent on concrete implementations, adhering to the DIP, which results in a more flexible and maintainable code.