Skip to main content

SOLID Principles: The Foundation of OOP Design

SOLID Principles: The Foundation of OOP Design

Writing code that works is easy. Writing code that can survive changing requirements, feature additions, and team turnover without turning into a tangled, unmaintainable mess—that is the hard part of software engineering.

In the world of Object-Oriented Programming (OOP), the biggest enemy is rigidity. When a system is rigid, a simple change in one place causes cascading breaks in seemingly unrelated places.

Enter SOLID. Coined by Robert C. Martin (Uncle Bob), these five principles are the bedrock of clean, maintainable, and scalable OOP design. They aren't rigid rules, but guiding lights to help you design software that is easy to extend and hard to break.

Here is a practical breakdown of the SOLID principles, complete with the "before" and "after" code that shows why they matter.


1. Single Responsibility Principle (SRP)

The Definition: A class should have one, and only one, reason to change.

The keyword here isn't "single purpose"—it's "reason to change." If a class handles UI rendering, business logic, and database access, it has three reasons to change. A change in the database schema could break your UI.

❌ The Violation: The "God Object"

class Employee:
    def calculate_pay(self):
        # Complex payroll logic
        pass

    def save_to_database(self):
        # DB connection and SQL queries
        pass

    def generate_employment_report(self):
        # PDF generation logic
        pass

If HR changes the payroll formula, the DBA migrates the database, and the design team updates the report template, they are all editing the same class. Merge conflicts galore.

✅ The Fix: Separate the Concerns

class PayrollCalculator:
    def calculate_pay(self, employee): ...

class EmployeeRepository:
    def save(self, employee): ...

class EmploymentReportGenerator:
    def generate(self, employee): ...

2. Open/Closed Principle (OCP)

The Definition: Software entities should be open for extension, but closed for modification.

You should be able to add new functionality to a system without modifying existing, tested code. If you find yourself opening a switch statement to add a new case every time a new type is introduced, you are violating OCP.

❌ The Violation: The Brittle Switch Statement

class DiscountCalculator:
    def calculate(self, customer_type, price):
        if customer_type == "regular":
            return price * 0.9
        elif customer_type == "vip":
            return price * 0.8
        # Every new customer type requires modifying this class!

✅ The Fix: Polymorphism / Strategy Pattern

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def apply(self, price: float) -> float: pass

class RegularDiscount(DiscountStrategy):
    def apply(self, price): return price * 0.9

class VIPDiscount(DiscountStrategy):
    def apply(self, price): return price * 0.8

# Now, adding a "PremiumDiscount" means writing a NEW class.
# DiscountCalculator never changes.
class DiscountCalculator:
    def calculate(self, strategy: DiscountStrategy, price: float) -> float:
        return strategy.apply(price)
graph TD
    Calculator[DiscountCalculator] -->|Depends on| Interface[DiscountStrategy]
    Interface --> Impl1[RegularDiscount]
    Interface --> Impl2[VIPDiscount]
    Interface --> Impl3[New PremiumDiscount: No existing code changed!]

    style Calculator fill:#bbf,stroke:#333
    style Interface fill:#ff9,stroke:#333

3. Liskov Substitution Principle (LSP)

The Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

If you replace a parent class with a child class, the program should still behave as expected. If a child class throws an exception or silently ignores a method it inherited, it violates LSP.

❌ The Violation: The Square/Rectangle Problem

class Rectangle:
    def set_width(self, w): self.width = w
    def set_height(self, h): self.height = h

class Square(Rectangle):
    def set_width(self, w):
        self.width = w
        self.height = w  # Forces height to match!

    def set_height(self, h):
        self.height = h
        self.width = h  # Forces width to match!

