SOLID Principles: Practical Examples for Better Software Design

SOLID Principles

From my perspective, the process of writing code and crafting software is like an art, where we use technology to solve real-world problems. There are countless ways to write code and build software, and different developers, teams, or companies have their own unique styles and approaches. What’s interesting is that there’s no one-size-fits-all answer – if a method works well, it’s good to go; if not, we make improvements. Sometimes, we even use different coding styles and techniques in different projects based on the project’s needs and the technologies available.

However, there are some essential principles and best practices that are generally recommended to help manage code and projects more easily. These practices make the code easier to maintain, scale, adapt, and read. One of the most well-regarded principles is known as the SOLID principle, and it’s something every developer, no matter what programming language or framework they use, should consider.

Following the SOLID principles in software development is incredibly important for some very good reasons. These principles are like a set of clear guidelines and best practices that make it much easier to create software that can be maintained, scaled, and made robust.

SOLID Principles are listed below

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

The name is SOLID, derived from the first letter of each principle. Let’s delve into each of these principles with examples.

Single Responsibility Principle (SRP)

The SRP dictates that a class should have only one reason to change. It means a class should have a single responsibility. If a class has multiple responsibilities, it becomes more complex and less maintainable.

Violation of SRP

Example 1. Consider a UserManager class that handles both user authentication and user profile management. This violates SRP because changes to authentication logic can affect profile management and vice versa.

// Violation of SRP
class UserManager
{
    public bool Authenticate(string username, string password) { /* Authentication logic */ }
    public void UpdateProfile(User user) { /* Profile management logic */ }
}

Comply SRP

Simply, we can separate the two functionalities into two classes to comply with SRP.

// Following SRP
class AuthenticationManager
{
    public bool Authenticate(string username, string password) { /* Authentication logic */ }
}
class ProfileManager
{
    public void UpdateProfile(User user) { /* Profile management logic */ }
}

Similarly, in another example 2, consider a class responsible for both logging and sending emails. It violates SRP because if the logging or email functionality needs to change, you have to modify the same class. Instead, create separate classes for logging and email functionality.

// Violation of SRP
class LogAndEmailService
{
    public void Log(string message) { /* Logging logic */ }
    public void SendEmail(string to, string subject, string body) { /* Email logic */ }
}

Obey SRP

Simply, we can separate the two functionalities into two classes to comply with SRP.

// Following SRP
class Logger
{
    public void Log(string message) { /* Logging logic */ }
}

class EmailService
{
    public void SendEmail(string to, string subject, string body) { /* Email logic */ }
}

Open/Closed Principle (OCP)

The OCP suggests that software entities (classes, modules, functions) should be open for extension but closed for modification. It encourages developers to extend existing code rather than change it.

Violation of OCP

Example: Consider a shape calculation system.

// Violation of OCP
class ShapeCalculator
{
    public double CalculateArea(Shape shape)
    {
        if (shape is Circle) { /* Calculate circle area */ }
        else if (shape is Rectangle) { /* Calculate rectangle area */ }
        // Adding a new shape requires modifying this class.
    }
}

In the above class, adding a new shape requires modifying the existing class.

Following OCP

To comply with the OCP principle, we can modify the above code. We can create an abstract class shape and inherit it into several specific space classes to calculate the area, as shown below.

// Following OCP
abstract class Shape
{
    public abstract double CalculateArea();
}

class Circle : Shape
{
    public override double CalculateArea() { /* Calculate circle area */ }
}

class Rectangle : Shape
{
    public override double CalculateArea() { /* Calculate rectangle area */ }
}

// Now, adding a new shape doesn't require modifying the ShapeCalculator class.

In the above implementation, we can add a new shape that doesn’t require modification in existing classes and functions.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is one of the five SOLID principles of object-oriented programming and design. Named after Barbara Liskov, a computer scientist, the LSP asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In simpler terms, if a class is a subclass of another class, it should be able to step in and take the place of its parent class without causing any unexpected or incorrect behavior.

Example

abstract class Bird
{
    public virtual void Fly()
    {
        // Common fly behavior for all birds
    }
}

class Ostrich : Bird
{
    public override void Fly()
    {
        // Ostrich-specific behavior (e.g., running)
    }
}

class Sparrow : Bird
{
    // Sparrows can fly, so no method override needed
}

class Program
{
    static void Main()
    {
        Bird ostrich = new Ostrich();
        Bird sparrow = new Sparrow();

        ostrich.Fly(); // Executes ostrich-specific behavior
        sparrow.Fly(); // Executes common fly behavior
    }
}

