As a software developer, you're likely familiar with the term SOLID. These five principles were introduced by Robert C. Martin, also known as Uncle Bob, in the early 2000s. The SOLID principles are designed to make software designs more understandable, flexible, and maintainable. These principles form an acronym where each letter represents a specific principle that contributes to writing cleaner, more efficient code—enough blabber. Let's see what each letter represents.

S - Single Responsibility Principle (SRP)

The Single Responsibility Principle not only helps in making your code more readable and maintainable but also enhances testability. When each class has only one responsibility, you can more easily write unit tests for that class without setting up complex test scenarios. This separation also simplifies debugging since changes in one class are less likely to impact other parts of your system. Imagine a scenario where a class handles both business logic and user interface updates. Any modification to the business logic might inadvertently affect the UI, leading to unexpected bugs. By following the SRP, you mitigate such risks and ensure that each class evolves independently, making your software more robust over time.

// Violates SRP
public class UserService
{
    public void AddUser(string userName)
    {
        // Add user to the database
    }

    public void SendEmail(string email)
    {
        // Send an email to the user
    }
}

// Adheres to SRP
public class UserService
{
    public void AddUser(string userName)
    {
        // Add user to the database
    }
}

public class EmailService
{
    public void SendEmail(string email)
    {
        // Send an email
    }
}

In this example, we split the email sending of the UserService into a new EmailService to keep our UserService from serving multiple purposes.

O - Open/Closed Principle (OCP)

The Open/Closed Principle is excellent for managing growing codebases. As new features are added, modifying existing classes can lead to a cascade of changes and potential bugs. Instead, by extending classes, you preserve existing behaviour and only introduce new functionality. This is particularly useful in large teams where multiple developers work on the same codebase. By following OCP, developers can add features without stepping on each other's toes. Additionally, design patterns like Strategy, Decorator, and Factory Method inherently support OCP by promoting the extension of behaviours. In essence, OCP drives the creation of more modular and adaptable systems where new requirements are accommodated smoothly.

// Violates OCP
public class ReportGenerator
{
    public void GenerateReport(string reportType)
    {
        if (reportType == "PDF")
        {
            // Generate PDF report
        }
        else if (reportType == "Excel")
        {
            // Generate Excel report
        }
    }
}

// Adheres to OCP
public abstract class ReportGenerator
{
    public abstract void GenerateReport();
}

public class PdfReportGenerator : ReportGenerator
{
    public override void GenerateReport()
    {
        // Generate PDF report
    }
}

public class ExcelReportGenerator : ReportGenerator
{
    public override void GenerateReport()
    {
        // Generate Excel report
    }
}

In this example, by adding new implementations of the ReportGenerator class we do not modify the existing code but rather add new types when we need to.

L - Liskov Substitution Principle (LSP)

The Liskov Substitution Principle, which was introduced by Barbara Liskov, emphasizes designing with polymorphism in mind. It encourages you to create a hierarchy where subclasses can seamlessly replace their base classes. This ensures that your code remains consistent and predictable as the derived classes adhere to the expected behaviour of their base classes. Violating LSP can lead to subtle bugs that are hard to track down. For instance, if a subclass overrides a method with behaviour that isn't logically compatible with the base class, it can cause runtime errors. LSP advocates for a disciplined approach to inheritance, promoting the use of abstract classes and interfaces that clearly define expected behaviours, thus enhancing the reliability of your code.

// Violates LSP
public class Bird
{
    public virtual void Fly()
    {
        // Implementation to fly
    }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotImplementedException("Penguins cannot fly.");
    }
}

// Adheres to LSP
public abstract class Bird
{
}

public class FlyingBird : Bird, IFlyable
{
    public void Fly()
    {
        // Implementation to fly
    }
}

public class Penguin : Bird
{
    // Penguin does not inherit Fly() method
}

public interface IFlyable
{
    void Fly();
}

In our example we are introducing an IFlyable interface to separate flying birds from non-flying ones, ensuring proper behavior inheritance.

