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:
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:
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:
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.15Each 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:
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 0Adding 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:
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:
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:
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:
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
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.
Frequently Asked Questions
Do SOLID principles apply to Python?
Which SOLID principle is most important in Python?
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.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.