Published on

SOLID Principles

Authors

The 'Design Dilemma'

Developers build amazing applications with good and tidy designs using the experience they have gained working in the industry. However, over time these applications may develop bugs and be revisioned causing the design to be altered with every iteration. This can cause technical debt and may cause very easy tasks to take quite a bit of time and a ton of working knowledge of the whole application. Developers cannot stop adding features or fixing bugs in their applications, however. One of the main solutions to this problem is to follow design patterns. This post will walk you through the SOLID principles.

What are SOLID Principles?

SOLID principles represent a set of fundamental guidelines in software engineering, offering developers a systematic approach to overcome common design hurdles. Let's embark on a journey to unravel each principle and understand its significance in crafting exemplary software solutions.

What does SOLID stand for?

SOLID is an acronym

  1. S Stands for Single Responsibility Principle (SRP)
  2. O stands for Open closed Principle (OCP)
  3. L stands for Liskov substitution Principle (LSP)
  4. I stands for Interface Segregation Principle (ISP)
  5. D stands for Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is a fundamental tenet of the SOLID design principles in object-oriented programming. It states that a class should have only one reason to change, meaning it should have only one responsibility or job within the software system.

Benefits of SRP

The Single Responsibility Principle (SRP) brings several benefits to software development. Firstly, it makes code easier to understand, change, and fix, which saves time and money in the long run. Secondly, it helps keep code organized and makes it easier to reuse in different parts of a program. Thirdly, SRP reduces the chances of one part of the code accidentally messing up another part. Additionally, it makes code easier to read and understand, which helps teams work together better. Lastly, SRP makes it easier to test pieces of code on their own, which makes the whole program more reliable.

  • Improved Maintainability
  • Enhanced Modularity
  • Reduced Coupling
  • Increased Code Clarity
  • Improved Testability

SRP Trade-Offs

While the Single Responsibility Principle (SRP) brings notable benefits, there are some trade-offs to consider. Firstly, strictly adhering to SRP might result in a small amount of code duplication, particularly when multiple classes require similar functionality. However, the advantages of enhanced maintainability and reduced coupling typically outweigh this drawback. Secondly, following SRP may lead to an increased number of classes, introducing initial complexity to the codebase; nevertheless, the improved organization and ease of maintenance often justify this. Lastly, defining the "single responsibility" for a class can be subjective, requiring developer judgment to strike the right balance between granularity and cohesion.

  • Potential for Code Duplication
  • Increased Number of classes
  • Subjectivity in Defining Responsibility

Bad Design Example

public class IUserManagement
{
    void Register(string username, string password)
    void Login(string username, string password);
    void UpdateUserProfile(string username, string newEmail);
    void LogError(Exception ex, string errorMessage);
    void SendEmail(string emailAddr, string emailContent);
}

Why is this a problem? The interface IUserManagement violates the SRP because it defines multiple distinct responsibilities within a single interface.

Each of these methods represents a different concern or responsibility within the system. By grouping them within a single interface, you create a cohesive unit that has multiple reasons to change. For example, if the logic for user registration changes, it could necessitate modifications to the same interface that affect unrelated functionality like error logging or email sending.

Good Design Example

public interface IUserService
{
    void Register(string username, string password);
    void Login(string username, string password);
}

public interface IUserProfileManagement
{
    void UpdateUserProfile(string username, string newEmail);
}

public interface IErrorLogger
{
    void LogError(Exception ex, string errorMessage);
}

public interface IEmailService
{
    void SendEmail(string emailAddr, string emailContent);
}

Each interface now encapsulates a single responsibility. This makes the code more modular, maintainable, and aligned with SRP. Now classes implementing these interfaces will focus solely on their respective responsibilities, reducing ocupling and improving code clarity.

Open/Closed Principle

