Skip to main content
Design Patterns

What Are The SOLID Principles? The Art Of Writing Clean And Maintainable Code in C#

Christian Schou

If you are a new software developer you probably haven't heard it yet 😅 If you are an experienced software developer I am sure that you have been told that you write bad code at some point in your career. Have anyone ever told you that? 😄

Almost every developer I have talked to has, and honestly... it's totally normal for every developer. Life is about learning (so is coding) and not all of us can be the perfect programmers from the beginning. The good news is that you are willing to change that, or else you wouldn't be here! 🥳

One of the best ways to improve your coding skills is by improving your design principles when writing code. I always tell new programmers that they should think about the principles as a guide to becoming better software developers.

You probably already have googled how to become a better programmer, bought 8 courses and they all teach you the same stuff, read dozens of articles, and made a lot of trial-error code, but actually - it's all about following some principles.

I know there are many principles out there and it can be quite overwhelming if you just open Google and search for software design principles... To fix that I will cover five essential principles that go under the well-known acronym SOLID. You probably have heard that word before? 😊

🧑‍💻
As I am primarily writing code in C# I will be using that programming language for showing examples, but you can easily transfer the concept to other well-known programming languages like Python, Java, etc...
  • S – Single Responsibility PRinciple 🎯
  • O – Open/Closed Principle 🔐
  • L – Liskov Substitution Principle 📡
  • I – Interface Segregation Principle 😰
  • D - Dependency Inversion Principle 🧱

Let's get started and see what the SOLID principles are about! 🚀

S – Single Responsibility Principle 🎯

The "S" stands for the Single Responsibility Principle (SRP). This principle states that a class should have only one reason to change, meaning it should have only one responsibility or job. In other words:

It is responsible for making sure we break up the code into smaller pieces with their own responsibilities.

Here is an example to showcase it. Imagine you got a Book class and its responsibilities include managing the book information and also saving it to a file. Doing that in the same class would break the SRP principle.

using System.IO;

public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int NumberOfPages { get; set; }

    // Method to save book information to a file
    public void SaveToFile(string fileName)
    {
        string bookInfo = $"{Title},{Author},{NumberOfPages}";
        File.WriteAllText(fileName, bookInfo);
    }
}

In the above example, the Book class breaks the single responsibility principle because it has two responsibilities. Let's see how we can fix that issue and make our code more maintainable.

// Book class is responsible for book information only
public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int NumberOfPages { get; set; }
}

// BookFileHandler class is responsible for saving book information to a file
public class BookFileHandler
{
    public void SaveToFile(Book book, string fileName)
    {
        string bookInfo = $"{book.Title},{book.Author},{book.NumberOfPages}";
        File.WriteAllText(fileName, bookInfo);
    }
}

Now we got a separate BookFileHandler class, responsible for the file-saving logic. The Book class only has one responsibility and that is to manage the book information.

I know it looks more complicated and required more steps from you as a developer, but I promise you that the code is more maintainable, extensible, and easier to understand. You will thank your future self.

O – Open/Closed Principle 🔐

The Open/Closed Principle (OCP) tells us that our entities should be open for extension but closed for modification. This allows for new functionalities to be added without altering existing code.

The Open/Closed Principle is like having a special toy box where you can add new toys without changing the old ones.

Let's have another example to see how this is implemented in a bad way and how we can solve it afterward. Imagine you got a Shape class, it is responsible for calculating the different types of shapes. What if I want to add more shapes in the future without modifying the Shape I have already made?

This is how you should not do it!

public class Shape
{
    public string Type { get; set; }

    public double Area()
    {
        if (Type == "Rectangle")
        {
            // Calculate area of the rectangle
            // ...
        }
        else if (Type == "Circle")
        {
            // Calculate area of the circle
            // ...
        }
        // More else-if blocks for other shapes, e.g., Triangle, Square, etc.
        // This code needs modification whenever a new shape is added.
        // This violates OCP.
    }
}

