Skip to content
Back to writings
Learning notes

SOLID programming principles with analogies

Breaks down the five essential SOLID principles using simple analogies and code examples.

Muhammad Rizki 3 min read
SOLID programming principles with analogies

Imagine you're a software architect. Your job isn't just to build an application that "works," but to design a digital "building" that is robust, easy to renovate, and won't collapse when changes are needed. The SOLID principles are the five fundamental architectural foundations that will ensure your code-building stands strong for a long time.

S - The Single Responsibility Principle

The Analogy: Every worker on a construction project should have one primary job. A painter focuses on painting, an electrician on wiring. If one person does everything, the project becomes chaotic and hard to manage.

The Core Idea: A class should have only one reason to change.

The Problem: This Report class has two responsibilities: generating the report's content and saving it to a file.

python
class Report:
    def __init__(self, title, content):
        self.title = title
        self.content = content

    def get_report_content(self):
        return f"Report: {self.title}\\n{self.content}"

    # Another responsibility: saving the file
    def save_to_file(self, filename):
        with open(filename, 'w') as f:
            f.write(self.get_report_content())

The Solution: Separate these responsibilities into different classes.

python
# This class is ONLY responsible for the report's content
class Report:
    def __init__(self, title, content):
        self.title = title
        self.content = content

    def get_content(self):
        return f"Report: {self.title}\\n{self.content}"

# This class is ONLY responsible for persistence
class PersistenceManager:
    @staticmethod
    def save_to_file(data, filename):
        with open(filename, 'w') as f:
            f.write(data)

# How to use it:
monthly_report = Report("Monthly", "This is the report content.")
content = monthly_report.get_content()
PersistenceManager.save_to_file(content, "report.txt")

The Result: If you later want to change the persistence method (e.g., save to a database), you only need to modify PersistenceManager without touching the Report class.

O - The Open/Closed Principle

