SOLID Principles — explained with examples

“Bad code is like a broken-down slum — hard to live in, harder to fix, and a nightmare to expand.”

This is why SOLID design matters.

Whether you’re adding a small feature or architecting an entire system, the SOLID principles help you write code that’s easier to maintain, scale, and test.

Writing clean, reliable, and scalable code is the goal of every developer. The SOLID principles — a set of five key design guidelines in object-oriented programming — are powerful tools to help you achieve that goal.

In this blog post, we will explain each of these five principles through a single relatable example: a Bird system.

 

🚀 What is SOLID?

SOLID is an acronym for five design principles introduced by Robert C. Martin (Uncle Bob), and they stand as the backbone of object-oriented architecture:

Letter Principle Goal
S Single Responsibility Principle One class = One job
O Open/Closed Principle Extend, don’t modify
L Liskov Substitution Principle Subtypes should fit seamlessly
I Interface Segregation Principle Keep interfaces focused and lean
D Dependency Inversion Principle Depend on abstractions, not concretions

 

Let’s assume you are building a simple system to model birds. Here is the starting point: we will explore each SOLID principle using birds as examples. 🐦

1️⃣ SRP – Single Responsibility Principle:

“A class should have only one reason to change.”

❌ Violation
Suppose you start with this simple Bird class:

class Bird
{
public:
    void fly() { /* flying logic */ }
    void eat() { /* eating logic */ }
    void printReport() { /* print logic */ }
};

The Bird class now handles:

  • Flying behavior
  • Eating behavior
  • Reporting behavior (maybe for logging or UI)

Violation: Too many responsibilities.

 

✅ Better Design (SRP applied):

Now, each class has a single, focused job. Each class has one reason to change, adhering to SRP.

class WalkableBird
{
public:
    void Walk() { /* Walk logic */ }
    void eat() { /* eating logic */ }
};

class FlyableBird
{
public:
    void fly() { /* flying logic */ }
    void eat() { /* eating logic */ }
};

class BirdReporter
{
public:
    void print(const Bird& bird) { /* printing logic */ }
};
  • If you later add a Penguin that cannot fly, you only need to adjust the WalkableBird class—not the FlyableBird class.
  • This separation makes your code easier to test, maintain, and extend.

🧠 Takeaway: Keep classes small and focused. SRP makes your code easier to maintain, test, and extend.

 

✅ 2. Open/Closed Principle (OCP):

Software should be open for extension but closed for modification.

In simple terms:

  • You should be able to add new behavior without changing existing code.
  • This helps reduce bugs and keeps systems stable as they grow.

❌ Violation:
Let’s say you want to calculate flying speed of different birds. Here’s a naive implementation:

class Bird
{
public:
    std::string name;
    std::string species;

    Bird(const std::string& name, const std::string& species)
        : name(name), species(species) {}

    //get bird speed
    double getBirdSpeed(Bird* bird)
    {
        if (dynamic_cast<Sparrow*>(bird))
        {
            return 10.5
        };
        else if (dynamic_cast<Eagle*>(bird))
        {
            return 25.0
        };
        else return 0;
    }
};

👎 Every time you add a new bird, you must modify this function — violating OCP.

 

✅ OCP-Compliant Design

Instead of modifying the base class, we extend it using inheritance or polymorphism.

class Bird
{
public:
    virtual double getBirdSpeed() const = 0;
};

class Sparrow : public Bird
{
public:
    double getBirdSpeed() const override
    {
        return 10.5;
    }
};

class Eagle : public Bird
{
public:
    double getBirdSpeed() const override
    {
        return 25.0;
    }
};

void printSpeed(const Bird& bird)
{
    std::cout << "Speed: " << bird.getBirdSpeed() << " km/h\n";
}

Now, adding a Parrot or Crow needs no change to existing code.

🧠 Takeaway: Use virtual functions and inheritance to add behavior without editing core logic.

