Cómo aplicar los principios SOLID en Python paso a paso

  • Los principios SOLID proporcionan una base clara para diseñar código Python orientado a objetos más legible, mantenible y escalable.
  • Cada principio (SRP, OCP, LSP, ISP y DIP) aborda un tipo concreto de problema de diseño, desde responsabilidades mal separadas hasta dependencias rígidas.
  • Aplicar SOLID con clases, abstracciones e inyección de dependencias en Python reduce el acoplamiento, mejora la capacidad de prueba y facilita la evolución del sistema.

Solid en Python

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.

mejores IDEs para programar windows 11
Artículo relacionado:
Los mejores IDE para programar en Windows 11