SOLID Principles in Python — Deep Dive

Applying SOLID in real Python systems

SOLID principles shape how production Python code is structured, tested, and extended. This guide covers concrete implementation patterns, tradeoffs, and the Pythonic ways to honour each principle.

Single Responsibility with modules and functions

In Java, SRP is usually discussed at the class level. In Python, the natural unit of organization is often the module or even a standalone function.

# Bad: one class doing too much
class OrderProcessor:
    def validate(self, order): ...
    def calculate_tax(self, order): ...
    def charge_payment(self, order): ...
    def send_confirmation_email(self, order): ...
    def update_inventory(self, order): ...

# Better: separate concerns into focused modules
# validation.py
def validate_order(order: Order) -> ValidationResult: ...

# tax.py
def calculate_tax(order: Order, region: Region) -> Decimal: ...

# payment.py
def charge(order: Order, gateway: PaymentGateway) -> ChargeResult: ...

The processor class becomes a thin orchestrator that calls into focused modules. Each module has one reason to change — tax rules change tax.py, payment provider changes affect payment.py, and so on.

Tradeoff: Over-splitting creates navigation overhead. A good heuristic is that a module should fit a single mental context — roughly 200-400 lines.

Open/Closed with protocols and registries

Python 3.8+ Protocol classes enable structural subtyping, which is the most Pythonic way to achieve OCP.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Notifier(Protocol):
    def send(self, recipient: str, message: str) -> None: ...

class EmailNotifier:
    def send(self, recipient: str, message: str) -> None:
        # SMTP logic
        ...

class SlackNotifier:
    def send(self, recipient: str, message: str) -> None:
        # Webhook logic
        ...

# Registry pattern — add new notifiers without modifying existing code
_registry: dict[str, type[Notifier]] = {}

def register_notifier(name: str, cls: type[Notifier]) -> None:
    _registry[name] = cls

def get_notifier(name: str) -> Notifier:
    return _registry[name]()

Adding SMS support means writing SmsNotifier and calling register_notifier("sms", SmsNotifier). No existing code changes.

When to break OCP deliberately: Prototyping and rapid iteration. Premature abstraction is worse than a few if branches that you plan to refactor once requirements stabilize.

Liskov Substitution in practice

LSP violations in Python are subtle because the language does not enforce return types at runtime.

class Cache:
    def get(self, key: str) -> str | None:
        ...

class TypedCache(Cache):
    def get(self, key: str) -> str:  # Never returns None
        result = super().get(key)
        if result is None:
            raise KeyError(key)
        return result

TypedCache violates LSP: callers expecting None on a miss will get an exception instead. The fix is to avoid inheritance here and use separate types, or keep the contract consistent.

Covariance rule: A subclass can return a narrower type (fine), but must accept the same or wider input types. Raising unexpected exceptions counts as narrowing the output contract.

Testing for LSP compliance

Write a shared test suite parameterized over implementations:

import pytest

@pytest.fixture(params=[MemoryCache, RedisCache, TypedCache])
def cache(request):
    return request.param()

def test_get_missing_key_returns_none(cache):
    assert cache.get("nonexistent") is None

Any implementation that fails this shared test violates LSP.

Interface Segregation with granular protocols

Fat abstract base classes are the main ISP antipattern in Python.

# ISP violation — forces implementors to define methods they don't need
class Repository(ABC):
    @abstractmethod
    def get(self, id: str): ...
    @abstractmethod
    def save(self, entity): ...
    @abstractmethod
    def delete(self, id: str): ...
    @abstractmethod
    def bulk_import(self, entities: list): ...
    @abstractmethod
    def generate_report(self): ...

# ISP-compliant — small, focused protocols
class Readable(Protocol):
    def get(self, id: str) -> Entity | None: ...

class Writable(Protocol):
    def save(self, entity: Entity) -> None: ...

class Deletable(Protocol):
    def delete(self, id: str) -> None: ...

Functions declare exactly which capabilities they need:

def display_entity(repo: Readable, entity_id: str) -> None:
    entity = repo.get(entity_id)
    ...

This function works with any object that has a get method — no unnecessary coupling to write or delete capabilities.

Dependency Inversion with constructor injection

Python does not have a built-in DI container, but constructor injection is straightforward.

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        payment: PaymentGateway,
        notifier: Notifier,
    ) -> None:
        self._repo = repo
        self._payment = payment
        self._notifier = notifier

    def place_order(self, order: Order) -> None:
        self._repo.save(order)
        self._payment.charge(order.total)
        self._notifier.send(order.customer_email, "Order confirmed")

For wiring dependencies at the application boundary, a simple create_app() factory works well for most projects. Libraries like dependency-injector or punq add container-managed scoping if your system grows large enough.

def create_order_service() -> OrderService:
    return OrderService(
        repo=PostgresOrderRepo(get_db_session()),
        payment=StripeGateway(settings.stripe_key),
        notifier=EmailNotifier(settings.smtp_host),
    )

Testing benefit: Replace any dependency with a fake in tests without monkeypatching.

def test_place_order():
    service = OrderService(
        repo=FakeOrderRepo(),
        payment=FakePayment(),
        notifier=FakeNotifier(),
    )
    service.place_order(sample_order)
    assert service._repo.saved[-1] == sample_order

Tradeoffs and practical guidelines

PrinciplePython strengthWatch out for
SRPModules + functions are natural boundariesOver-splitting into too many tiny files
OCPProtocols and registries are lightweightPremature abstraction before requirements settle
LSPEasy to violate due to dynamic typingShared parameterized tests catch violations
ISPProtocols support granular interfacesMultiple tiny protocols can confuse newcomers
DIPConstructor injection is simple and explicitAvoid heavyweight DI frameworks unless genuinely needed

When to relax SOLID

  • Scripts and one-off tools: A 100-line data munging script does not need protocols and registries.
  • Early prototypes: Lock down architecture after the domain is understood, not before.
  • Performance-critical paths: Sometimes inlining dependencies is faster than indirection through protocols.

The goal is not perfect adherence but deliberate choice. Know the rules, know when bending them is the right call, and document why.

The one thing to remember: SOLID principles in Python work best when expressed through protocols, constructor injection, and small modules — not through deep class hierarchies borrowed from Java.

pythonarchitectureclean-code

See Also