🧠 Benefits:

  • You can add new bird types without touching the existing Bird class or other subclasses.
  • The system is extensible and stable — exactly what OCP promotes.

 

✅ 3. Liskov Substitution Principle (LSP):

“Subtypes must be substitutable for their base types without altering the correctness of the program.”

Simply:

If class Penguin is a subclass of class Bird, then anywhere a Bird is used, a Penguin should be able to replace it without breaking the behavior.

❌ Violation

Let’s look at what happens if we treat all birds as if they can fly:

class Bird
{
public:
    std::string name;

    Bird(const std::string& name) : name(name) {}

    virtual void fly()
    {
        std::cout << name << " is flying." << std::endl;
    }

    virtual ~Bird() {}
};



class Penguin : public Bird
{
public:
    Penguin(const std::string& name) : Bird(name) {}

    void fly() override
    {
        // This makes no sense for a penguin
        std::cout << name << " can't fly!" << std::endl;
    }
};

Problem:

You are violating LSP because you are forcing a Penguin to implement fly() — a behavior it should not have. A penguin is not a bird that can fly, so substituting Penguin for Bird may lead to unexpected behavior.

 

✅ LSP Compliant Design

Split flying ability into its own abstraction, so only flying birds implement it.

Step 1: Base Bird class without fly():

class Bird
{
public:
    std::string name;

    Bird(const std::string& name) : name(name) {}

    virtual void move() = 0;
    virtual ~Bird() {}
};

 

Step 2: Create FlyingBird and NonFlyingBird:

class FlyingBird : public Bird
{
public:
    FlyingBird(const std::string& name) : Bird(name) {}

    void move() override
    {
        std::cout << name << " is flying." << std::endl;
    }
};



class NonFlyingBird : public Bird
{
public:
    NonFlyingBird(const std::string& name) : Bird(name) {}

    void move() override
    {
        std::cout << name << " is walking." << std::endl;
    }
};

Step 3: Create Specific birds:

class Sparrow : public FlyingBird
{
public:
    Sparrow(const std::string& name) : FlyingBird(name) {}
};




class Penguin : public NonFlyingBird
{
public:
    Penguin(const std::string& name) : NonFlyingBird(name) {}
};

Now only birds that can fly implement fly(), ensuring safe substitution.

🧠 Takeaway: Your subclasses must behave like the base class — not break it.

 

✅ 4. Interface Segregation Principle (ISP)

“Don’t force classes to implement methods they don’t use.”

In simpler terms:

  • Clients should not be forced to depend on interfaces they do not use.
  • Don’t lump too many unrelated methods into a single interface.
  • Instead, break interfaces into smaller, more specific ones, so classes only implement what they need.

❌ Violation

Suppose we define an interface that assumes all birds can fly and swim:

class IBird
{
public:
    virtual void fly() = 0;
    virtual void swim() = 0;
    virtual ~IBird() {}
};

Now we create a penguin, which can swim but can’t fly:

class Penguin : public IBird
{
public:
    void fly() override //❌
    {
        // Penguin can't fly — this is awkward
        std::cout << "Penguins can't fly!" << std::endl;
    }

    void swim() override
    {
        std::cout << "Penguin is swimming." << std::endl;
    }
};

👎 Penguin is forced to implement a fly() method it doesn’t need, which makes no sense. This violates the Interface Segregation Principle (ISP).

 

✅ ISP-Compliant Design:

This is the first SOLID principle that specifically applies to interfaces, while the previous three principles primarily apply to classes. Let’s consider the following example to understand this principle.

 

Step 1: Create segregated interfaces:

class IFlyable
{
public:
    virtual void fly() = 0;
    virtual ~IFlyable() {}
};

class ISwimmable
{
public:
    virtual void swim() = 0;
    virtual ~ISwimmable() {}
};

 

Step 2: Base bird class:

class Bird
{
public:
    std::string name;