In the example above I have added each shape in the Area method and this requires me to modify the Area method whenever a new shape is added in my system. Now let's refactor that code in order to comply with the Open/Closed Principle. This can be fixed very easily using inheritance and polymorphism.

Let's fix the bad code! Here is the good and refactored code.

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

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }

    public override double Area()
    {
        return Width * Height;
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }

    public override double Area()
    {
        return Math.PI * Radius * Radius;
    }
}

// Adding a new shape (e.g., Triangle) without modifying existing code.
public class Triangle : Shape
{
    public double Base { get; set; }
    public double Height { get; set; }

    public override double Area()
    {
        return 0.5 * Base * Height;
    }
}

See what I did there? 🎉 In my refactored code above I made the Shape class abstract with an abstract Area() method. I then created some concrete classes for each of my shape types (Rectangle, Circle, Triangle, etc...). Now I can continue adding several shapes without modifying the already existing ones.

🤓
Abstract means something that is not specific or concrete. It represents ideas or concepts rather than actual, tangible objects.

L – Liskov Substitution Principle 📡

The Liskov Substitution Principle (LSP) tells us that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. What the heck does that mean, Christian?! 🤔

Imagine you got a class and you create a new class that inherits from the already existing class. LSP tells us that we should always be able to use the new class wherever the original class is expected without making any troubles or bugs. 🦟

The Liskov Substitution Principle ensures that you can use a specialized version of an object wherever the original one is expected, without breaking the program's behavior.

Let's see how we can violate LSP and implement some smelly code in our application. Imagine you got a Bird class and a Penguin class that inherits from the Bird class. Now we should be able to use the Penguin class all places, where the Bird class was actually expected.

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("A bird is flying.");
    }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        // Penguins can't fly, so we override the Fly method with a different behavior. Whoops...
        Console.WriteLine("Sorry, I can't fly. I can swim though!");
    }

    public void Swim()
    {
        Console.WriteLine("I'm swimming!");
    }
}

See the problem? Now the Fly method is different from the original class and that breaks the LSP. So... how can we fix that? 👨‍💻

using System;

public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("A bird is flying.");
    }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        // Penguins can't fly, so we override the Fly method with a different behavior.
        Console.WriteLine("Sorry, I can't fly. I can swim though!");
    }

    public void Swim()
    {
        Console.WriteLine("I'm swimming!");
    }
}

public class BirdWatcher
{
    public void WatchBirdFly(Bird bird)
    {
        bird.Fly();
    }
}

public class Program
{
    public static void Main()
    {
        BirdWatcher birdWatcher = new BirdWatcher();
        Bird bird = new Bird();
        Bird penguin = new Penguin();

        birdWatcher.WatchBirdFly(bird);     // Output: "A bird is flying."
        birdWatcher.WatchBirdFly(penguin);  // Output: "Sorry, I can't fly. I can swim though!"
    }
}

To fix this issue I introduced before I added a BirdWatcher class. We are keeping the Bird and Penguin classes from before, but this time we are using the BirdWatcher class with the WatchBirdFly method that takes a Bird object as a parameter and then makes it fly.

In the Main method I created new instances of the Bird and the Penguin classes and call the WatchBirdFly on both of them. This will allow me to use the Fly method in both classes without problems, and I can still override the logic in the Penguin without breaking the Bird. BirdWatcher to the rescue 🦜

I – Interface Segregation Principle 😰

Have you ever implemented methods from interfaces other people have written where you were thinking – what the actual #$@&%*!... what did they think of?! No neither have I, said no developer ever... 🤣

You might have guessed it. The Interface Segregation Principle (ISP) tells us that our classes should not be forced to implement interfaces is does not use.

Instead of having a single large interface, classes should have multiple smaller, specific interfaces.

Imagine you have a Printer class and it is a multifunction printer with the whole shebang! An expensive one, it can Print, Copy, and Scan documents. Now let's see how the lazy bad programmer implemented the logic for it.

public interface IPrinter
{
    void Print();
    void Copy();
    void Scan();
}

public class Printer : IPrinter
{
    public void Print()
    {
        // Printing implementation
    }