The Open/Closed Principle states that software entities (such as classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to extend the behavior of a module without modifying its source code.

Benefits of OCP

The Open-Closed Principle (OCP) offers several advantages in software development. Firstly, it enables you to extend the functionality of existing code without needing to change its original source code. This is done by using techniques like abstraction and polymorphism, allowing for the introduction of new features through subclasses or new interfaces while keeping the core code intact. Secondly, OCP enhances maintainability by separating behavior from implementation details, making it easier to modify existing functionality without risking unintended consequences or regressions. Additionally, it promotes code reusability by allowing new implementations to be added without altering the existing codebase, reducing development time and effort. Moreover, OCP reduces the need for extensive refactoring of existing code when adapting the system's behavior, saving both time and resources and facilitating a more flexible design approach that can easily accommodate changing requirements.

  • Increased Code Extensibility
  • Improved Maintainability
  • Enhanced Code Reusability
  • Reduced Refactoring Needs
  • Improved Design Flexibility

OCP Trade-Offs

Implementing the Open-Closed Principle (OCP) effectively may entail additional upfront design effort, as it necessitates thorough planning for potential future extensions and the creation of abstractions to accommodate new functionalities. Additionally, there might be a slight performance overhead associated with using abstractions and polymorphism, although this is typically insignificant unless performance is of utmost importance. Furthermore, OCP can potentially lead to increased code complexity due to the utilization of interfaces and abstract classes, which may result in a steeper learning curve for new developers joining the project.

  • Increased Upfront Design Effort
  • Potential Performance Overhead
  • Increase Code Complexity

Bad Design Example

public class AreaFinder
{
    public void FindArea(string shapeType)
    {
        if (shapeType == "Circle")
        {
            Console.WriteLine("Calculating area for circle");
            //logic for calculating the area for a circle...
        }
        else if (shapeType == "Square")
        {
            Console.WriteLine("Calculating area for square");
            //logic for calculating the area of a square...
        }
        // Adding a new shape requires modifying existing code
        else if (shapeType == "Triangle")
        {
            Console.WriteLine("Calculating area for triangle");
            //logic for calculating the area of a triangle...

        }
    }
}

In the above example, the AreaFinder class directly handles different types of shapes based on the parameter 'shapeType'. Adding a new shape would require modifying the existing 'FindArea' method, violating the Open/Closed Principle.

Good Design Example

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

public class Circle : Shape
{
    public override double CalculateArea()
    {
        // Calculate the area for the circle...
        Console.WriteLine("Calculating area for circle");
        return Math.PI * Math.Pow(radius, 2);
    }
}

public class Square : Shape
{
    public override double CalculateArea()
    {
        // Calculate the area for the square...
        Console.WriteLine("Calculating area for square");
        return sideLength * sideLength;
    }
}

// Add more shape classes as needed...

public class AreaFinder
{
    public void FindArea(Shape shape)
    {
        double area = shape.CalculateArea();
        Console.WriteLine($"Area: {area}");
    }
}

In this good design example, each shape is represented by a concrete subclass of the 'Shape' class. Each subclass provides its implementation of the 'CalculateArea' method, encapsulating the logic for calculating the area of that specific shape. The FindArea method in the AreaFinder class no longer relies on the string to identify the shape type. It now accepts instances of the 'Shape' class or its subclasses, allowing it to work with any shape that implements the 'CalculateArea' method.

Adding a new shape would only require creating a new subclass of 'Shape' with its implementation of 'CalculateArea' without modifying existing code. Thus adhering to the Open/Closed Principle.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, subclasses should behave in such a way that they can be substituted for their base class without altering the desired behavior of the program.

Benefits of LSP

The Liskov Substitution Principle (LSP) brings several advantages to software development. Firstly, it enhances reliability by ensuring that subclasses behave consistently when used in place of their base classes, reducing the risk of unexpected errors or crashes. Secondly, it improves code maintainability by making it easier to understand and modify; developers can rely on the expected behavior of base classes, regardless of whether they're using the base class itself or a subclass, reducing the need for extensive documentation. Additionally, LSP promotes code reusability by allowing code written for the base class to seamlessly work with subclasses adhering to the principle, saving time and effort while maintaining consistency across the codebase. Moreover, it encourages clearer class hierarchies by emphasizing well-defined inheritance and extension of functionality, leading to a more organized and understandable design. Lastly, code following LSP is generally easier to test, as unit tests for the base class can often be applied to subclasses, reducing overall testing efforts.

  • Improved Reliability
  • Enhanced Code Maintainability
  • Increased Code Reusability
  • Promotes Clearer Class Hierarchies
  • Improved Testability

LSP Trade-Offs

While the Liskov Substitution Principle (LSP) offers significant benefits, there are potential drawbacks to consider. Firstly, strict adherence to LSP might result in overly complex inheritance hierarchies, as developers may need to introduce additional abstract classes or interfaces to ensure compliance with the principle, potentially adding initial complexity to the codebase. Secondly, designing classes and subclasses that fully adhere to LSP may require increased upfront design effort, as developers must carefully consider and plan for the expected behavior of subclasses to avoid violating the base class contract. Additionally, there's a possibility of inflexibility in rare cases, where a subclass may need to deviate slightly from the base class behavior for a specific purpose; however, such deviations should be approached cautiously and justified clearly to prevent unintended consequences.

  • Potential for Overly Strict Inheritance Hierarchies
  • Increased Design Effort
  • Potential for Inflexibility

Bad Design Example

// Interface
internal interface IBird
{
   void MakeSound();

   void Fly();

   void Run();
}

// Implementations
public class Duck : IBird
{
   public void MakeSound()
   {
       Console.WriteLine("Making sound.");
   }

   public void Fly()
   {
       Console.WriteLine("Flying...");
   }

   public void Run()
   {
       Console.WriteLine("Running...");
   }
}

// This breaks the Liskov substitution principle because if we follow polymorphism and call the Fly() method from the IBird reference variable
// then it will throw NotImplementedException.
public class Ostrich : IBird
{
   public void MakeSound()
   {
       Console.WriteLine("Making sound.");
   }

   // Ostrich cannot fly.
   public void Fly()
   {
       throw new NotImplementedException();
   }

   public void Run()
   {
       Console.WriteLine("Running...");
   }
}
IBird bird = new Ostrich();
bird.Fly(); // Will throw NotImplementedException

The Ostrich class violates the Liskov Substitution Principle (LSP) by throwing a NotImplementedException in its Fly method. This violates the principle because it breaks the substitutability of Ostrich objects for IBird objects. If code that expects an IBird reference were to call the Fly method on an Ostrich object, it would result in a runtime exception, leading to unexpected behavior and potentially crashing the application.

Good Design Example

// Interfaces
internal interface IBird
{
    void MakeSound();

    void Run();
}

internal interface IFlyingBird : IBird
{
    void Fly();
}

public class Duck : IFlyingBird
{
    public void MakeSound()
    {
        Console.WriteLine("Making sound.");
    }

    public void Fly()
    {
        Console.WriteLine("Flying...");
    }

    public void Run()
    {
        Console.WriteLine("Running...");
    }
}

public class Ostrich : IBird
{
    public void MakeSound()
    {
        Console.WriteLine("Making sound.");
    }

    public void Run()
    {
        Console.WriteLine("Running...");
    }
}

This example resolves this issue by introducing a new interface IFlyingBird that extends the IBird interface. By doing so, the IFlyingBird interface explicitly declares the Fly method, which is specific to birds capable of flight. This allows classes like Duck, which can fly, to implement the IFlyingBird interface and provide a concrete implementation of the Fly method. Meanwhile, classes like Ostrich, which cannot fly, can still implement the IBird interface without needing to implement the Fly method.

By separating the Fly behavior into its interface (IFlyingBird), we adhere to the Liskov Substitution Principle. Subclasses (or implementing classes) are now free to choose whether or not to implement the IFlyingBird interface, and they can do so without breaking the behavior expected from objects of the base IBird interface. This design allows for more flexible and robust code, promoting better maintainability and scalability of the system.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In other words, it suggests that you should create fine-grained interfaces that are specific to the needs of the client, rather than large, monolithic interfaces that encompass a wide range of functionality.

Benefits of ISP

The Interface Segregation Principle (ISP) brings several benefits to software development. Firstly, it enhances code clarity by providing clear and focused interfaces tailored to specific client needs, reducing confusion and improving understanding. Secondly, ISP reduces coupling between different parts of the codebase by breaking functionalities into smaller interfaces, allowing clients to depend only on what they require, thus minimizing the impact of changes elsewhere. Additionally, ISP promotes enhanced flexibility in code design, as clients can implement only the interfaces they need, fostering a modular and adaptable system, which is particularly advantageous for accommodating diverse client requirements. Moreover, smaller, more focused interfaces foster increased code reusability, as different parts of the codebase can utilize the same interface for specific functionalities, reducing duplication and enhancing maintainability. Lastly, ISP promotes loose coupling by enabling clients to depend on specific functionalities rather than entire interfaces, facilitating adaptability to changes and simplifying testing in isolation.

  • Improved Code Clarity
  • Reduced Coupling
  • Enhanced Flexibility
  • Increase Code Reusability
  • Promotes Loose Coupling

ISP Trade-Offs

While adhering to the Interface Segregation Principle (ISP) offers advantages, there are potential drawbacks to consider. Firstly, it may lead to the creation of a larger number of smaller interfaces compared to fewer, larger interfaces, potentially adding initial complexity to the codebase, especially for simpler applications. Secondly, there's a possibility of redundancy when some overlap in functionality exists between smaller interfaces, which could increase code complexity; however, the benefits of clarity and flexibility usually outweigh this drawback. Lastly, determining the appropriate granularity for interfaces can be subjective, requiring careful design considerations and experience to strike a balance between having too many or too few interfaces.

  • Potential for Redundancy
  • Finding the Right Granularity
  • Increased Number of Interfaces

Bad Design Example

public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

public class Employee : IWorker
{
    public void Work()
    {
        Console.WriteLine("Working...");
    }

    public void Eat()
    {
        Console.WriteLine("Eating...");
    }

    public void Sleep()
    {
        Console.WriteLine("Sleeping...");
    }
}

public class Robot : IWorker
{
    public void Work()
    {
        Console.WriteLine("Working...");
    }

    // Robots don't eat or sleep, but they're forced to implement these methods.
    public void Eat()
    {
        throw new NotImplementedException();
    }

    public void Sleep()
    {
        throw new NotImplementedException();
    }
}

The IWorker interface includes methods for working, eating, and sleeping. However, not all types of workers (e.g., robots) need to eat or sleep. The Robot class is forced to implement these unnecessary methods, violating the Interface Segregation Principle.

Good Design Example

public interface IWorker
{
    void Work();
}

public interface IEater
{
    void Eat();
}

public interface ISleeper
{
    void Sleep();
}

public class Employee : IWorker, IEater, ISleeper
{
    public void Work()
    {
        Console.WriteLine("Working...");
    }

    public void Eat()
    {
        Console.WriteLine("Eating...");
    }

    public void Sleep()
    {
        Console.WriteLine("Sleeping...");
    }
}

public class Robot : IWorker
{
    public void Work()
    {
        Console.WriteLine("Working...");
    }
}

This design adheres to the Interface Segregation Principle by ensuring that each interface is specific to the needs of its client. Clients are not forced to depend on methods they do not use, promoting better encapsulation and flexibility in the codebase.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Furthermore, abstractions should not depend on details; rather, details should depend on abstractions. In essence, DIP promotes decoupling and flexibility by ensuring that dependencies flow toward abstractions rather than concrete implementations.

Benefits of DIP

The Dependency Inversion Principle (DIP) offers several benefits to software development. Firstly, it improves testability by facilitating easier unit testing of high-level modules. By depending on abstractions, concrete implementations can be mocked or injected during testing, isolating the functionality of the high-level module from lower-level dependencies and leading to more reliable and focused unit tests. Secondly, DIP enhances loose coupling between different parts of the codebase, as high-level modules aren't reliant on specific concrete classes, making them more adaptable to changes and easier to maintain. Additionally, code adhering to DIP promotes reusability, as high-level modules can be reused across different contexts as long as they interact with the required abstractions, reducing code duplication and development effort. Moreover, DIP contributes to improved maintainability by reducing the need for modifications to high-level modules when changes occur in low-level modules (concrete implementations), thus minimizing the ripple effects of changes and simplifying code maintenance. Lastly, DIP aligns well with the Open/Closed Principle (OCP) by enabling the introduction of new concrete implementations without modifying existing high-level modules, fostering a more extensible and adaptable system.

  • Improved Testability
  • Enhanced Loose Coupling
  • Increase Code Reusability
  • Improved Maintainability
  • Promotes OCP

DIP Trade-Offs

While implementing the Dependency Inversion Principle (DIP) offers significant benefits, there are potential drawbacks to consider. Firstly, it often requires more upfront design effort, as developers need to carefully define abstractions and ensure that concrete implementations adhere to them, adding complexity to the design phase. Secondly, there's a possibility of slight performance overhead in rare cases when using abstractions and dependency injection compared to direct dependencies on concrete classes, although this is usually negligible unless performance is a critical concern. Additionally, DIP can sometimes result in a more complex codebase due to the use of abstractions and interfaces, potentially requiring a steeper learning curve for new developers joining the project.

  • Increased Upfront Design Effort
  • Potential for Performance Overhead
  • Potential for Increased Code Complexity

Bad Design Example

public class LightSwitch
{
    private ElectricPowerSupply _powerSupply;

    public LightSwitch()
    {
        _powerSupply = new ElectricPowerSupply();
    }

    public void Toggle()
    {
        if (_powerSupply.IsElectricityOn())
        {
            _powerSupply.TurnOffElectricity();
        }
        else
        {
            _powerSupply.TurnOnElectricity();
        }
    }
}

public class ElectricPowerSupply
{
    public bool IsElectricityOn()
    {
        // Logic to check if electricity is on
        return true;
    }

    public void TurnOnElectricity()
    {
        // Logic to turn on electricity
    }

    public void TurnOffElectricity()
    {
        // Logic to turn off electricity
    }
}

The LightSwitch class directly depends on the ElectricPowerSupply class, creating a tight coupling between the two. The LightSwitch class is tightly bound to the concrete implementation of the ElectricPowerSupply, making it difficult to change or extend the behavior of the LightSwitch class without also modifying the ElectricPowerSupply class.

Good Design Example


public interface IPowerSupply
{
    bool IsElectricityOn();
    void TurnOnElectricity();
    void TurnOffElectricity();
}

public class LightSwitch
{
    private IPowerSupply _powerSupply;

    public LightSwitch(IPowerSupply powerSupply)
    {
        _powerSupply = powerSupply;
    }

    public void Toggle()
    {
        if (_powerSupply.IsElectricityOn())
        {
            _powerSupply.TurnOffElectricity();
        }
        else
        {
            _powerSupply.TurnOnElectricity();
        }
    }
}

public class ElectricPowerSupply : IPowerSupply
{
    public bool IsElectricityOn()
    {
        // Logic to check if electricity is on
        return true;
    }

    public void TurnOnElectricity()
    {
        // Logic to turn on electricity
    }

    public void TurnOffElectricity()
    {
        // Logic to turn off electricity
    }
}

This design promotes loose coupling between modules, making it easier to replace or extend components without affecting the rest of the system. It also enables easier unit testing and promotes better separation of concerns.

Wrap-Up

Disadvantages of SOLID

Although SOLID design principles are a cornerstone of software engineering it is important to remember there can be violations or disadvantages of using these principles.

  • Over-Engineering: For small projects, strict adherence could result in needless complexity.
  • Learning Curve: It could take some time for developers to understand and successfully apply the concepts.
  • Increased Abstraction: Using too many abstractions could make the code more difficult to read.
  • Rigidity: Systems that place too much stress on the open/closed principle may become rigid.
  • Complexity of Implementation: Applying every principle, particularly in legacy systems, can be challenging.
  • Possibility of Duplication: Duplicate code could arise from excessively separating interfaces.
  • Not Always Applicable: Not every project or situation will benefit from applying every principle.
  • Time and Resource-Intensive: It could take more time and resources to apply SOLID principles.
  • Exchange with Performance: Overly abstract Designs may occasionally affect performance.

Summary

In this blog post, we explored the SOLID principles, which are fundamental in software design aimed at improving code quality, maintainability, and scalability.

  • Single Responsibility Principle (SRP) emphasizes that a class should have only one reason to change, promoting cohesion and modularity within the codebase. We discussed how breaking down responsibilities into separate interfaces or classes improves maintainability.

  • Open/Closed Principle (OCP) suggests that software entities should be open for extension but closed for modification. By utilizing abstractions and polymorphism, we can extend the behavior of modules without modifying their source code, thereby enhancing flexibility and scalability.

  • Liskov Substitution Principle (LSP) highlights the importance of ensuring that subclasses can be substituted for their base classes without altering the behavior of the program. We examined how adhering to LSP promotes robustness and extensibility in object-oriented systems.

  • Interface Segregation Principle (ISP) advocates for creating fine-grained interfaces specific to the needs of clients, rather than large, monolithic interfaces. This approach enhances encapsulation, reduces coupling, and improves code clarity by eliminating unnecessary dependencies.

  • Dependency Inversion Principle (DIP) promotes decoupling and flexibility by ensuring that high-level modules do not depend on low-level modules. Instead, both should depend on abstractions, allowing for easier code maintenance, testing, and scalability.

By understanding and applying these SOLID principles in software design, developers can create code that is more modular, maintainable, and adaptable to change, ultimately leading to more robust and reliable software systems.

Further Learning