# Client code expects a Rectangle, but gets a Square
def resize_shape(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.width == 5   # BOOM! Fails if rect is a Square (width becomes 10)

✅ The Fix: Redesign the Hierarchy A Square isn't a Rectangle that behaves differently; they are both just Shapes.

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: pass

class Rectangle(Shape):
    def area(self): return self.width * self.height

class Square(Shape):
    def area(self): return self.side ** 2

4. Interface Segregation Principle (ISP)

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

Fat interfaces are a nightmare. If an interface has 10 methods, but a specific implementing class only cares about 2, it is forced to implement stub methods or throw NotImplementedException.

❌ The Violation: The Monolithic Interface

from abc import ABC, abstractmethod

class Machine(ABC):
    @abstractmethod
    def print(self, doc): pass

    @abstractmethod
    def scan(self, doc): pass

    @abstractmethod
    def fax(self, doc): pass

class OldPrinter(Machine):
    def print(self, doc): # Actually prints
        pass

    def scan(self, doc): raise NotImplementedError("I can't scan!")
    def fax(self, doc): raise NotImplementedError("I can't fax!")

✅ The Fix: Split into Focused Interfaces

class Printer(ABC):
    @abstractmethod
    def print(self, doc): pass

class Scanner(ABC):
    @abstractmethod
    def scan(self, doc): pass

class OldPrinter(Printer):
    def print(self, doc): # Clean!
        pass

class MultiFunctionDevice(Printer, Scanner):
    def print(self, doc): ...
    def scan(self, doc): ...

5. Dependency Inversion Principle (DIP)

The Definition: 1. High-level modules should not depend on low-level modules. Both should depend on abstractions. 2. Abstractions should not depend on details. Details should depend on abstractions.

The business logic (high-level) should not care about the database or UI (low-level). It should define an interface it needs, and the low-level module implements that interface. This is the foundation of Dependency Injection.

❌ The Violation: Hardcoded Dependency

class MySQLDatabase:
    def insert(self, data): ...

class OrderService:
    def __init__(self):
        # Tightly coupled to MySQL! What if we want Postgres for testing?
        self.db = MySQLDatabase() 

    def create_order(self, order):
        self.db.insert(order)

✅ The Fix: Depend on Abstractions

from abc import ABC, abstractmethod

class DatabaseInterface(ABC):
    @abstractmethod
    def insert(self, data): pass

class MySQLDatabase(DatabaseInterface):
    def insert(self, data): ...

class PostgresDatabase(DatabaseInterface):
    def insert(self, data): ...

class OrderService:
    # Dependency is injected via the constructor
    def __init__(self, db: DatabaseInterface):
        self.db = db 

    def create_order(self, order):
        self.db.insert(order)

# Now we can easily pass a Postgres DB, or a Mock DB for unit testing!
graph LR
    subgraph "Without DIP (Tight Coupling)"
        A1[OrderService] -->|Directly creates| B1[MySQLDatabase]
    end

    subgraph "With DIP (Loose Coupling)"
        A2[OrderService] -->|Depends on| C[DatabaseInterface]
        D1[MySQLDatabase] -->|Implements| C
        D2[PostgresDatabase] -->|Implements| C
    end

    style A1 fill:#f96,stroke:#333
    style A2 fill:#4CAF50,color:#fff
    style C fill:#ff9,stroke:#333

Conclusion: SOLID is a Compass, Not a Lawbook

If you try to apply all five SOLID principles to every single class you write on day one of a project, you will suffer from analysis paralysis and over-engineering.

SOLID is not a starting point; it is a target. Start with the simplest code that works. When you feel the pain—when adding a feature requires touching 10 files, or when a mock object forces you to implement empty methods—that is when you reach for SOLID to refactor your way out of the corner.

  • SRP keeps your classes focused and easy to merge.
  • OCP protects your existing code from regressions.
  • LSP ensures your polymorphism actually works.
  • ISP prevents your interfaces from becoming bloated contracts.
  • DIP decouples your system so you can swap databases, frameworks, and test in peace.

Master these principles, and you will write code that not only works today, but welcomes the changes of tomorrow.