    Bird(const std::string& name) : name(name) {}
    virtual ~Bird() {}
};

Step 3: Implement only relevant interfaces:

class Sparrow : public Bird, public IFlyable
{
public:
    Sparrow(const std::string& name) : Bird(name) {}

    void fly() override
    {
        std::cout << name << " is flying." << std::endl;
    }
};




class Penguin : public Bird, public ISwimmable
{
public:
    Penguin(const std::string& name) : Bird(name) {}

    void swim() override
    {
        std::cout << name << " is swimming." << std::endl;
    }
};

✅ Usage Example:

int main()
{
    Sparrow sparrow("Sparrow");
    Penguin penguin("Penguin");

    sparrow.fly();      // Sparrow is flying.
    penguin.swim();     // Penguin is swimming.

    return 0;
}

🧠 Why This Works:

  • Sparrow implements only what it needs (IFlyable)
  • Penguin implements only what it needs (ISwimmable)
  • No class is forced to deal with irrelevant methods

“ISP is about avoiding bloated interfaces — make interfaces sharp and focused”.

 

✅ 5. Dependency Inversion Principle (DIP):

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”

This principle makes your code loosely coupled, easier to test, and allows you to swap out components with minimal impact.

The Dependency Inversion Principle also states that:

“Classes should depend on abstractions, not on concretions.”

But what does that really mean?

Instead of having one class directly depend on another concrete class, it should depend on an interface or abstract class. This abstraction acts as a contract that both sides agree on.

By doing this, class A doesn’t directly interact with class B’s internal implementation — it communicates through the abstraction. As a result, if class B’s implementation changes, class A doesn’t need to change, because it relies only on the stable interface.

👉 This promotes loose coupling, enhances flexibility, and makes the system much easier to extend, maintain, and test

 

❌ Bad Design: Tightly coupled class:

Imagine a BirdFeeder class directly creating and feeding birds:

class Sparrow
{
public:
    void eat()
    {
        std::cout << "Sparrow is eating seeds." << std::endl;
    }
};

class BirdFeeder
{
public:
    void feed()
    {
        Sparrow sparrow;
        sparrow.eat();  // tightly coupled to Sparrow
    }
};

👎 Problem:

  • BirdFeeder is tightly coupled to the Sparrow class.
  • You can’t easily feed other types of birds without modifying BirdFeeder.

✅ DIP-Compliant Design:

Let’s depend on an abstraction like an interface IBird, not on specific bird types.

 

Step 1: Define the abstraction:

class IBird
{
public:
    virtual void eat() = 0;
    virtual ~IBird() {}
};

Step 2: Implement different birds:

class Sparrow : public IBird
{
public:
    void eat() override
    {
        std::cout << "Sparrow is eating seeds." << std::endl;
    }
};



class Crow : public IBird
{
public:
    void eat() override
    {
        std::cout << "Crow is eating grains." << std::endl;
    }
};

 

Step 3: Create the high-level module (BirdFeeder) that depends on the abstraction:

class BirdFeeder
{
private:
    IBird* bird;

public:
    BirdFeeder(IBird* bird) : bird(bird) {}

    void feed()
    {
        bird->eat();  // depends only on IBird
    }
};

 

✅ Usage:

int main()
{
    Sparrow sparrow;
    Crow crow;

    BirdFeeder sparrowFeeder(&sparrow);
    BirdFeeder crowFeeder(&crow);

    sparrowFeeder.feed();  // Sparrow is eating seeds.
    crowFeeder.feed();  // Crow is eating grains.

    return 0;
}

🧠 Why This Works:

  • BirdFeeder is now decoupled from specific birds.
  • You can add new birds (e.g., Pigeon, Duck) without changing BirdFeeder.
  • This aligns with Dependency Inversion by inverting control: BirdFeeder doesn’t decide which bird to feed.

 

💡 Bonus Tip:

In a real-world application, you’d often inject dependencies via a constructor or a dependency injection framework.