Beginner - Software Engineering

SOLID Principles: The Basics of Good Software Design

Even though my purpose is publishing ever green content in this blog, it is not easy to write about such a broader topic. Due to my recent interview process, I have been exposed to this topic since it is being asked again and again. Maybe, I should give the link to this article before the interview and skip to the next question straight away. Anyway, lets dive into the topic.

There are certain foundational concepts about software engineering guiding developers in writing efficient, maintainable, and scalable code and the SOLID principles is at the top of the list. The word “SOLID” is an acronym, representing five design principles essential for building strong and easy-to-maintain object-oriented software. The principles are Single Responsibility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle. These principles offer a mixture of guidelines that can prevent a lot of common design issues in object-oriented systems.

The SOLID principles were introduced by Robert C. Martin, also known as “Uncle Bob” in the early 2000s. These principles, although they are invented in the 21st century, tthey were built upon foundational concepts of object-oriented design and programming that had been evolving for decades. The branding of these ideas into the SOLID framework was efficient in providing developers a clear set of guidelines to prevent many of the common pitfalls in software design.

Having a good understanding of the SOLID principles has proven time and time again to be essential for software architects and developers. By applying to these principles, teams can ensure that their code remains clean, and ready to meet the evolving challenges and requirements. In this article, I will explain SOLID principles and provide code examples. I generally use C#, but I used JavaScript in this case due to a reason I don’t really know. Let’s dive.

Single Responsibility Principle (SRP)

The idea behind the Single Responsibility Principle is straightforward – a class should have one, and only one responsibility. Now, why is this important? Imagine the number of times you’ve come across a piece of code that does too much. It’s confusing, isn’t it?

When we talk about responsibilities, we’re referring to reasons for change. Let’s say you have a Document class that contains data and methods to both format and print the document. Now, this class has two responsibilities – handling the data and taking care of the printing process. If you want to change the way you print in the future, you would be altering the class for a printing-related change. Similarly, if the data format changed, you would adjust the same class but for a different reason. This can lead to fragile classes that are hard to maintain and open to errors.

For an example, consider a basic application that manages user data. Here’s a User class that both manages user data and saves it to a database:

class User {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  saveToDatabase() {
    // Logic to save user to database
  }
}

This class has multiple reasons to change – user data management and database interactions. A better approach would be to split these responsibilities:

class User {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

class DatabaseManager {
  saveUser(user) {
    // Logic to save user to database
  }
}

Here, each class has its own responsibility, making it easier to manage and modify.

Open/Closed Principle (OCP)

Open/Closed Principle has a unique formula. The principle suggests that software entities should be open for extension but closed for modification. It might sound a bit paradoxical, but it’s actually quite logical. When we say “open for extension,” we’re talking about the ability to add new functionality without altering existing code. On the other hand, “closed for modification” means that once a module has been developed and tested, its behaviour should not be changed.

Let’s think about a class called Shape. If we have methods that calculate the area of different shapes, every time we introduce a new shape, we’ll end up modifying this class. This can introduce errors and affect existing functionality. A better way would be to have a base Shape class and extend it for different shapes:

class Shape {
  getArea() {}
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  getArea() {
    return 3.14 * this.radius * this.radius;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  getArea() {
    return this.side * this.side;
  }
}

This way, adding a new shape is simply done by extending the Shape class without changing the existing code.

Liskov Substitution Principle (LSP)

The 3rd one, Liskov Substitution Principle (for some reason this one is my favourite) is slightly more complex than the others. Simply, objects of a superclass should be replaceable with objects of a subclass without affecting the program’s correctness. This principle ensures that a derived class just improves the behaviour of the base class without changing its original purpose.

To understand better, think about a Bird class with a method fly. If we introduce a subclass called Penguin which is a bird but cannot fly, then substituting a Penguin object where a generic bird object is expected would create issues:

class Bird {
  fly() {
    console.log("Flying...");
  }
}

class Penguin extends Bird {
  fly() {
    throw new Error("Can't fly");
  }
}

Using a Penguin object and calling fly will cause an error. A more appropriate design might separate flying birds and non-flying birds, ensuring that derived classes genuinely extend the behaviour of base classes. We can fix the issue as follows:

class Bird {
  // common methods and properties for birds
}

class FlyingBird extends Bird {
  fly() {
    console.log("Flying...");
  }
}

class Sparrow extends FlyingBird {
  // Behaviours specific to a Sparrow
}

class Penguin extends Bird {
  // Penguin specific behaviours which fly is not one of them
  swim() {
    console.log("Swimming...");
  }
}

// Using the classes:
const bird1 = new Sparrow();
bird1.fly();

const bird2 = new Penguin();
bird2.swim();

Interface Segregation Principle (ISP)

As we proceed, we meet the Interface Segregation Principle. This principle points out a common pitfall which is creating bulky interfaces. The core idea here is that no client should depend on methods that it does (or will) not use.

Imagine having an interface Worker that has methods related to working, eating, and sleeping. Now, if there’s a RobotWorker that implements this interface, it’s in trouble. Robots don’t eat or sleep. By creating a single, broad interface, we’re forcing the RobotWorker to implement methods irrelevant to it’s purpose. It’s like buying a Swiss Army knife when you just need a simple blade.

In practice, it’s better to have smaller, specific interfaces. The best practice in this case might be having separate Working, Eating and Sleeping interfaces. Then, our HumanWorker class can implement all three, while the RobotWorker only implements the Working interface. By this way, we’re not imposing unnecessary methods on a class.

Dependency Inversion Principle (DIP)

Lastly, the Dependency Inversion Principle. This principle emphasizes depending on abstractions over concrete implementations. When we discuss high-level modules (providing core functionalities) and low-level modules (details supporting those functionalities), this principle recommends both should depend on abstractions.

Imagine a scenario where you’re building a light control system. You have a Switch class to operate different types of bulbs. Without DIP, you might design it like this:

class LEDBulb {
    turnOn() {
        console.log("LED bulb turned on.");
    }
    turnOff() {
        console.log("LED bulb turned off.");
    }
}

class Switch {
    constructor(bulb) {
        this.bulb = bulb;
    }
    operate() {
        this.bulb.turnOn();
    }
}

With the above approach, the Switch class is tightly coupled with LEDBulb. If you decide to introduce another bulb type, say, HalogenBulb, you’ll need to modify the Switch class, violating the Open/Closed Principle.

Now, let’s implement Dependency Inversion Principle:


interface Bulb {
    turnOn();
    turnOff();
}

class LEDBulb extends Bulb {
    turnOn() {
        console.log("LED bulb turned on.");
    }
    turnOff() {
        console.log("LED bulb turned off.");
    }
}

class HalogenBulb extends Bulb {
    turnOn() {
        console.log("Halogen bulb turned on.");
    }
    turnOff() {
        console.log("Halogen bulb turned off.");
    }
}

class Switch {
    constructor(bulb) {
        if (!bulb.turnOn || !bulb.turnOff) {
            throw new Error("The bulb must have turnOn and turnOff methods");
        }
        this.bulb = bulb;
    }
    operate() {
        this.bulb.turnOn();
    }
}

With this design, the Switch class is now decoupled from the specific bulb type and depends on the Bulb abstraction. This means you can introduce and use any new bulb type without changing the Switch class. The high-level Switch class doesn’t rely on the low-level LEDBulb or HalogenBulb; instead, both depend on the Bulb abstraction, thereby aligning with the Dependency Inversion Principle.

Suleyman Cabir Ataman, PhD

Sharing on social media:

Leave a Reply

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