SOLID Principles in Python

advanced SOLID SRP OCP LSP ISP DIP design-principles

SOLID is a set of five design principles that help us write code that’s easier to maintain, extend, and test. They were coined for statically typed OOP languages, but they apply just as well to Python — we just implement them more idiomatically using duck typing, protocols, and first-class functions.

S — Single Responsibility Principle

A class should have only one reason to change. Each class does one thing, and does it well.

Bad — one class doing too many things:

class UserManager:
    def create_user(self, name, email):
        # validates, saves to DB, AND sends email — three responsibilities
        if "@" not in email:
            raise ValueError("Invalid email")
        self._save_to_db(name, email)
        self._send_welcome_email(email)

Good — separate concerns into focused classes:

class UserValidator:
    def validate(self, email: str) -> bool:
        return "@" in email

class UserRepository:
    def save(self, name: str, email: str) -> None:
        print(f"Saved {name} to DB")  # database logic only

class EmailService:
    def send_welcome(self, email: str) -> None:
        print(f"Welcome email sent to {email}")  # email logic only

Now if the email provider changes, we only touch EmailService. If the database changes, we only touch UserRepository. Each class has one reason to change.

O — Open/Closed Principle

Open for extension, closed for modification. We should be able to add new behavior without changing existing code.

Bad — modifying the class every time we add a new format:

class ReportExporter:
    def export(self, data, format):
        if format == "json":
            return json.dumps(data)
        elif format == "csv":
            return ",".join(data)
        # every new format = another elif here

Good — use a Protocol (or ABC) so new formats are new classes:

from typing import Protocol

class Exporter(Protocol):
    def export(self, data: list) -> str: ...

class JsonExporter:
    def export(self, data: list) -> str:
        import json
        return json.dumps(data)

class CsvExporter:
    def export(self, data: list) -> str:
        return ",".join(str(item) for item in data)

def generate_report(data: list, exporter: Exporter) -> str:
    return exporter.export(data)  # works with ANY exporter

Adding XML export? Just create XmlExporter. We never touch existing code.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types without breaking anything. If our code works with a Bird, it should work with any subclass of Bird too.

Bad — subclass breaks the parent’s contract:

class Bird:
    def fly(self) -> str:
        return "flying"

class Penguin(Bird):
    def fly(self) -> str:
        raise NotImplementedError("Penguins can't fly!")  # breaks the contract

Any code that calls bird.fly() will blow up if it gets a Penguin. That’s a Liskov violation.

Good — restructure so the contract holds:

from typing import Protocol

class Bird(Protocol):
    def move(self) -> str: ...

class Sparrow:
    def move(self) -> str:
        return "flying through the air"

class Penguin:
    def move(self) -> str:
        return "swimming through the water"

def travel(bird: Bird) -> None:
    print(bird.move())  # works for ALL birds — no surprises

travel(Sparrow())  # flying through the air
travel(Penguin())  # swimming through the water

The key insight: if a subclass can’t fully honor the parent’s behavior, the hierarchy is wrong. Fix the abstraction, not the subclass.

I — Interface Segregation Principle

Don’t force classes to implement methods they don’t use. Keep interfaces small and focused.

Bad — one fat interface:

from abc import ABC, abstractmethod

class Worker(ABC):
    @abstractmethod
    def code(self) -> None: ...

    @abstractmethod
    def test(self) -> None: ...

    @abstractmethod
    def design(self) -> None: ...

class BackendDev(Worker):
    def code(self): print("coding")
    def test(self): print("testing")
    def design(self): pass  # forced to implement something irrelevant

Good — small, focused protocols:

from typing import Protocol

class Coder(Protocol):
    def code(self) -> None: ...

class Tester(Protocol):
    def test(self) -> None: ...

class Designer(Protocol):
    def design(self) -> None: ...

class BackendDev:
    def code(self): print("coding")
    def test(self): print("testing")
    # no design() — and that's perfectly fine

class UiDesigner:
    def design(self): print("designing")
    # no code() or test() — also fine

def run_tests(tester: Tester) -> None:
    tester.test()  # only needs what it actually uses

Each Protocol asks for exactly what it needs, nothing more. Classes implement only what makes sense for them.

D — Dependency Inversion Principle

Depend on abstractions, not concrete implementations. High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions (protocols).

Bad — high-level code depends on a concrete class:

class MySqlDatabase:
    def query(self, sql: str) -> list:
        return [{"id": 1}]  # MySQL-specific

class UserService:
    def __init__(self):
        self.db = MySqlDatabase()  # hardcoded dependency — can't swap

    def get_users(self) -> list:
        return self.db.query("SELECT * FROM users")

Good — depend on a Protocol and inject the dependency:

from typing import Protocol

class Database(Protocol):
    def query(self, sql: str) -> list: ...

class MySqlDatabase:
    def query(self, sql: str) -> list:
        return [{"id": 1}]

class UserService:
    def __init__(self, db: Database):  # accepts any Database
        self.db = db

    def get_users(self) -> list:
        return self.db.query("SELECT * FROM users")

# Production
service = UserService(MySqlDatabase())

# Testing — swap in a fake
class FakeDb:
    def query(self, sql: str) -> list:
        return [{"id": 99, "name": "test"}]

test_service = UserService(FakeDb())

Constructor injection + Protocol = easy to test, easy to swap implementations, and zero coupling to concrete classes.

SOLID in Python — The Pragmatic View

Python is more flexible than Java or C#, so we apply SOLID with some nuance:

  • SRP — use modules and functions, not just classes. A module can be a “unit of responsibility.”
  • OCP — first-class functions and duck typing often remove the need for elaborate class hierarchies.
  • LSP — duck typing makes this about behavioral contracts, not just type hierarchies.
  • ISP — Protocols are perfect for this. Small protocols > fat ABCs.
  • DIP — constructor injection with Protocol types. Python’s dynamic nature makes DI frameworks largely unnecessary.

In simple language, SOLID gives us five rules for writing maintainable OOP code. In Python, we implement them idiomatically: small classes and modules for SRP, Protocols for OCP/ISP/DIP, and well-designed abstractions for LSP. The goal isn’t to follow the letters religiously — it’s to write code that’s easy to change, test, and extend.