Apply SOLID Principles in Python

Learn how to apply the five SOLID design principles in Python with practical examples that make your code more maintainable and flexible.

8 min read

SOLID is an acronym for five design principles that help you write code that is easier to maintain, test, and extend. The principles were collected by Robert C. Martin and apply to object-oriented programming in any language, including Python.

Each principle addresses a specific kind of design problem. Together they guide you toward classes and modules that are focused, loosely coupled, and open to change without requiring rewrites. This article shows each principle with a Python example, building on the class design concepts from composition over inheritance in Python. The examples are deliberately small so the pattern is visible without domain-specific noise.

Single Responsibility Principle

A class should have one, and only one, reason to change. If a class handles file I/O, data validation, and report generation, a change to any of those responsibilities risks breaking the others.

Consider a report generator that reads data, validates it, formats it, and writes the output. Every change to the file format, validation rules, or output layout touches the same class:

pythonpython
class ReportGenerator:
    def generate(self, filepath):
        data = self._read_data(filepath)
        valid = self._validate(data)
        formatted = self._format(valid)
        self._write_output(formatted)

The fix splits each responsibility into its own class. Each class changes for exactly one reason, and each one can be tested and modified without touching the others:

pythonpython
class DataReader:
    def read(self, filepath):
        with open(filepath) as fh:
            return json.load(fh)
 
class DataValidator:
    def validate(self, data):
        return [entry for entry in data if entry.get("valid")]
 
class ReportFormatter:
    def format(self, data):
        return "\n".join(str(item) for item in data)
 
class ReportWriter:
    def write(self, content, output_path):
        with open(output_path, "w") as fh:
            fh.write(content)

Now the main orchestrator composes these focused classes. A change to validation logic touches only DataValidator. A change to output format touches only ReportFormatter, the same composition approach covered earlier.

Open-Closed Principle

Classes should be open for extension but closed for modification. When you need new behavior, add new code rather than changing existing, working code.

A discount calculator that uses if-elif chains violates this principle. Adding a new discount type requires modifying the existing function:

pythonpython
def calculate_discount(order, discount_type):
    if discount_type == "percentage":
        return order.total * 0.1
    elif discount_type == "flat":
        return min(10, order.total)
    elif discount_type == "seasonal":
        return order.total * 0.15

Each new discount type forces a change to this function. The fix uses a dictionary or registry of discount strategies. New types are added without touching existing code:

pythonpython
from abc import ABC, abstractmethod
 
class DiscountStrategy(ABC):
    @abstractmethod
    def apply(self, order):
        pass
 
class PercentageDiscount(DiscountStrategy):
    def apply(self, order):
        return order.total * 0.1
 
class FlatDiscount(DiscountStrategy):
    def apply(self, order):
        return min(10, order.total)
 
discounts = {
    "percentage": PercentageDiscount(),
    "flat": FlatDiscount(),
}
 
def calculate_discount(order, discount_type):
    strategy = discounts.get(discount_type)
    return strategy.apply(order) if strategy else 0

Adding a seasonal discount now means creating a new class and registering it in the dictionary. The calculate_discount function never changes.

Liskov Substitution Principle

Subtypes must be substitutable for their base types without breaking the program. If code works with a base class, it must also work with any subclass without knowing the difference.

A classic violation is a Square class that inherits from Rectangle but changes the behavior of setting width and height. Code that expects a Rectangle breaks when given a Square:

pythonpython
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
 
class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)
 
    def __setattr__(self, name, value):
        super().__setattr__(name, value)
        if name in ("width", "height"):
            super().__setattr__("width", value)
            super().__setattr__("height", value)

A function that resizes a rectangle by doubling its width will produce unexpected results with a Square. The height also doubles, violating the caller's expectation. The fix is not to inherit: Square and Rectangle are separate shapes with separate interfaces. For more on this, see Python object-oriented programming explained.

Interface Segregation Principle

Clients should not depend on interfaces they do not use. In Python, this means keeping abstract base classes small and focused. A large interface forces every implementation to provide methods it may not need.

Instead of one monolithic Worker interface that requires both work and eat methods, split into focused protocols:

pythonpython
from typing import Protocol
 
class Workable(Protocol):
    def work(self) -> None:
        """Perform work."""
 
class Eatable(Protocol):
    def eat(self) -> None:
        """Consume food."""

A Robot implements Workable but not Eatable. A Human implements both. The caller that only needs work accepts a Workable and never asks for eat. This keeps interfaces small and classes free of unused methods.

Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. In Python, this means accepting dependencies through constructors instead of creating them inside the class.

A notification service that creates its own email sender is tightly coupled to that implementation:

pythonpython
class EmailSender:
    def send(self, to, subject, body):
        pass  # SMTP logic
 
class NotificationService:
    def __init__(self):
        self.sender = EmailSender()
 
    def notify(self, user, message):
        self.sender.send(user.email, "Notification", message)

The fix passes the sender through the constructor. The service depends on the concept of a sender, not a specific email implementation:

pythonpython
class NotificationService:
    def __init__(self, sender):
        self.sender = sender
 
    def notify(self, user, message):
        self.sender.send(user.email, "Notification", message)

In tests, you pass a mock sender. In production, you pass the real one.

To add SMS notifications later, create an SmsSender with the same send method and pass it to the service. The service code never changes. This pattern is covered in depth in Design Reusable Python Modules.

Applying SOLID pragmatically

SOLID principles are guidelines, not laws. Applying all five to a 50-line script is over-engineering. Start with single responsibility: if each class and function does one thing, the other principles often follow naturally.

When a class has multiple reasons to change, split it. When an if-elif chain grows with every feature, use a registry of strategies. When a class creates its own dependencies, pass them through the constructor. These three habits cover most real-world SOLID applications.

Rune AI

Rune AI

Key Insights

  • Single Responsibility: each class should have one reason to change.
  • Open-Closed: extend behavior through composition, not by modifying existing code.
  • Liskov Substitution: subclasses must be usable wherever the parent is expected.
  • Interface Segregation: keep interfaces small so clients only depend on what they use.
  • Dependency Inversion: depend on abstractions, not concrete implementations.
RunePowered by Rune AI

Frequently Asked Questions

Do SOLID principles apply to Python?

Yes. While SOLID originated in the Java community, the principles are language-agnostic. Python's dynamic nature makes some patterns simpler, but the underlying ideas about separation of concerns and loose coupling are just as valuable.

Which SOLID principle is most important in Python?

The Single Responsibility Principle is the most immediately useful. Keeping classes and functions focused on one job makes code easier to test, understand, and change. Most other SOLID principles flow naturally from getting this one right.

Conclusion

SOLID principles are not rules to memorize. They are guardrails that help you recognize when a design is becoming rigid. Apply them pragmatically: single responsibility and dependency inversion will improve most Python codebases immediately. The others matter more as systems grow.