    public void Copy()
    {
        // Copying implementation
    }

    public void Scan()
    {
        // Scanning implementation
    }
}

The code above got an IPrinter interface that contains three methods for each of the functionalities that the printer provides. Well... not all printers support all three of the features and one of the methods is also not relevant for the class.

Let's see how we can refactor the code to comply with ISP.

public interface IPrinter
{
    void Print();
}

public interface ICopyMachine
{
    void Copy();
}

public interface IScanner
{
    void Scan();
}

public class Printer : IPrinter
{
    public void Print()
    {
        // Printing implementation
    }
}

public class CopyMachine : ICopyMachine
{
    public void Copy()
    {
        // Copying implementation
    }
}

public class Scanner : IScanner
{
    public void Scan()
    {
        // Scanning implementation
    }
}

In my code above I have segregated the methods into three smaller interfaces:

  • IPrinter - Only responsible for doing print stuff.
  • ICopyMachine - Only responsible for handling copy tasks. Another name might perhaps be better. 😅
  • IScanner - Only responsible for scanning documents.

Now our application is more flexible and modular. This allows each class to implement only the methods it requires and avoid cluttering the code with irrelevant methods.

D – Dependency Inversion Principle 🧱

The last principle Dependency Inversion Principle (DIP) is responsible for making sure that high-level modules don't depend on low-level modules.

In other words - This principle encourages us to rely on abstractions (interfaces or abstract classes) rather than concrete implementations. By doing so, we can decouple classes and make sure that the code is more maintainable and flexible. What's not to like!? ✌️

Last example for today. Consider an application where you have a Logger class that writes log messages to different destinations, such as a File or a Database. Let's see how we should not implement that in real life.

public enum LogDestination
{
    File,
    Database
}

public class Logger
{
    private LogDestination destination;

    public Logger(LogDestination destination)
    {
        this.destination = destination;
    }

    public void Log(string message)
    {
        if (destination == LogDestination.File)
        {
            // Write log message to a file.
        }
        else if (destination == LogDestination.Database)
        {
            // Write log message to a database.
        }
    }
}

In my code example above I have implemented the Logger class so it depends directly on the LogDestination enum and then has conditional statements based on the destination chosen.

This makes the Logger class tightly coupled to specific implementations, which is the primary factor for making it difficult in the future to extend it and add new log destinations or modify the current ones.

Let's see how to fix that using dependency injection.

public interface ILogDestination
{
    void WriteLog(string message);
}

public class FileLogDestination : ILogDestination
{
    public void WriteLog(string message)
    {
        // Write log message to a file.
    }
}

public class DatabaseLogDestination : ILogDestination
{
    public void WriteLog(string message)
    {
        // Write log message to a database.
    }
}

public class Logger
{
    private ILogDestination destination;

    public Logger(ILogDestination destination)
    {
        this.destination = destination;
    }

    public void Log(string message)
    {
        destination.WriteLog(message);
    }
}

Great! 💪 Now we got a ILogDestination interface and separate implementations for the destinations. The Logger class no longer depends on the specific implementations but on the ILogDestination abstraction. As you can see I now use dependency injection to inject the appropriate ILogDestination into the Logger class.

In DIP terms – The high-level Logger module/class depends on the abstraction ( ILogDestination ), and the low-level modules/classes ( FileLogDestination and DatabaseLogDestination ) depends on the abstraction as well. Now I can easily extend with other log destinations in the future or make modifications without breaking any other class.

Summary

The SOLID principles has been around for a long time and lots of developers are discussing them all the time. Some engineers believe they have withstood their time and others think its the best.

What is your perspective? I would love to hear about that in the comments below. Do you use them or think they are still relevant?

From my own perspective, I can only say that think the SOLID principles is a good foundation for writing good and maintainable software/code. I do not always live by them, but I think they are great to have in mind. For small applications they can be quite overwhelming to implement, but the idea behind them might lead to some better smaller applications if used correctly.

Thank you for taking the time to read this post about the SOLID Principles. Let me know your thoughts in the comments below. Until next time – Happy coding! ✌️