When you start working on large Python projects, one of the first things you notice is that The code becomes difficult to understand, test, and extend. If you don't follow a few basic design rules. That's where the famous SOLID principles come in: a collection of best practices designed to make the team's life much easier.
These principles originated in the field of classic object-oriented programming (Java, C++, C#, etc.)But they fit perfectly with Python as long as you use classes and objects in a more or less serious way. Let's look in detail at what they are, where they come from, why they matter, and, above all, how Apply SOLID with clear examples in Python to make your code more maintainable, scalable, and pleasant to work with.
What is SOLID and where does it all come from?
The term SOLID is an acronym popularized by Michael Feathers to group five design principles originally proposed by Robert C. Martin, better known as Uncle Bob. This American software engineer, one of the signatories of the Agile Manifesto, published the article "The Principles of OOD" in the mid-90s and later "Design Principles and Design Patterns," where he laid many of the foundations of modern object-oriented design.
Over time, other authors such as Barbara Liskov and Bertrand Meyer They also contributed ideas that were integrated into this set of principles. Michael Feathers simply had the (very astute) idea of rearranging them so that the initials formed the word SOLID, which helped them spread like wildfire in the development community.
The five letters of SOLID correspond to these object-oriented design principles, also applicable to Python:
- S – Single Responsibility Principle (Principle of Single Responsibility)
- O – Open/Closed Principle (Open/Closed Principle)
- L – Liskov Substitution Principle (Liskov Substitution Principle)
- I – Interface Segregation Principle (Principle of Interface Segregation)
- D – Dependency Inversion Principle (Principle of Dependency Reversal)
The general idea is that these five principles, used together, They help you write flexible, easy-to-test, and maintainable softwareThis translates into faster deployments, fewer mysterious bugs, better code reuse, and fewer headaches when the project has been in production for a few years.
What are the SOLID principles used for in Python?
Applying SOLID principles in Python isn't just an academic exercise; it has a direct impact on the team's daily work. When you adhere to these principles, They reduce spaghetti code, decrease code smell, and prevent your codebase from "smelling rotten."using the famous analogy, “if it smells bad, something is poorly designed.” In Windows, many developers choose to Install and configure WSL2 to have a Linux environment closer to production.
In collaborative environments (backend development teams, data engineering, products with long cycles, etc.) these principles are key to multiple people can work on the same codebase without overstepping their bounds or breaking everything at the slightest touch.Furthermore, Python, although flexible and dynamic, allows for the seamless application of typical OOP abstractions: abstract classes, inheritance hierarchies, composition, and interfaces via abc, etc.
In summary, SOLID helps you achieve:
- Cleaner, more readable codeeven years after writing it.
- Better testabilitybecause responsibilities are clearly separated.
- High reusability and scalability thanks to fewer rigid dependencies between modules.
- Fewer collateral errorsWhen you change something in one module, you don't accidentally break five other things.
S – Single Responsibility Principle
The first principle states that A class should have only one reason to change.In other words, it must assume a single, well-defined responsibility. This doesn't mean having only one method, but rather that all its logic should point toward a single, coherent purpose.
Imagine a Python class that represents a user and, in addition to storing their data, also handles accessing the database and generating reports:
class User:
def __init__(self, name: str):
self.name = name
def get_user_from_database(self, user_id: int) -> dict:
# Recupera datos desde la base de datos
# ...
pass
def save_user_to_database(self) -> None:
# Persiste el usuario en la base de datos
# ...
pass
def generate_user_report(self) -> str:
# Genera un informe del usuario
# ...
pass
Here's the class mixes three distinct responsibilitiesRepresenting the user, managing persistence, and building reports. Changes to the database, report format, or user attributes require modifying the same class, increasing the risk of introducing cross-cutting bugs.
If we separate these concerns, the design improves significantly:
class User:
def __init__(self, name: str):
self.name = name
class UserDB:
@staticmethod
def get_user(user_id: int) -> User:
# Lógica para obtener usuarios de la base de datos
# ...
return User("John Doe")
@staticmethod
def save_user(user: User) -> None:
# Lógica para guardar el usuario
# ...
pass
class UserReportGenerator:
@staticmethod
def generate_report(user: User) -> str:
# Lógica para generar informes de usuario
# ...
return f"Report for user: {user.name}"
Now the class User only represents the user as an entityIf the way reports are generated changes, just tap UserReportGeneratorIf you change the database, you just touch UserDBEach class has a single reason for change, which simplifies debugging and system evolution.
SRP applied to a more realistic example: ducks and communication
Let's look at a classic scenario adapted: a class Duck To which, initially, responsibilities are gradually added until it becomes a monster that's difficult to maintain. Imagine a naive implementation:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
def greet(self, other_duck: "Duck") -> None:
print(f"{self.name}: {self.do_sound()}, hello {other_duck.name}")
Class It should be defined simply as “a duck”But it also manages how they communicate with each other. If tomorrow you change the logic of the conversation (more phrases, other languages, different channels), you have to modify the duck class, which already functions well as an entity.
The solution that respects SRP is to extract that second responsibility from another class specializing in communication:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
class Communicator:
def __init__(self, channel: str):
self.channel = channel
def communicate(self, duck1: Duck, duck2: Duck) -> None:
sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
conversation =
print(*conversation, f"(via {self.channel})", sep="\n")
Thanks to this separation, You can evolve the communication logic without touching the definition of the duckFurthermore, the code is easier to test: you test the behavior of Duck and on the other hand, the one of Communicatorwithout mixing responsibilities.
O – Open/Closed Principle
The OCP principle states that Software entities should be open to extending their behavior, but closed to direct modifications.In other words, when you want to add new functionality, ideally you shouldn't have to rewrite classes that already work and are used by other modules.
A classic example is calculating the areas of geometric figures. Let's first look at a version that does not respect OCP:
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Circle:
def __init__(self, radius: float):
self.radius = radius
class AreaCalculator:
def calculate_area(self, shape) -> float:
if isinstance(shape, Rectangle):
return shape.width * shape.height
elif isinstance(shape, Circle):
return 3.14159 * shape.radius * shape.radius
else:
raise ValueError("Forma no soportada")
If you want to add a triangle tomorrow, you'll be forced to modify the code of AreaCalculatoradding another elifThis violates OCP, because the class is no longer "closed" to changes.
The correct version involves introducing an abstraction Shape with a method area() which each figure implements in their own way:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius * self.radius
class AreaCalculator:
def calculate_area(self, shape: Shape) -> float:
return shape.area()
Thanks to this design, for add a triangle you don't touch AreaCalculatorYou simply create a new subclass:
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
The Open/Closed principle fits very well with the idea of define clear extension points through abstractions: interfaces, abstract classes, hooks, etc. In Python, the module abc It allows you to express this explicitly, even if the language is dynamic.
OCP applied to the communicator example
If we go back to the example of CommunicatorWe can go a step further and prepare the design to support different types of conversations without rewriting the communicator each time. To do this, we define a conversation abstraction and have the communicator only use it:
from typing import final
from abc import ABC, abstractmethod
class AbstractConversation(ABC):
@abstractmethod
def do_conversation(self) -> list:
pass
class SimpleConversation(AbstractConversation):
def __init__(self, duck1: Duck, duck2: Duck):
self.duck1 = duck1
self.duck2 = duck2
def do_conversation(self) -> list:
sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
return
class Communicator:
def __init__(self, channel: str):
self.channel = channel
@final
def communicate(self, conversation: AbstractConversation) -> None:
print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")
In this version, If you want to add a new way of talking (for example, an aggressive conversation, a turn-taking conversation, etc.), you just create another subclass of AbstractConversation. The method communicate() de Communicator It does not change, complying with OCP to the letter.
L – Liskov Substitution Principle
The Liskov Substitution Principle, formulated by Barbara Liskov, states that Subclasses should be able to replace their base classes without altering the expected behavior of the program.In practice, this means that if code works with one instance of the base class, it should work just as well with any instance of a subclass.
A typical example of LSP violation is modeling all birds with one method fly()including ostriches:
class Bird:
def fly(self) -> None:
pass
class Duck(Bird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
def fly(self) -> None:
# Las avestruces no vuelan
raise NotImplementedError("Las avestruces no pueden volar")
Any code that assumes that Every bird that can fly will fail when it receives an ostrich. That is, Ostrich It is not a valid substitute for Bird, thereby violating LSP.
The solution is to adjust the hierarchy to better reflect reality: not all birds fly, so Only a portion of the birds should have the method fly():
class Bird:
pass
class FlyingBird(Bird):
def fly(self) -> None:
pass
class Duck(FlyingBird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
# No vuela, así que no implementa fly()
pass
With this design, Any function that requires a flying bird will declare that it requires one. FlyingBirdand will never receive an ostrich. This way, LSP is respected and unexpected runtime exceptions are avoided.
LSP and bird conversations
Returning to the example of conversations, it's common to start coding thinking only about ducks and then want to add crows or other birds. If the conversation class depends on Duck, You will not be able to reuse it with other types of birds without touching the code:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation It's typed only for ducks; you can't just pass a crow through without modifying it. The correct approach is to create a common abstraction. Bird and make the conversation depend on that abstraction:
from abc import ABC, abstractmethod
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def do_sound(self) -> str:
pass
class Crow(Bird):
def do_sound(self) -> str:
return "Caw"
class Duck(Bird):
def do_sound(self) -> str:
return "Quack"
class SimpleConversation(AbstractConversation):
def __init__(self, bird1: Bird, bird2: Bird):
self.bird1 = bird1
self.bird2 = bird2
def do_conversation(self) -> list:
sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
return
In this way, any subclass of Bird that respects the contract (do_sound()(name, etc.) is a valid substitute and will not break the expected behavior of SimpleConversation.
I – Interface Segregation Principle
The ISP principle holds that No customer should be forced to rely on methods they do not use.Translated into abstract classes or interfaces, this means that it is better to have several small, specific interfaces than one huge, generic interface.
Observe this design in which an interface Worker It requires all those who implement it to have specific work and eating methods:
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self) -> None:
pass
@abstractmethod
def eat(self) -> None:
pass
class Human(Worker):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Worker):
def work(self) -> None:
print("El robot está trabajando")
def eat(self) -> None:
# El robot no come, pero está obligado a declarar este método
pass
Class Robot relies on a method eat() that does not needAny change related to food will affect the robot, even if it has nothing to do with that behavior.
By applying ISP, we divided the interface into two smaller, more specific ones:
class Workable(ABC):
@abstractmethod
def work(self) -> None:
pass
class Eatable(ABC):
@abstractmethod
def eat(self) -> None:
pass
class Human(Workable, Eatable):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Workable):
def work(self) -> None:
print("El robot está trabajando")
Now Each class only implements the methods it actually needs.This reduces coupling, facilitates design evolution, and makes the code more expressive: it becomes very clear who can do what.
ISP in bird modeling: flying and swimming
Something similar happens when modeling birds that fly and swim. If the basic abstraction Bird It requires implementing both fly() , the swim()You'll end up with classes like Crow who have to pretend they know how to swim:
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def fly(self) -> None:
pass
@abstractmethod
def swim(self) -> None:
pass
@abstractmethod
def do_sound(self) -> str:
pass
The solution according to the ISP is segregate the interface into more specific capabilities:
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def do_sound(self) -> str:
pass
class FlyingBird(Bird):
@abstractmethod
def fly(self) -> None:
pass
class SwimmingBird(Bird):
@abstractmethod
def swim(self) -> None:
pass
class Crow(FlyingBird):
def fly(self) -> None:
print(f"{self.name} is flying high and fast!")
def do_sound(self) -> str:
return "Caw"
class Duck(SwimmingBird, FlyingBird):
def fly(self) -> None:
print(f"{self.name} is flying not very high")
def swim(self) -> None:
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
If you ever decide to model a penguin, simply you make him inherit from SwimmingBird but not from FlyingBirdAnd you won't have to implement empty methods or throw artificial exceptions.
D – Dependency Inversion Principle
The last principle, DIP, can be summarized in two key ideas: High-level modules should not depend on low-level modules; both should depend on abstractions.And abstractions should not depend on details, but rather details should depend on abstractions.
In practice, this means your business logic shouldn't be tied to specific details like "I use MySQL," "I write to a local file," or "I send SMS messages with this provider." Instead, you define abstract interfaces (for example, Database, Channel, NotificationService) and you make your high-level code talk only to them.
A design that break DIP This would be a user repository that directly instantiates a MySQL database:
class MySQLDatabase:
def connect(self) -> None:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
# Ejecutar consulta
return []
class UserRepository:
def __init__(self) -> None:
self.database = MySQLDatabase() # Dependencia directa
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
If you decide to use PostgreSQL tomorrow, you have to modify the high-level class UserRepositoryYou are tied to a specific implementation detail.
By applying DIP, we first define a database abstraction and then have the concrete implementations inherit from it:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self) -> None:
pass
@abstractmethod
def query(self, sql: str) -> list:
pass
class MySQLDatabase(Database):
def connect(self) -> None:
# Conexión a MySQL
pass
def query(self, sql: str) -> list:
# Consulta en MySQL
return []
class PostgreSQLDatabase(Database):
def connect(self) -> None:
# Conexión a PostgreSQL
pass
def query(self, sql: str) -> list:
# Consulta en PostgreSQL
return []
class UserRepository:
def __init__(self, database: Database) -> None:
self.database = database # Depende de una abstracción
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
Thus, You can inject any implementation of Database when creating the repository, without touching its internal code:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
This pattern is known as Dependency Injection And it is the most common way to apply DIP: classes do not create their own dependencies, but receive them from outside (through the constructor or through specific methods), always using abstractions as a type.
DIP applied to channels and communicators
In the example of bird conversations, we can also improve channel management by applying DIP. Suppose you define one abstraction for the channel and another for the communicator:
class AbstractChannel(ABC):
@abstractmethod
def get_channel_message(self) -> str:
pass
class AbstractCommunicator(ABC):
@abstractmethod
def get_channel(self) -> AbstractChannel:
pass
@final
def communicate(self, conversation: AbstractConversation) -> None:
print(*conversation.do_conversation(),
self.get_channel().get_channel_message(),
sep="\n")
A first, naive implementation could be:
class SMSChannel(AbstractChannel):
def get_channel_message(self) -> str:
return "(via SMS)"
class SMSCommunicator(AbstractCommunicator):
def __init__(self) -> None:
self._channel = SMSChannel() # Depende de detalle concreto
def get_channel(self) -> AbstractChannel:
return self._channel
Although it seems correct, This communicator is still directly coupled to SMSChannelWe improved the design by having the communicator receive the channel from outside (dependency injection), and thus depend only on the abstraction:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
With this approach, any new channel (email, push notifications, etc.) implements AbstractChannel y It can be used without changing the communicator code.Again, high-level classes depend on abstractions, not details.
What happens when you ignore SOLID?
If these principles are not taken into account, the code tends to suffer from problems such as code smell, code rot, and couplings impossible to untangleThat is, huge classes with a thousand responsibilities, subclasses that break contracts, cyclical dependencies, and methods that change every other day because they are doing too many things.
The consequences are clear and quite painful for any team: More vulnerabilities, more bugs, constant refactoring, and, in the worst case, code that ends up being practically unusable.It's what's commonly called "spaghetti code": difficult to follow, full of patches, and almost impossible to extend without breaking something important.
The SOLID principles are not set in stone, and it's not always worthwhile to apply them all rigidly, especially in rapid prototyping or very small projects. Even so, Keep them in mind and apply them to most of your object-oriented design in Python. It makes the difference between a project that scales over time and one that crumbles as soon as it grows a little.