The Analogy: Your building should be open for extension (e.g., adding a new floor), but closed for modification (you shouldn't have to tear down the foundation to add that floor).

The Core Idea: A class should be extensible without modifying its own code.

The Problem: This AreaCalculator must be changed every time a new shape is introduced.

python
class Square:
    def __init__(self, side):
        self.side = side

class Circle:
    def __init__(self, radius):
        self.radius = radius

class AreaCalculator:
    def calculate(self, shape_list):
        total_area = 0
        for shape in shape_list:
            if isinstance(shape, Square):
                total_area += shape.side ** 2
            elif isinstance(shape, Circle):
                total_area += 3.14 * (shape.radius ** 2)
            # If we add a Triangle, we have to add another 'if' here!
        return total_area

The Solution: Use abstraction (e.g., an abstract class) so the AreaCalculator doesn't care about the details of each shape.

python
from abc import ABC, abstractmethod

# A contract or blueprint for all shapes
class Shape(ABC):
    @abstractmethod
    def calculate_area(self):
        pass

class Square(Shape):
    def __init__(self, side):
        self.side = side
    def calculate_area(self):
        return self.side ** 2

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def calculate_area(self):
        return 3.14 * (self.radius ** 2)

# This class is now closed for modification
class AreaCalculator:
    def calculate(self, shape_list):
        total_area = 0
        for shape in shape_list:
            total_area += shape.calculate_area() # Just call the method
        return total_area

The Result: You can add Triangle, Trapezoid, etc., without ever touching the AreaCalculator code again.

L - The Liskov Substitution Principle

The Analogy: If you order a "chair" (base class), any "office chair" or "dining chair" (subclass) that is delivered must still function as a chair. You must be able to sit on it. If you get a fragile "display chair" that breaks, the principle is violated.

The Core Idea: A subclass must be substitutable for its base class without causing errors.

The Problem: Not all Birds can fly. A Penguin, which is a subclass of Bird, will break a program that expects all birds to fly.

python
class Bird:
    def fly(self):
        print("Flying high...")

class Penguin(Bird):
    def fly(self):
        # Penguins can't fly, this will be a problem
        raise Exception("I can't fly!")

def make_bird_fly(bird: Bird):
    bird.fly()

# This code will error out when run
penguin = Penguin()
make_bird_fly(penguin)

The Solution: Create more appropriate abstractions. Don't force an ability that doesn't exist.

python
class Bird:
    # All birds have common characteristics, but not all can fly
    pass

class FlyingBird(Bird):
    def fly(self):
        print("Flying high...")

class Eagle(FlyingBird):
    pass

class Penguin(Bird):
    # A Penguin is a Bird, but not a FlyingBird
    pass

def make_bird_fly(bird: FlyingBird):
    bird.fly()

# This code is now safe
eagle = Eagle()
make_bird_fly(eagle) # Works

The Result: Your class hierarchy becomes more logical and prevents hard-to-trace bugs.

I - The Interface Segregation Principle

The Analogy: You wouldn't give a security guard a complete mechanic's toolbox (wrench, screwdriver, hammer). The guard only needs the keys to the gate. Provide specific interfaces (sets of tasks) according to their needs.

The Core Idea: Don't force a class to implement methods it doesn't need.

The Problem: The IWorker interface is too "fat." A Robot is forced to have eat and sleep methods.

python
from abc import ABC, abstractmethod

class IWorker(ABC):
    @abstractmethod
    def work(self): pass
    @abstractmethod
    def eat(self): pass

class Human(IWorker):
    def work(self): print("Human is working...")
    def eat(self): print("Human is eating...")

class Robot(IWorker):
    def work(self): print("Robot is working...")
    def eat(self):
        # Robots don't eat, this method is useless
        pass

The Solution: Break the "fat" interface into smaller, more specific ones.

python
class IWorkable(ABC):
    @abstractmethod
    def work(self): pass

class IEatable(ABC):
    @abstractmethod
    def eat(self): pass

# A human can work and eat
class Human(IWorkable, IEatable):
    def work(self): print("Human is working...")
    def eat(self): print("Human is eating...")

# A robot can only work
class Robot(IWorkable):
    def work(self): print("Robot is working...")

The Result: Classes become more focused and only implement the abilities they truly possess.

D - The Dependency Inversion Principle

The Analogy: A manager (high-level module) shouldn't depend on a specific courier's name, like "Bob" (low-level module). The manager only needs to know about a "courier service" (abstraction). Any courier will do, as long as they fulfill the "courier service" contract.

The Core Idea: High-level modules should not depend on low-level modules. Both should depend on abstractions.

The Problem: The Notification class is tightly coupled to the EmailService. It's hard to switch to SMS.

python
class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")

class Notification:
    def __init__(self):
        # Direct dependency on a concrete class
        self.email_service = EmailService()

    def send(self, message):
        self.email_service.send_email(message)

The Solution: Make Notification depend on a "contract" (interface), not a concrete class.

python
class IMessageService(ABC):
    @abstractmethod
    def send_message(self, message): pass

class EmailService(IMessageService):
    def send_message(self, message):
        print(f"Sending email: {message}")

class SMSService(IMessageService):
    def send_message(self, message):
        print(f"Sending SMS: {message}")

class Notification:
    # Depends on the abstraction, not the detail
    def __init__(self, service: IMessageService):
        self.service = service

    def send(self, message):
        self.service.send_message(message)

# Now it's incredibly flexible!
email_notification = Notification(EmailService())
email_notification.send("Hello via Email")

sms_notification = Notification(SMSService())
sms_notification.send("Hello via SMS")

The Result: Your system becomes highly flexible and "pluggable." You can swap the email service for SMS, Push Notifications, or anything else without changing the Notification class.

Conclusion

By applying these five principles, you are no longer just "stacking bricks." You are becoming an architect who designs elegant, strong, and future-proof software buildings.

Portrait of Muhammad Rizki

About the author

Muhammad Rizki

Muhammad Rizki is a full-stack and AI-focused engineer who writes about web development, software engineering, AI concepts, and the practical lessons behind building portfolio projects.

Keep reading

Related writings