Cuando empiezas a trabajar en proyectos grandes en Python, una de las primeras cosas que notas es que el código se vuelve difícil de entender, probar y ampliar si no sigues unas cuantas reglas básicas de diseño. Ahí es donde entran en juego los famosos principios SOLID: una colección de buenas prácticas pensadas para hacer la vida del equipo mucho más sencilla.
Estos principios nacieron en el ámbito de la programación orientada a objetos clásica (Java, C++, C#, etc.), pero encajan perfectamente con Python siempre que uses clases y objetos de forma más o menos seria. Vamos a ver en detalle qué son, de dónde vienen, por qué importan y, sobre todo, cómo aplicar SOLID con ejemplos claros en Python para que tu código sea más mantenible, escalable y agradable de trabajar.
Qué es SOLID y de dónde sale todo esto
El término SOLID es un acrónimo popularizado por Michael Feathers para agrupar cinco principios de diseño propuestos originalmente por Robert C. Martin, más conocido como Uncle Bob. Este ingeniero de software estadounidense, uno de los firmantes del Manifiesto Ágil, publicó a mediados de los 90 el artículo “The Principles of OOD” y más tarde “Design Principles and Design Patterns”, donde sentó muchas de las bases del diseño orientado a objetos moderno.
Con el tiempo, otros autores como Barbara Liskov y Bertrand Meyer también aportaron ideas que se integraron en este conjunto de principios. Michael Feathers simplemente tuvo la ocurrencia (muy acertada) de reorganizarlos para que las iniciales formaran la palabra SOLID, lo que ayudó a que se difundieran como la pólvora en la comunidad de desarrollo.
Las cinco letras de SOLID corresponden a estos principios de diseño orientado a objetos, aplicables también a Python:
- S – Single Responsibility Principle (Principio de Responsabilidad Única)
- O – Open/Closed Principle (Principio de Abierto/Cerrado)
- L – Liskov Substitution Principle (Principio de Sustitución de Liskov)
- I – Interface Segregation Principle (Principio de Segregación de Interfaces)
- D – Dependency Inversion Principle (Principio de Inversión de Dependencias)
La idea general es que estos cinco principios, usados en conjunto, te ayudan a escribir software flexible, fácil de probar y de mantener. Eso se traduce en despliegues más ágiles, menos bugs misteriosos, mejor reutilización del código y menos dolor de cabeza cuando el proyecto lleva ya unos años en producción.
Para qué sirven los principios SOLID en Python
Aplicar SOLID en Python no es una cuestión académica; tiene un impacto directo en el día a día del equipo. Cuando respetas estos principios, reducen el código espagueti, disminuyen el code smell y evitan que tu base de código “huela a podrido”, usando el famoso símil de “si huele mal, es que algo está mal diseñado”. En Windows, muchos desarrolladores optan por instalar y configurar WSL2 para disponer de un entorno Linux más cercano a producción.
En entornos colaborativos (equipos de desarrollo backend, ingeniería de datos, productos con ciclos largos, etc.) estos principios son clave para que múltiples personas puedan tocar la misma base de código sin pisarse ni romperlo todo a la mínima. Además, Python, aunque flexible y dinámico, permite sin problemas aplicar abstracciones típicas de OOP: clases abstractas, jerarquías de herencia, composición, interfaces vía abc, etc.
En resumen, SOLID te ayuda a conseguir:
- Código más limpio y legible, incluso años después de haberlo escrito.
- Mejor capacidad de prueba (testabilidad), porque las responsabilidades están bien separadas.
- Alta reutilización y escalabilidad gracias a menos dependencias rígidas entre módulos.
- Menos errores colaterales: cuando cambias algo en un módulo, no rompes cinco cosas más sin querer.
S – Single Responsibility Principle (Responsabilidad Única)
El primer principio indica que una clase debe tener una sola razón para cambiar. Es decir, debe asumir una única responsabilidad bien definida. No significa que solo tenga un método, sino que toda su lógica apunte a un único propósito coherente.
Imagina una clase en Python que representa un usuario y, además de guardar sus datos, también se encarga de acceder a la base de datos y de generar informes:
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
Aquí la clase mezcla tres responsabilidades distintas: representar al usuario, gestionar la persistencia y construir informes. Cambios en la base de datos, en el formato del informe o en los atributos del usuario obligan a tocar la misma clase, aumentando el riesgo de introducir bugs cruzados.
Si separamos estas preocupaciones, el diseño mejora notablemente:
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}"
Ahora la clase User solo representa al usuario como entidad. Si cambia la forma de generar informes, solo tocas UserReportGenerator. Si cambias la base de datos, solo tocas UserDB. Cada clase tiene una única razón de cambio, lo que simplifica la depuración y la evolución del sistema.
SRP aplicado a un ejemplo más realista: patos y comunicación
Veamos un escenario clásico adaptado: una clase Duck a la que, de inicio, se le van añadiendo responsabilidades hasta que se convierte en un monstruo difícil de mantener. Imagina una implementación ingenua:
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}")
La clase debería definirse solo como “un pato”, pero además gestiona cómo se comunican entre sí. Si mañana cambias la lógica de la conversación (más frases, otros idiomas, distintos canales), tienes que tocar la clase del pato, que ya funciona bien como entidad.
La solución que respeta SRP es extraer esa segunda responsabilidad a otra clase especializada en comunicación:
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")
Gracias a esta separación, puedes evolucionar la lógica de comunicación sin tocar la definición del pato. Además, el código es más fácil de probar: pruebas por un lado el comportamiento de Duck y por otro el de Communicator, sin mezclar responsabilidades.
O – Open/Closed Principle (Abierto/Cerrado)
El principio OCP afirma que las entidades de software deben estar abiertas para extender su comportamiento, pero cerradas a modificaciones directas. Es decir, cuando quieres añadir una funcionalidad nueva, lo ideal es no tener que reescribir las clases que ya funcionan y que otros módulos utilizan.
Un ejemplo clásico es el cálculo de áreas de figuras geométricas. Veamos primero una versión que no respeta 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")
Si mañana quieres añadir un triángulo, te ves obligado a modificar el código de AreaCalculator, añadiendo otro elif. Esto viola OCP, porque la clase deja de estar “cerrada” a cambios.
La versión correcta consiste en introducir una abstracción Shape con un método area() que cada figura implementa a su manera:
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()
Gracias a este diseño, para añadir un triángulo no tocas AreaCalculator; simplemente creas una nueva subclase:
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
El principio Abierto/Cerrado encaja muy bien con la idea de definir puntos de extensión claros mediante abstracciones: interfaces, clases abstractas, hooks, etc. En Python, el módulo abc te permite expresar esto de forma explícita, incluso aunque el lenguaje sea dinámico.
OCP aplicado al ejemplo del comunicador
Si volvemos al ejemplo de Communicator, podemos ir un paso más allá y preparar el diseño para soportar distintos tipos de conversaciones sin reescribir el comunicador cada vez. Para ello definimos una abstracción de conversación y hacemos que el comunicador solo se encargue de usarla:
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")
En esta versión, si quieres añadir una nueva forma de conversación (por ejemplo, una conversación agresiva, una conversión con más turnos, etc.), solo creas otra subclase de AbstractConversation. El método communicate() de Communicator no cambia, cumpliendo OCP al pie de la letra.
L – Liskov Substitution Principle (Sustitución de Liskov)
El principio de Sustitución de Liskov, formulado por Barbara Liskov, dice que las subclases deben poder sustituir a sus clases base sin alterar el comportamiento esperado del programa. En la práctica, significa que si un código funciona con una instancia de la clase base, debería funcionar igual de bien con cualquier instancia de una subclase.
Un ejemplo típico de violación de LSP es modelar todas las aves con un método fly(), incluidos los avestruces:
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")
Cualquier código que asuma que toda ave puede volar fallará cuando reciba una avestruz. Es decir, Ostrich no es un sustituto válido de Bird, con lo que se viola LSP.
La solución es ajustar la jerarquía para reflejar mejor la realidad: no todas las aves vuelan, así que solo una parte de las aves debe tener el método 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
Con este diseño, cualquier función que necesite un pájaro volador declarará que requiere un FlyingBird, y nunca recibirá un avestruz. De esta manera se respeta LSP y se evitan excepciones inesperadas en tiempo de ejecución.
LSP y conversaciones entre aves
Volviendo al ejemplo de las conversaciones, es habitual empezar codificando pensando solo en patos y luego querer añadir cuervos u otras aves. Si la clase de conversación depende de Duck, no podrás reutilizarla con otros tipos de aves sin tocar el código:
class Crow:
# Implementación específica del cuervo
...
Si SimpleConversation está tipada solo para patos, no podrás pasarle un cuervo sin modificarla. Lo correcto es crear una abstracción común Bird y hacer que la conversación dependa de esa abstracción:
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
De esta manera, cualquier subclase de Bird que respete el contrato (do_sound(), nombre, etc.) es un sustituto válido y no romperá el comportamiento esperado de SimpleConversation.
I – Interface Segregation Principle (Segregación de Interfaces)
El principio ISP sostiene que ningún cliente debería verse obligado a depender de métodos que no usa. Traducido a clases abstractas o interfaces, significa que es mejor tener varias interfaces específicas y pequeñas que una sola interfaz enorme y genérica.
Observa este diseño en el que una interfaz Worker obliga a todos los que la implementan a tener métodos de trabajo y de comida:
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
La clase Robot depende de un método eat() que no necesita. Cualquier cambio relacionado con la comida afectará al robot, aunque no tenga nada que ver con ese comportamiento.
Aplicando ISP, dividimos la interfaz en dos más pequeñas y específicas:
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")
Ahora, cada clase solo implementa los métodos que realmente necesita. Esto reduce el acoplamiento, facilita la evolución del diseño y hace el código más expresivo: queda muy claro quién puede hacer qué.
ISP en el modelado de aves: volar y nadar
Algo parecido ocurre al modelar aves que vuelan y nadan. Si la abstracción base Bird obliga a implementar tanto fly() como swim(), terminarás con clases como Crow que tienen que fingir que saben nadar:
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
La solución siguiendo ISP es segregar la interfaz en capacidades más concretas:
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"
Si algún día decides modelar un pingüino, simplemente haces que herede de SwimmingBird pero no de FlyingBird, y no tendrás que implementar métodos vacíos ni lanzar excepciones artificiales.
D – Dependency Inversion Principle (Inversión de Dependencias)
El último principio, DIP, se resume en dos ideas clave: los módulos de alto nivel no deben depender de módulos de bajo nivel, ambos deben depender de abstracciones, y las abstracciones no deben depender de los detalles, sino los detalles de las abstracciones.
En la práctica, esto significa que tu lógica de negocio no debería estar atada a detalles concretos como “uso MySQL”, “escribo en un fichero local” o “envío SMS con este proveedor”. En su lugar, defines interfaces abstractas (por ejemplo, Database, Channel, NotificationService) y haces que tu código de alto nivel hable solo con ellas.
Un diseño que rompe DIP sería este repositorio de usuarios que instancia directamente una base de datos MySQL:
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")
Si mañana decides usar PostgreSQL, tienes que modificar la clase de alto nivel UserRepository. Estás acoplado a un detalle de implementación concreto.
Aplicando DIP, definimos primero una abstracción de base de datos y hacemos que las implementaciones concretas hereden de ella:
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")
De esta forma, puedes inyectar cualquier implementación de Database al crear el repositorio, sin tocar su código interno:
mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)
postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)
Este patrón se conoce como Inyección de Dependencias y es la forma más habitual de aplicar DIP: las clases no crean sus propias dependencias, sino que las reciben desde fuera (por el constructor o por métodos específicos), siempre usando abstracciones como tipo.
DIP aplicado a canales y comunicadores
En el ejemplo de las conversaciones entre aves, también podemos mejorar la gestión de canales aplicando DIP. Supón que defines una abstracción para el canal y otra para el comunicador:
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")
Una primera implementación ingenua podría ser:
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
Aunque parece correcto, este comunicador sigue acoplado directamente a SMSChannel. Mejoramos el diseño haciendo que el comunicador reciba el canal desde fuera (inyección de dependencias), y así depender solo de la abstracción:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel) -> None:
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
Con este enfoque, cualquier nuevo canal (correo, notificaciones push, etc.) implementa AbstractChannel y puede utilizarse sin cambiar el código del comunicador. De nuevo, las clases de alto nivel dependen de abstracciones, no de detalles.
Qué pasa cuando ignoras SOLID
Si estos principios no se tienen en cuenta, el código tiende a sufrir problemas como code smell, code rot y acoplamientos imposibles de desenredar. Es decir, clases enormes con mil responsabilidades, subclases que rompen contratos, dependencias cíclicas y métodos que cambian cada dos por tres porque están haciendo demasiadas cosas.
Las consecuencias son claras y bastante dolorosas para cualquier equipo: más vulnerabilidades, más bugs, refactorizaciones permanentes y, en el peor caso, código que termina siendo prácticamente inutilizable. Es lo que se suele llamar “código espagueti”, difícil de seguir, lleno de parches y casi imposible de extender sin romper algo importante.
Los principios SOLID no son leyes grabadas en piedra, y no siempre compensa aplicarlos todos a rajatabla, sobre todo en prototipos rápidos o proyectos muy pequeños. Aun así, tenerlos en mente y aplicarlos en la mayor parte del diseño orientado a objetos en Python marca la diferencia entre un proyecto que escala con el tiempo y otro que se desmorona en cuanto crece un poco.