DEV Community

Madhukar Vissapragada
Madhukar Vissapragada

Posted on

SOLID Design Principles: Stop Writing Code That Breaks When You Touch It

Guidelines, not rules. Here's the difference — and why it matters.


What is SOLID?

SOLID is a set of software design guidelines — not hard rules, but principles that guide how we organize our code.

The goal is simple: as your codebase grows and your team scales, things should get easier to change, not harder. SOLID is what makes that possible.

Five principles. One goal. Let's walk through each one with real code.


S — Single Responsibility Principle

A class, function, or method should have one and only one reason to change.

The Violation

class Bird:
    def __init__(self, name: str, bird_type: str):
        self.name = name
        self.bird_type = bird_type

    def make_sound(self):
        # two jobs — deciding the type AND making the sound
        if self.bird_type == "parrot":
            print("Squawk!")
        elif self.bird_type == "eagle":
            print("Screech!")
        elif self.bird_type == "owl":
            print("Hoot!")
        else:
            print("...")
Enter fullscreen mode Exit fullscreen mode

make_sound() has two responsibilities — deciding which bird type it is AND making the sound. That's two reasons to change. Add a new bird? Touch make_sound(). Change how sounds work? Touch make_sound() again. Two different reasons, one method. SRP violated.

The Fix

from abc import ABC, abstractmethod

class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def make_sound(self):
        pass

class Parrot(Bird):
    def make_sound(self):
        print("Squawk!")

class Eagle(Bird):
    def make_sound(self):
        print("Screech!")

class Owl(Bird):
    def make_sound(self):
        print("Hoot!")

# Usage
birds = [Parrot("Polly"), Eagle("Sam"), Owl("Oliver")]
for bird in birds:
    bird.make_sound()
Enter fullscreen mode Exit fullscreen mode

Now each class has one responsibility. Parrot.make_sound() only changes if parrots change how they sound. Nothing else touches it.


O — Open/Closed Principle

A class should be open for extension but closed for modification.

SRP and OCP go hand in hand. When you fixed SRP in the Bird example above — you also fixed OCP.

The Violation

class BirdSoundService:
    def make_sound(self, bird_type: str):
        if bird_type == "parrot":
            print("Squawk!")
        elif bird_type == "eagle":
            print("Screech!")
        # adding a new bird = modifying this existing class
Enter fullscreen mode Exit fullscreen mode

Every time a new bird arrives, you open this class and add another elif. You're modifying existing, working code. That's an OCP violation — and a regression risk.

The Fix

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def make_sound(self):
        pass

class Parrot(Bird):
    def make_sound(self):
        print("Squawk!")

class Eagle(Bird):
    def make_sound(self):
        print("Screech!")

# Adding a new bird — zero changes to existing code
class Flamingo(Bird):
    def make_sound(self):
        print("Honk!")

class BirdSoundService:
    def make_sound(self, bird: Bird):
        bird.make_sound()  # polymorphism handles it
Enter fullscreen mode Exit fullscreen mode

New bird? New class. Existing code untouched. That's OCP.

Inheritance vs Composition

OCP can be achieved two ways — inheritance and composition. For larger codebases and larger teams, composition is safer.

Inheritance creates tight coupling. Change the parent and every subclass is potentially affected. When multiple developers are pushing code simultaneously, one change in the parent ripples through the entire hierarchy.

Composition injects behaviour instead:

class Bird:
    def __init__(self, sound_behaviour):
        self.sound = sound_behaviour

    def make_sound(self):
        self.sound.execute()

class ScreechSound:
    def execute(self):
        print("Screech!")

class SilentSound:
    def execute(self):
        print("...")

eagle = Bird(sound_behaviour=ScreechSound())
rubber_duck = Bird(sound_behaviour=SilentSound())
Enter fullscreen mode Exit fullscreen mode

Swap the behaviour by swapping the object. Nothing else changes.

Inheritance defines what you are. Composition defines what you have. Prefer what you have — it's easier to change.


L — Liskov Substitution Principle

A child class should be fully substitutable for its parent. Wherever you use the parent, the child must work correctly — no surprises, no broken contracts.

The Violation

class Bird(ABC):
    @abstractmethod
    def fly(self):
        pass

    @abstractmethod
    def make_sound(self):
        pass

class Eagle(Bird):
    def fly(self):
        print("Eagle soaring high!")

    def make_sound(self):
        print("Screech!")

class Penguin(Bird):
    def fly(self):
        raise NotImplementedError("Penguins can't fly!")  # ← LSP violated

    def make_sound(self):
        print("Squawk!")

# This breaks at runtime
def make_bird_fly(bird: Bird):
    bird.fly()  # works for Eagle, crashes for Penguin
Enter fullscreen mode Exit fullscreen mode

Penguin extends Bird but can't honour the fly() contract. It's like a button that does nothing when you click it — the interface promises behaviour, the implementation delivers nothing. Users and callers hate this.

The Fix

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def make_sound(self):
        pass

class Flyable(ABC):
    @abstractmethod
    def fly(self):
        pass

class Eagle(Bird, Flyable):
    def make_sound(self):
        print("Screech!")

    def fly(self):
        print("Eagle soaring high!")

class Penguin(Bird):
    def make_sound(self):
        print("Squawk!")
    # no fly() — penguins don't fly, so they don't implement Flyable

class Flamingo(Bird, Flyable):
    def make_sound(self):
        print("Honk!")

    def fly(self):
        print("Flamingo taking off!")

# Clean — only birds that can fly implement Flyable
def make_bird_fly(bird: Flyable):
    bird.fly()

