What is S.O.L.I.D principles

In technical interviews, I always make sure to include questions about the S.O.L.I.D principles. Honestly, in my early days, I was also asked about it and couldn’t answer correctly because I didn’t really know it. But later, I realized that, at some point in my programming career, I was already applying these principles without even knowing it.

Now, I want to dive into something fundamental for writing clean, maintainable code: the SOLID principles. These five principles serve as guidelines to help us design code that’s more flexible, readable, and easier to manage. Apologies to non-C# developers, as my example code here is written in C#, but the concepts are the same across any programming language.

Let’s break down what SOLID stands for and how each principle can be implemented in C#.

S – Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have one, and only one, reason to change. In other words, each class should have a single responsibility or job.

Why It Matters

When a class has only one job, it’s easier to maintain. If something breaks, you know exactly where to look. Plus, it reduces the chances of ripple effects when modifying code.

Example: Here’s a simple example. Suppose we have a class that handles both user data and sending notifications. Not quite SRP-compliant, right? Let’s refactor it.

C#
// Violates SRP
public class UserService
{
    public void AddUser(User user)
    {
        // Logic to add a user
    }

    public void SendWelcomeEmail(User user)
    {
        // Logic to send a welcome email
    }
}

// SRP-Compliant Version
public class UserService
{
    public void AddUser(User user)
    {
        // Logic to add a user
    }
}

public class NotificationService
{
    public void SendWelcomeEmail(User user)
    {
        // Logic to send a welcome email
    }
}

By splitting out the NotificationService, each class now has a single responsibility, making our code much cleaner and easier to maintain.

O – Open/Closed Principle (OCP)

The Open/Closed Principle suggests that classes should be open for extension but closed for modification. This means we should be able to add new functionality without changing existing code, reducing the risk of introducing bugs.

Why It Matters

This principle allows us to add new features or behaviors without modifying the core logic, which is essential for system stability.

Example: Imagine we have a payment processing class that handles different payment methods. Instead of modifying the class every time we add a new payment type, we can use inheritance or interfaces to keep the class open for extension.

C#
// Violates OCP: We modify the PaymentProcessor class for every new payment type
public class PaymentProcessor
{
    public void ProcessCreditCardPayment() { /* Logic for credit card */ }
    public void ProcessPayPalPayment() { /* Logic for PayPal */ }
}

// OCP-Compliant Version
public interface IPayment
{
    void ProcessPayment();
}

public class CreditCardPayment : IPayment
{
    public void ProcessPayment() { /* Logic for credit card */ }
}

public class PayPalPayment : IPayment
{
    public void ProcessPayment() { /* Logic for PayPal */ }
}

public class PaymentProcessor
{
    public void ProcessPayment(IPayment payment)
    {
        payment.ProcessPayment();
    }
}

Now, we can add new payment methods by implementing the IPayment interface without touching the PaymentProcessor class itself.

L – Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program. Basically, subclasses should behave in a way that doesn’t break the expected behavior of the superclass.

Why It Matters

Following LSP makes our code more predictable and helps ensure that polymorphism works as expected.

Example: Let’s say we have a Rectangle class and a Square class that inherits from it. If we override the SetWidth and SetHeight methods in Square so that they behave differently than in Rectangle, we’d violate LSP.

C#
// Violates LSP: Square behaves differently than Rectangle
public class Rectangle
{
    public virtual void SetWidth(int width) { /* Set width */ }
    public virtual void SetHeight(int height) { /* Set height */ }
}

public class Square : Rectangle
{
    public override void SetWidth(int width) { /* Violates LSP */ }
    public override void SetHeight(int height) { /* Violates LSP */ }
}

The better approach would be to avoid inheritance here, as a square doesn’t need to extend from a rectangle.

I – Interface Segregation Principle (ISP)

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. In simpler terms, interfaces should be small and specific to avoid forcing a class to implement unnecessary methods.

Why It Matters
When interfaces are too broad, they become harder to implement and maintain. ISP helps us avoid creating “fat” interfaces that aren’t specialized.

Example: Consider an interface for a printer that includes methods for both regular printing and scanning. Not all printers can scan, so we should separate these responsibilities.

C#
// Violates ISP: Forces non-scanning printers to implement scanning
public interface IPrinter
{
    void Print();
    void Scan();
}

// ISP-Compliant Version
public interface IPrinter
{
    void Print();
}

public interface IScanner
{
    void Scan();
}

public class BasicPrinter : IPrinter
{
    public void Print() { /* Print logic */ }
}

public class MultiFunctionPrinter : IPrinter, IScanner
{
    public void Print() { /* Print logic */ }
    public void Scan() { /* Scan logic */ }
}

With this approach, classes only need to implement what they actually use.

D – Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This principle encourages us to rely on interfaces or abstract classes, not concrete implementations.

Why It Matters
DIP helps reduce coupling between classes, making our code easier to test, maintain, and extend.

Example: Let’s look at a logging system where the high-level class depends directly on a low-level class.

C#
// Violates DIP: High-level class depends on a low-level class
public class Logger
{
    public void Log(string message) { /* Log message */ }
}

public class UserService
{
    private Logger _logger = new Logger();

    public void AddUser(User user)
    {
        // User logic
        _logger.Log("User added.");
    }
}

// DIP-Compliant Version
public interface ILogger
{
    void Log(string message);
}

public class ConsoleLogger : ILogger
{
    public void Log(string message) { /* Log to console */ }
}

public class UserService
{
    private readonly ILogger _logger;

    public UserService(ILogger logger)
    {
        _logger = logger;
    }

    public void AddUser(User user)
    {
        // User logic
        _logger.Log("User added.");
    }
}

Now, UserService depends on the ILogger interface rather than the concrete ConsoleLogger class, which makes it easy to swap out the logger as needed.

The SOLID principles are a fantastic foundation for writing clean, maintainable code. They’re not just rules but also practices that guide us in structuring our code for easier testing, scaling, and collaborating with other developers.

If you’re new to these principles, don’t worry if they seem a bit abstract at first. As you write more code, they’ll start to make sense, and soon you’ll find yourself naturally thinking in a SOLID way. Happy coding, and may your code be as robust as it is elegant!

Assi Arai
Assi Arai
Articles: 31