Software Crafters® 2025 | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español | Legal
En esta entrada vamos a ver los 5 principios S.O.L.I.D. de diseño de software orientado a objetos, pero adaptados al lenguaje de programación Python. Cabe destacar que dichos principios también pueden ser aplicados al desarrollo con otros lenguajes de programación como podrían ser Java, C++, PHP, etc.
Explicaremos en profundidad cada uno de los cinco principios, que representan cinco buenas prácticas o pautas que deberías seguir para mejorar tus desarrollos de software. Tras numerosos estudios psicológicos, se ha determinado que seguir las reglas S.O.L.I.D. hará que seas más feliz en tu puesto de trabajo, tendrás más tiempo para tomar café, y quien sabe, hasta puede que te suban el sueldo. Yo personalmente emplearía mucha cautela a la hora de seguir estas reglas, porque podrías terminar siendo un experto en diseño de software, y eso conlleva una gran responsabilidad.
Los principios están pensados para trabajar conjuntamente como un todo, con el objetivo de conseguir un código más limpio y sencillo de mantener, manteniendo un code smell (término proveniente de la expresión "si huele mal, es que está podrido", aplicado al software) que no ahuyente a tus compañeros de trabajo.
El origen de estos principios tuvo lugar hacen unas décadas (en 1995), gracias a un respetado y reconocido ingeniero de software estadounidense llamado Robert Cecil Martin, también conocido como "Uncle Bob" (tío Bob), quien fue el que publicó un artículo denominado The Principles of OOD (principios del diseño orientados a objetos).
Pero no fue hasta algunos años más tarde cuando Michael Feathers, otro ingeniero informático, recomendó el acrónimo "SOLID" para representar los cinco principios de los que vamos a hablar.
Como he mencionado al comienzo del artículo, S.O.L.I.D. es la sigla mnemotécnica que hace referencia a 5 principios del diseño orientado a objetos, útiles sobre todo a la hora de crear software que sea fácil de mantener, extender y entender. En definitiva, nos permiten reducir problemas comunes que se pueden encontrar durante el desarrollo de una aplicación.
A continuación, veremos en detalle cada uno de los cinco principios, junto con ejemplos de código en Python. Aunque estos ejemplos serán básicos y estarán muy focalizados en explicar el principio, serán suficientemente ilustrativos para que comprendamos su aplicación.
Una clase debe tener una y solo una razón para cambiar, lo que significa que una clase debe tener un único trabajo.
De manera más formal, este principio dice que "ninguna clase debería tener más de un único motivo para cambiar". En otras palabras, cada clase debería representar una entidad concreta, ya sea un elemento de negocio como un ticket, un usuario, una cuenta bancaria, etc., o un servicio o helper como un formatter, un convertidor, un validador, un creador, etc.
Si una clase cumple más de un propósito, se dice que no está "centrada", lo que la hace más frágil y susceptible a cambios. Por ejemplo, si una clase
User
es la que almacena toda la información personal de cada usuario, pero también se encarga de las conexiones a la base de datos para recuperar los datos del usuario, tendríamos una clase que está haciendo más de un único trabajo.
Esto es un problema porque si cambia el motor de persistencia, tendremos que cambiar la clase de usuario. Si la clase de usuario cambia, digamos que necesita otros atributos, tendremos que modificar la clase a pesar de que la nueva información nada tiene que ver con las conexiones a la base de datos.
Un segundo beneficio de seguir este principio es que permite evitar situaciones en las que corregir un error en una funcionalidad de tu aplicación introduce un nuevo error en otra funcionalidad que dependía del código modificado.
En Python, podemos seguir este principio dividiendo una clase monolítica en varias, donde cada una tenga una tarea específica bien definida. A continuación tienes un ejemplo de cómo aplicarlo:
Mal ejemplo (no sigue SRP):
class User: def __init__(self, name: str): self.name = name def get_user_from_database(self, user_id: int) -> dict: # Hace una tarea diferente: obtiene de la base de datos # ... pass def save_user_to_database(self) -> None: # Hace una tarea diferente: guarda en la base de datos # ... pass def generate_user_report(self) -> str: # Hace una tarea diferente: genera un informe # ... pass
Este código viola el principio SRP porque la clase
User
tiene varias razones para cambiar:
Buen ejemplo (siguiendo SRP):
class User: def __init__(self, name: str): self.name = name class UserDB: @staticmethod def get_user(user_id: int) -> User: # Obtiene usuarios de la base de datos # ... return User("John Doe") @staticmethod def save_user(user: User) -> None: # Guarda usuario en la base de datos # ... pass class UserReportGenerator: @staticmethod def generate_report(user: User) -> str: # Genera informe de usuario # ... return f"Report for user: {user.name}"
Hemos dividido las responsabilidades en tres clases, cada una con una única razón para cambiar. Ahora, si cambia la forma de generar informes, solo necesitaremos modificar
UserReportGenerator
, sin afectar a las demás clases.
Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para su extensión, pero cerradas para su modificación.
Este principio establece que las clases deben estar diseñadas de tal manera que se puedan extender fácilmente (añadiendo nuevas funcionalidades), sin tener que modificar su código fuente.
Supongamos que tenemos una clase que calcula el área de diferentes formas geométricas:
Mal ejemplo (no sigue 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 # Si añadimos una nueva forma, debemos modificar esta clase # ... else: raise ValueError("Forma no soportada")
En este ejemplo, si quisiéramos añadir una nueva forma geométrica (por ejemplo, un triángulo), tendríamos que modificar la clase
AreaCalculator
, lo que viola el principio OCP.
Buen ejemplo (siguiendo OCP):
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()
Ahora, si queremos añadir una nueva forma, simplemente creamos una nueva clase que herede de
Shape
e implemente el método area()
. La clase AreaCalculator
no necesita ser modificada, ya que trabaja con la abstracción Shape
.
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 # No es necesario modificar AreaCalculator
Las clases derivadas deben poder sustituir a sus clases base sin afectar el comportamiento del programa.
Este principio, formulado por Barbara Liskov en 1987, establece que si un programa utiliza un objeto de una clase base, debería poder usar cualquier objeto de una clase derivada sin afectar la funcionalidad del programa.
En otras palabras, las clases derivadas deben respetar el contrato establecido por la clase base. Por ejemplo, si la clase base establece que un método devuelve un número positivo, la clase derivada no debería devolver un número negativo.
Mal ejemplo (no sigue LSP):
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: # Problema: los avestruces no pueden volar raise NotImplementedError("Las avestruces no pueden volar")
En este ejemplo, el método
fly()
en la clase Ostrich
viola el principio de Liskov, ya que no puede ser utilizado como un sustituto de la clase base Bird
. Si algún código espera un objeto Bird
que pueda volar, un Ostrich
causará una excepción.
Buen ejemplo (siguiendo LSP):
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 implementa fly() porque no es una ave voladora pass
Ahora,
Ostrich
es una subclase de Bird
, pero no de FlyingBird
, lo que refleja mejor la realidad y respeta el principio de Liskov.
Un cliente no debe ser forzado a depender de interfaces que no utiliza.
Este principio sugiere que es mejor tener muchas interfaces específicas que una interfaz de propósito general. En otras palabras, las clases no deberían verse obligadas a implementar métodos que no utilizan.
Mal ejemplo (no sigue ISP):
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: # Los robots no comen, pero están obligados a implementar este método pass
En este ejemplo,
Robot
se ve obligado a implementar el método eat()
que no necesita, lo que viola el principio ISP.
Buen ejemplo (siguiendo ISP):
from abc import ABC, abstractmethod 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,
Robot
solo implementa la interfaz Workable
y no tiene que preocuparse por el método eat()
que no necesita.
Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones. Además, las abstracciones no deben depender de los detalles, sino los detalles de las abstracciones.
Este principio nos dice que debemos reducir las dependencias entre los módulos, o al menos controlar esas dependencias de manera que los módulos de alto nivel no dependan de los detalles de implementación de los módulos de bajo nivel.
Vamos a aclarar qué significan estos términos:
Mal ejemplo (no sigue DIP):
class MySQLDatabase: def connect(self) -> None: # Conectar a MySQL pass def query(self, sql: str) -> list: # Ejecutar consulta en MySQL return [] class UserRepository: def __init__(self): self.database = MySQLDatabase() # Dependencia directa def get_users(self) -> list: return self.database.query("SELECT * FROM users")
En este ejemplo,
UserRepository
depende directamente de MySQLDatabase
, lo que viola el principio DIP. Si queremos cambiar la base de datos a PostgreSQL, tendremos que modificar UserRepository
.
Buen ejemplo (siguiendo DIP):
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: # Conectar a MySQL pass def query(self, sql: str) -> list: # Ejecutar consulta en MySQL return [] class PostgreSQLDatabase(Database): def connect(self) -> None: # Conectar a PostgreSQL pass def query(self, sql: str) -> list: # Ejecutar consulta en PostgreSQL return [] class UserRepository: def __init__(self, database: Database): self.database = database # Dependencia de abstracción def get_users(self) -> list: return self.database.query("SELECT * FROM users")
Ahora,
UserRepository
depende de la abstracción Database
y no de una implementación concreta. Podemos cambiar fácilmente la base de datos sin modificar UserRepository
:
# Usar MySQL mysql_db = MySQLDatabase() user_repo = UserRepository(mysql_db) # Cambiar a PostgreSQL postgres_db = PostgreSQLDatabase() user_repo = UserRepository(postgres_db)
Esto es lo que se conoce como "Inyección de Dependencias", un patrón de diseño que implementa el principio de inversión de dependencias.
Los principios S.O.L.I.D. son fundamentales para desarrollar un software robusto, flexible y mantenible. Al seguir estos principios, no solo mejoramos la calidad de nuestro código, sino que también facilitamos la colaboración entre desarrolladores, la extensión del sistema y la resolución de problemas.
En Python, estos principios se pueden aplicar aunque el lenguaje no sea estrictamente orientado a objetos como Java o C++. La flexibilidad de Python permite adoptar estos principios cuando trabajamos con clases y objetos, lo que resulta en un código más limpio y profesional.
Recuerda que estos principios son guías, no reglas estrictas. En algunos contextos, puede ser aceptable hacer concesiones en favor de la simplicidad, especialmente en proyectos pequeños o prototipos. Sin embargo, en proyectos grandes y complejos, seguir los principios S.O.L.I.D. puede marcar la diferencia entre un proyecto exitoso y uno que se vuelve inmanejable con el tiempo.