make_bird_fly(Eagle())    # ✅
make_bird_fly(Flamingo()) # ✅
# make_bird_fly(Penguin()) — type checker catches this at compile time
Enter fullscreen mode Exit fullscreen mode

Now the contract is honest. If a class says it can fly, it actually can.


I — Interface Segregation Principle

An interface should only contain methods that are genuinely related to each other.

Notice the fix for LSP above — we moved fly() into a separate Flyable interface. That's ISP in action.

The Violation

class BirdInterface(ABC):
    @abstractmethod
    def make_sound(self): pass

    @abstractmethod
    def fly(self): pass

    @abstractmethod
    def dance(self): pass  # ← what does dancing have to do with flying?

    @abstractmethod
    def swim(self): pass
Enter fullscreen mode Exit fullscreen mode

Flying, dancing, and swimming are unrelated behaviours. Any class implementing BirdInterface is forced to implement all of them — even if dancing makes no sense for an eagle.

The rule: if you look at a method in an interface and think "why is this even here?" — that's ISP telling you to split.

The Fix

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def make_sound(self): pass

class Flyable(ABC):
    @abstractmethod
    def fly(self): pass

class Swimmable(ABC):
    @abstractmethod
    def swim(self): pass

class Danceable(ABC):
    @abstractmethod
    def dance(self): pass

# Each bird implements only what makes sense
class Eagle(Bird, Flyable):
    def make_sound(self): print("Screech!")
    def fly(self): print("Eagle soaring!")

class Penguin(Bird, Swimmable):
    def make_sound(self): print("Squawk!")
    def swim(self): print("Penguin swimming!")

class Peacock(Bird, Danceable):
    def make_sound(self): print("Screech!")
    def dance(self): print("Peacock dancing!")
Enter fullscreen mode Exit fullscreen mode

Focused, cohesive, justified. Each interface does exactly one thing.

LSP vs ISP — Same Smell, Different Diagnosis

Both surface as a class implementing something it has no business implementing:

  • Penguin forced to implement fly()LSP says fix the hierarchy
  • BirdInterface forced to include dance()ISP says split the interface

Two sides of the same coin. Knowing which applies is what separates a good diagnosis from a guess.


D — Dependency Inversion Principle

Classes should not depend directly on each other — they should both depend on an abstraction.

The Violation

class LowFly:
    def low_fly(self):
        print("Flying low...")

class HighFly:
    def high_fly(self):
        print("Flying high!")

class Pigeon:
    def __init__(self):
        self.flying = LowFly()  # hardcoded dependency

    def fly(self):
        self.flying.low_fly()

pigeon = Pigeon()
pigeon.fly()  # Flying low...
Enter fullscreen mode Exit fullscreen mode

Pigeon is hardcoded to LowFly. The pigeon has a RedBull and decides to fly high like an eagle. Now you're deep in the codebase changing every place LowFly was hardcoded. One decision ripples everywhere.

The Fix

from abc import ABC, abstractmethod

class DoFlying(ABC):
    @abstractmethod
    def fly(self): pass

class LowFly(DoFlying):
    def fly(self):
        print("Flying low...")

class HighFly(DoFlying):
    def fly(self):
        print("Flying high!")

class Pigeon:
    def __init__(self, flying: DoFlying):
        self.flying = flying  # injected, not hardcoded

    def fly(self):
        self.flying.fly()

# Flying low
pigeon = Pigeon(flying=LowFly())
pigeon.fly()  # Flying low...

# Pigeon had a RedBull — swap the behaviour, zero code changes
pigeon = Pigeon(flying=HighFly())
pigeon.fly()  # Flying high!
Enter fullscreen mode Exit fullscreen mode

Pigeon depends on the DoFlying interface — not on LowFly or HighFly directly. Both concrete classes implement the same fly() method through DoFlying. Swap the behaviour by changing one line at the call site. The Pigeon class never changes.


How They Connect

This is the question that often comes up in interviews: "How do SOLID principles relate to each other?"

Here's the connection:

SRP gives you small, focused classes. When a class does one thing, it becomes naturally easy to extend without modification — which is exactly what OCP asks for.

OCP tells you to extend via abstractions. But once you have inheritance hierarchies, LSP steps in and says: make sure your subclasses honour the parent's contract.

LSP and ISP surface the same smell — a class implementing something it has no business implementing. LSP says fix your hierarchy. ISP says fix your interface. Two sides of the same coin.

DIP is the glue that ties everything together. SRP, OCP, LSP, and ISP all tell you to create abstractions. DIP says: now depend on those abstractions, not the concretions. This is what makes everything testable, swappable, and decoupled.

SRP creates focused classes → OCP makes them extendable → LSP keeps hierarchies honest → ISP keeps interfaces clean → DIP wires it all together.


The Tradeoffs — What SOLID Costs You

SOLID is a compass, not a religion. Over-applying it leads to premature abstraction — which is its own anti-pattern.

Principle Benefit Cost
SRP Focused, easy to change in isolation More classes, more files, more navigation
OCP Add features without breaking existing code More abstractions upfront — overkill if variants never appear
LSP Subclasses are truly interchangeable Sometimes forces flat hierarchies or abandoning inheritance
ISP Classes only know what they need Interface explosion — dozens of tiny interfaces
DIP Testable, swappable, decoupled Wiring complexity — need a composition root

Signs you've over-engineered:

  • You need to open 5 files to understand one feature
  • Interfaces all have exactly one implementation
  • New engineers can't find where the actual work happens

The rule of three: Once is a one-off. Twice is a coincidence. Three times — abstract it.


Understanding SOLID isn't about reciting five definitions. It's about knowing where each principle applies, how they connect to each other, and honestly explaining what each one costs. That's what makes the difference.


Top comments (0)