I - Interface Segregation Principle (ISP)

The Interface Segregation Principle is particularly beneficial in systems that evolve over time. Large, monolithic interfaces can lead to bloated classes that implement unnecessary methods, making them harder to maintain and test. By segregating interfaces, you create more focused and cohesive contracts. This leads to cleaner and more understandable code, where classes implement only the methods they truly need. Moreover, ISP reduces the impact of changes; if an interface needs to be updated, only the classes that depend on it are affected. This principle fosters better design practices and helps in building scalable systems where components can be developed and tested in isolation.

// Violates ISP
public interface IWorker
{
    void Work();
    void Eat();
}

public class HumanWorker : IWorker
{
    public void Work()
    {
        // Working
    }

    public void Eat()
    {
        // Eating in the break
    }
}

public class RobotWorker : IWorker
{
    public void Work()
    {
        // Working much more efficiently
    }

    public void Eat()
    {
        throw new NotImplementedException("Robots do not eat.");
    }
}

// Adheres to ISP
public interface IWork
{
    void Work();
}

public interface IEat
{
    void Eat();
}

public class HumanWorker : IWork, IEat
{
    public void Work()
    {
        // Working
    }

    public void Eat()
    {
        // Eating in the break
    }
}

public class RobotWorker : IWork
{
    public void Work()
    {
        // Working much more efficiently
    }
}

In this case, we are splitting the larger interface into smaller, more specific pieces to ensure that our classes only implement what they actually need.

D - Dependency Inversion Principle (DIP)

The Dependency Inversion Principle is foundational for building decoupled and flexible systems. By depending on abstractions rather than concrete implementations, you promote the use of dependency injection and inversion of control (IoC) containers. This makes your codebase more adaptable to changes, as you can easily swap out implementations without modifying the dependent classes. DIP also facilitates better unit testing, as dependencies can be mocked or stubbed out. This principle encourages thinking about high-level policy decisions and low-level detail implementations separately, ensuring that your high-level modules remain unaffected by changes in the underlying details. Adhering to DIP leads to a more modular and testable architecture.

// Violates DIP
public class User
{
    private SqlContext _context;

    public User(SqlContext context)
    {
        _context = context;
    }

    public void Add(string userName)
    {
        _context.AddUser(userName);
    }
}

// Adheres to DIP
public interface IContext
{
    void AddUser(string userName);
}

public class SqlContext : IContext
{
    public void AddUser(string userName)
    {
        // Add user to SQL database
    }
}

public class User
{
    private IContext _context;

    public User(IContext context)
    {
        _context = context;
    }

    public void Add(string userName)
    {
        _context.AddUser(userName);
    }
}

Finally, we are depending on an abstraction, the IContext, instead of an actual implementation which allows us to have more testable code and different implementation if needed.

Criticism

Despite their widespread adoption and the benefits they provide, the SOLID principles are not perfect. One of the main criticisms is that strict adherence to these principles can sometimes lead to overly complex codebases. For example, the Single Responsibility Principle might encourage developers to create numerous small classes, which can make the system harder to navigate and understand. Similarly, the Open/Closed Principle can result in excessive use of inheritance and interfaces, potentially leading to a bloated architecture. Additionally, some developers feel that the principles can be misinterpreted or applied dogmatically, leading to solutions that are theoretically correct but impractical in practice. Ultimately, while the SOLID principles provide valuable guidelines, they should be applied with a balance of pragmatism and context awareness, adapting to the specific needs of the project at hand. Think about what you actually need instead of following any principle blindly.

Conclusion

The SOLID principles are known and have lasted over the years for a simple reason: they solve everyday issues we have in our code. Learning how and when to use them will make it easier for you to build maintainable applications. What about you? Were you aware of SOLID? Are you using them in your everyday life? Reply to let me know and as always keep coding!

Next steps

If you're interested in taking your knowledge of SOLID principles in C# a step further, you can always check our "From Zero to Hero: SOLID Principles for C# Developers" course on Dometrain.

fzth-solid-csharp.jpg