Skip to content
SP StackPractices
intermediate

SOLID Principles Explained with Examples

Learn the five SOLID principles with practical code examples: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.

Topics: design

SOLID Principles Explained with Examples

Introduction

SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable. They were introduced by Robert C. Martin and are foundational to object-oriented design.

LetterPrincipleCore Idea
SSingle ResponsibilityA class should have one reason to change
OOpen/ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for their base types
IInterface SegregationClients should not depend on interfaces they don’t use
DDependency InversionDepend on abstractions, not concretions

S — Single Responsibility Principle (SRP)

A class should have only one reason to change.

# Bad: one class handles order logic AND reporting
class OrderManager:
    def create_order(self, items):
        ...
    def cancel_order(self, order_id):
        ...
    def generate_monthly_report(self):
        ...  # completely different concern

# Good: separate responsibilities
class OrderService:
    def create_order(self, items):
        ...
    def cancel_order(self, order_id):
        ...

class ReportGenerator:
    def generate_monthly_report(self):
        ...

Why it matters: When a class has multiple responsibilities, changes to one responsibility can break another. Small, focused classes are easier to understand, test, and reuse.

O — Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

# Bad: modify existing code for every new payment method
class PaymentProcessor:
    def process(self, payment):
        if payment.type == "credit_card":
            ...
        elif payment.type == "paypal":
            ...
        elif payment.type == "crypto":  # added later
            ...

# Good: extend via new classes
class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount):
        pass

class CreditCardPayment(PaymentMethod):
    def process(self, amount):
        ...

class PayPalPayment(PaymentMethod):
    def process(self, amount):
        ...

class PaymentProcessor:
    def __init__(self, method: PaymentMethod):
        self.method = method

    def process(self, amount):
        self.method.process(amount)

# Adding a new method requires zero changes to existing code
class CryptoPayment(PaymentMethod):
    def process(self, amount):
        ...

Why it matters: Modifying existing, working code introduces risk. By extending through new code, you preserve the stability of what already works.

L — Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the program.

# Bad: Square violates LSP when used as Rectangle
class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def set_width(self, w):
        self._width = w

    def set_height(self, h):
        self._height = h

    def area(self):
        return self._width * self._height

class Square(Rectangle):  # violates LSP
    def set_width(self, w):
        self._width = w
        self._height = w  # surprising side effect!

    def set_height(self, h):
        self._width = h   # surprising side effect!
        self._height = h

# A function expecting Rectangle behavior breaks with Square

def resize_rectangle(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20  # fails for Square!
# Good: model Square independently or as a value object
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

@dataclass(frozen=True)
class Square:
    side: int

    def area(self):
        return self.side * self.side

Why it matters: Violating LSP leads to subtle bugs when polymorphism is used. The subclass must honor the contract of the parent class.

I — Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use.

# Bad: one fat interface
class Worker(ABC):
    @abstractmethod
    def work(self):
        pass
    @abstractmethod
    def eat(self):  # robots don't eat
        pass
    @abstractmethod
    def sleep(self):  # robots don't sleep
        pass

# Good: split into focused interfaces
class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

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

class HumanWorker(Workable, Feedable):
    def work(self): ...
    def eat(self): ...

class RobotWorker(Workable):
    def work(self): ...
    # no need to implement eat() or sleep()

Why it matters: Fat interfaces create unnecessary coupling. When a client depends on methods it doesn’t use, changes to those methods can force unnecessary recompilation or retesting.

D — Dependency Inversion Principle (DIP)

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

# Bad: high-level module depends on concrete low-level module
class EmailService:
    def send(self, to, subject, body):
        ...  # SMTP logic

class NotificationManager:  # high-level
    def __init__(self):
        self.email = EmailService()  # hardcoded dependency

    def notify_user(self, user):
        self.email.send(user.email, "Hello", "...")

# Good: depend on abstraction
class NotificationChannel(ABC):
    @abstractmethod
    def send(self, to, subject, body):
        pass

class EmailService(NotificationChannel):
    def send(self, to, subject, body):
        ...

class SMSService(NotificationChannel):
    def send(self, to, subject, body):
        ...

class NotificationManager:
    def __init__(self, channel: NotificationChannel):
        self.channel = channel

    def notify_user(self, user):
        self.channel.send(user.email, "Hello", "...")

# Easy to swap implementations without changing NotificationManager
email_notifier = NotificationManager(EmailService())
sms_notifier = NotificationManager(SMSService())

Why it matters: Depending on abstractions makes the system flexible. You can swap implementations (for testing, different environments, or new requirements) without touching the high-level business logic.

Applying SOLID Together

SOLID principles reinforce each other:

PrincipleSupports
SRPMakes OCP easier (smaller classes = easier to extend)
OCPEnables LSP (extension via inheritance/substitution)
LSPEnables polymorphism used by DIP
ISPReduces the surface area of dependencies for DIP
DIPEnables OCP by allowing behavior injection

Common Mistakes

  • Creating a class per method to force SRP — not every function needs its own class
  • Using OCP as an excuse for premature abstraction — YAGNI still applies
  • Misapplying LSP to value objects that aren’t meant to be substitutable
  • Splitting interfaces so finely that the system becomes fragmented
  • Injecting dependencies everywhere including trivial, stable utilities

Frequently Asked Questions

Should I apply all SOLID principles to every class?

No. These are guidelines, not laws. Apply them where they reduce complexity and coupling. Small scripts and CRUD operations often don’t need full SOLID treatment.

Do SOLID principles apply only to OOP?

The concepts translate well to other paradigms. Functional programming achieves DIP via higher-order functions, and SRP applies to modules and functions in any paradigm.

How do I convince my team to refactor toward SOLID?

Don’t refactor for the sake of the principles. Wait until a change is needed, then use the principles to guide a cleaner design. Show before/after comparisons in PRs.