In the above example, the class Bird has a common character, Fly(). However, the Ostrich subclass overrides the Fly method with ostrich-specific behavior (running), and the Sparrow subclass retains the base class behavior. Now, substituting an Ostrich or a Sparrow for a Bird maintains expected behavior, as per the LSP.

Interface Segregation Principle (ISP)

The ISP states that clients should not be forced to depend on interfaces they do not use. It encourages creating smaller, more specific interfaces rather than large, monolithic ones.

Example. If an interface contains methods not used by a class, it’s better to split it into multiple, smaller interfaces.

Violation of ISP

// Violation of ISP
interface IWorker
{
    void Work();
    void Eat();
}

class Robot : IWorker
{
    public void Work() { /* Work logic */ }
    public void Eat() { /* Eat logic, but not used by Robot */ }
}

Following ISP

// Following ISP
interface IWorkable
{
    void Work();
}

interface IEatable
{
    void Eat();
}

class Robot : IWorkable
{
    public void Work() { /* Work logic */ }
}

Dependency Inversion Principle (DIP)

DIP suggests that high-level modules (such as classes or components) should not depend on low-level modules, but both should depend on abstractions. In simpler terms, it encourages you to use interfaces or abstract classes to define the relationships between components, making your code more flexible and easier to maintain.

Here’s a straightforward example in C#:

Suppose you’re building a messaging system that can send messages via different channels like email and SMS.

class MessageSender
{
    private EmailService emailService;
    private SmsService smsService;

    public MessageSender()
    {
        emailService = new EmailService();
        smsService = new SmsService();
    }

    public void SendEmail(string message)
    {
        emailService.Send(message);
    }

    public void SendSMS(string message)
    {
        smsService.Send(message);
    }
}

In this non-DIP example, the MessageSender class is tightly coupled to the concrete implementations of email and SMS services. If you ever want to add a new messaging channel, you’d need to modify the MessageSender class, which violates the Open/Closed Principle (another SOLID principle).

Now, let’s apply the Dependency Inversion Principle to improve this design.

interface IMessageService
{
    void Send(string message);
}

class EmailService : IMessageService
{
    public void Send(string message)
    {
        // Implementation to send an email
    }
}

class SmsService : IMessageService
{
    public void Send(string message)
    {
        // Implementation to send an SMS
    }

}

class MessageSender
{
    private IMessageService messageService;

    public MessageSender(IMessageService service)
    {
        messageService = service;
    }

    public void SendMessage(string message)
    {
        messageService.Send(message);
    }
}

In this DIP-compliant example, we introduced an IMessageService interface that both EmailService and SmsService implement. The MessageSender class now depends on the abstraction (the interface) rather than the concrete implementations. This makes it easy to extend your system with new messaging services without modifying existing code, ensuring better maintainability and flexibility.

SOLID Principles in Software Development

The SOLID principle can add numerous advantages that contribute to better software quality, maintainability, and extensibility. Here are some key advantages of adhering to SOLID principles:

  • Code Quality & Maintainability: SOLID principles promote clean, well-structured, and readable code, improving code quality and making it easier to maintain.
  • Scalability & Flexibility: Software developed with SOLID principles serves as a solid foundation for scalability, allowing it to adapt and grow in response to changing requirements without major rewrites.
  • Team Collaboration: These principles establish a common set of best practices, fostering collaboration among development teams and leading to more effective teamwork.
  • Reuse & Extensibility: The modular and loosely coupled nature of SOLID code encourages component reusability, making it easier to extend the software’s functionality.
  • Efficient Testing: SOLID principles simplify unit testing, enabling early issue detection and resolution and enhancing the software’s overall quality.
  • Reduced Technical Debt: Adhering to SOLID principles minimizes technical debt, contributing to a more sustainable and cost-effective development process.

Conclusion

Software development is like solving real-world puzzles with code. Each developer has their unique style, and if the code works, it’s not necessarily ‘wrong.’ However, without organization and following some basic rules, it can get messy over time. This mess leads to more bugs, makes the code hard to understand, and makes it tough to add new features. It can also slow down new developers trying to work on it and even cause performance issues. To avoid this, developers use principles and best practices to keep their code clean and maintainable. These guidelines serve as a compass, ensuring that the software is well-structured, maintainable, and adaptable.

They lead to cleaner, more readable code that is easier to debug and extend. SOLID principles, for example, offer valuable guidance on structuring code, promoting single responsibilities for classes, enabling easy extensions without modifying existing code, ensuring compatibility between derived and base classes, and simplifying the creation of smaller, focused interfaces. By embracing these principles, developers pave the way for efficient, long-lasting software that can gracefully evolve with the ever-changing landscape of technology.

Reference: https://dotnetcopilot.com/solid-principles-explained-from-theory-to-practice-using-c/