SOLID Programming Principles

Recently, I finished reading a fascinating book titled Clean Architecture by Robert C. Martin (widely known as Uncle Bob), which I highly recommend. It offers valuable insights that help you grow professionally with every page.

The book is written in a straightforward and accessible style. Chapter by chapter, Uncle Bob explains the principles and strategies for designing software that is maintainable, scalable (architecturally speaking), and adaptable to change—a challenge that is far from trivial.

For several years, I worked at a company where I frequently developed new software (mainly medium-to-small web applications). One of my constant priorities was writing code in a way that allowed these applications to be “expanded” later without requiring extensive overhauls. The principles we’re about to explore are essential to achieving this goal. While they may seem complex at first, with practice and experience, they become second nature.

What is the SOLID principle?

The SOLID principle is a set of guidelines for object-oriented software design that aims to make systems more understandable, maintainable, and flexible. These principles are discussed extensively in the book I mentioned earlier, and the acronym SOLID represents five key principles.

Let’s now explore each principle in detail, accompanied by code examples to clarify their meaning. My aim with this article is to provide a simple and clear overview of these principles. For a deeper dive, I highly recommend the book, which dedicates entire chapters to these concepts, complete with detailed examples.

S – Single Responsibility Principle (SRP)

Each class should have a single responsibility, meaning it should have only one reason to change.

Example: A class responsible for handling the logic of saving data to a database should not also manage the logic for presenting that data. In this example, we create two separate classes—one for saving data and another for extracting it. If this software were to allow the generation of reports with different layouts, following this principle simplifies development and maintenance. The report-generating class remains unchanged, while it’s always possible to add or modify classes for new layouts.

C#
public class ReportSaver
{
    public void SaveToFile(string report)
    {
        Console.WriteLine($"Report salvato su file: {report}");
    }
}

public class ReportGenerator
{
    public string GenerateReport()
    {
        return "Report generato";
    }
}

var generator = new ReportGenerator();
var report = generator.GenerateReport();

var saver = new ReportSaver();
saver.SaveToFile(report);

O – Open/Closed Principle(OCP)

Software should be open for extension but closed for modification.

Example: New behaviors or features can be added using inheritance or interfaces without modifying existing code. Below, we see a base class implementing tax calculation logic. By overriding its method, we adapt the logic for different countries without altering the base class.

C#
// Base class for tax calculation
public abstract class TaxCalculator
{
    public abstract double CalculateTax(double income);
}

// Italy-specific implementation
public class ItalianTaxCalculator : TaxCalculator
{
    public override double CalculateTax(double income)
    {
        return income * 0.22; // Aliquota fissa del 22%
    }
}

// USA-specific implementation
public class USTaxCalculator : TaxCalculator
{
    public override double CalculateTax(double income)
    {
        return income * 0.30; // Aliquota fissa del 30%
    }
}

// Extending without modifying base logic
var calculator = new ItalianTaxCalculator();
Console.WriteLine(calculator.CalculateTax(1000)); // Output: 220

L – Liskov Substitution Principle (LSP)

Derived classes should be substitutable for their base classes without altering the program’s behavior.

Example: A subclass that breaks the expected behavior of a base class violates this principle. The following example demonstrates how to correctly and incorrectly extend a Bird class to handle exceptions like penguins (which cannot fly).

C#
// Base class
public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Fly!");
    }
}

// Derived class respecring base behavior
public class Sparrow : Bird
{
    public override void Fly()
    {
        Console.WriteLine("Sparrow fly!");
    }
}

// Derived class violating base behavior: penguins don't fly
public class PenguinWrong : Bird
{
    public override void Fly()
    {
        throw new NotImplementedException("Penguin can't fly");
    }
}

// Correct: adjust th hierarchy to handle exceptions like penguins
public class PenguinCorrect : Bird
{
    public void Swim()
    {
        Console.WriteLine("Penguin is swimming");
    }
}

I – Interface Segregation Principle (ISP)

Interfaces should be specific and tailored to client needs, rather than general and all-encompassing.

Example: It’s better to have several small, focused interfaces (e.g., IPrintable and IScannable) rather than a single massive interface (IMultifunctionalDevice) that forces clients to implement unused methods.

C#
// Specific interfaces
public interface IPrinter
{
    void Print();
}

public interface IScanner
{
    void Scan();
}

// Multifunction device
public class MultifunctionDevice : IPrinter, IScanner
{
    public void Print()
    {
        Console.WriteLine("Print in progress...");
    }

    public void Scan()
    {
        Console.WriteLine("Scan in progress...");
    }
}

// Only printer class
public class SimplePrinter : IPrinter
{
    public void Print()
    {
        Console.WriteLine("Print in progress...");
    }
}

public class SimpleScanner : IScanner
{
    public void Scan()
    {
        Console.WriteLine("Scan in progress...");
    }
}

D – Dependency Inversion Principle(DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces).

Example: A module should depend on an interface rather than a low-level class. In the example below, an interface is created to handle sending a generic message, with two classes implementing it: one for SMS and another for emails. In modules where we need to send a notification, we inject the implementation of the interface using dependency injection (potentially through a factory pattern). In the future, if the provider used for sending emails changes, all we would need to do is create a new class implementing IMessageSender, replace it where dependency injection is configured, and everything will continue to work seamlessly!

C#
// Abstract interface
public interface IMessageSender
{
    void SendMessage(string message);
}

// Implementation: email sending
public class EmailSender : IMessageSender
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"Email sent: {message}");
    }
}

// Implementation: SMS sending
public class SmsSender : IMessageSender
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"SMS sent: {message}");
    }
}

// Class depending on abstraction
public class NotificationService
{
    private readonly IMessageSender _messageSender;

    public NotificationService(IMessageSender messageSender)
    {
        _messageSender = messageSender;
    }

    public void Notify(string message)
    {
        _messageSender.SendMessage(message);
    }
}

// Usage
var emailService = new NotificationService(new EmailSender());
emailService.Notify("Email notification");

var smsService = new NotificationService(new SmsSender());
smsService.Notify("SMS notification");

Conclusion

In conclusion, SOLID principles are essential guidelines for designing robust, modular, and maintainable software. Their application not only enhances code quality but also helps in effectively managing software complexity.

While adopting SOLID requires discipline and practice, the benefits far outweigh the effort. It’s important to remember that these principles are meant to guide—not dictate—your work and should be tailored to the specific needs of each project.

By incorporating SOLID principles into your workflow, you can create software that not only fulfills current requirements but is also equipped to handle future challenges in a constantly evolving technological landscape.

In an upcoming article, we will explore how to structure a project using Clean Architecture, applying the principles covered in this article as well—stay tuned!

Share this article
Shareable URL
Prev Post

HashSet in .NET 9

Next Post

.NET 9 Hybrid Cache

Leave a Reply

Your email address will not be published. Required fields are marked *

Read next