Saltar al contenido principal
Datos y código sólido Buenas prácticas~ 55 min de lectura
PorSergio Perea· intermedio

Arquitectura limpia, principios SOLID y tipado en Python: parte I

Aprende Clean Architecture, principios SOLID y type hints en Python: cómo estructurar apps mantenibles, testables y adaptables al cambio real.

Clean ArchitectureSOLIDPythontype hintsmypyarquitectura de softwaretestingbuenas prácticas

Arquitectura limpia, principios SOLID y tipado en Python: parte I

Hay un momento en la vida de todo programador en el que mira su propio código y piensa: ¿quién escribió este desastre?

Spoiler: fuiste tú. Hace seis meses. Cuando todo iba rápido, las ideas fluían y la fecha de entrega estaba encima. Hiciste que funcionara, lo entregaste, y todo el mundo se fue a casa contento. Pero seis meses después, cuando el cliente quiere añadir una nueva funcionalidad que «parece sencilla», descubres que cambiar una sola cosa rompe otras cinco, que los módulos están tan mezclados que nadie sabe de dónde viene cada cosa, y que escribir un test requiere levantar media base de datos.

Esto no es un problema de talento. Es un problema de arquitectura.

En este artículo vas a aprender tres cosas que van a cambiar por completo cómo diseñas software en Python:

  1. Clean Architecture: una forma de estructurar tu aplicación para que el cambio no te cueste una semana de sufrimiento.
  2. Principios SOLID: cinco reglas concretas que te dicen cómo escribir clases y módulos que aguantan el paso del tiempo.
  3. Sistema de tipos de Python: cómo usar type hints y herramientas de análisis estático para cazar errores antes de que lleguen a producción.

No es teoría de academia. Todo lo que leerás aquí son decisiones que puedes tomar hoy mismo, en el proyecto en el que estás trabajando ahora. Empecemos.


El problema real: por qué el código se vuelve inmanejable

Antes de entrar en soluciones, necesitas entender bien el problema. Porque si no ves el dolor, las soluciones te van a parecer innecesariamente complicadas.

La trampa del crecimiento

Python tiene una ventaja enorme: es fácil de empezar. Con unas pocas líneas tienes algo que funciona. Eso está muy bien para prototipos, scripts y proyectos pequeños. Pero cuando un proyecto crece, esa misma facilidad se vuelve en tu contra.

Imagina que trabajas en un e-commerce llamado PyShop. La empresa decide añadir una funcionalidad de papel de regalo en los pedidos. Suena simple, ¿verdad? Una opción más en el checkout, unas instrucciones extra al almacén, un pequeño coste adicional.

Pero en la práctica, si el código está mal estructurado, esto se convierte en algo así:

  • El módulo de procesamiento de pedidos necesita saber que existe la opción de papel de regalo.
  • El sistema de inventario tiene que rastrear el stock de papel de regalo.
  • El motor de precios tiene que calcular el coste adicional.
  • La interfaz de usuario tiene que mostrar la opción al cliente.
  • El sistema de fulfillment tiene que incluir instrucciones especiales para el embalaje.

Lo que parecía un trabajo de dos semanas se convierte en un proyecto de tres meses. ¿Por qué? Porque los módulos están tan acoplados entre sí que tocar uno implica tocar todos los demás. Cambias el procesamiento de pedidos y se rompe el reporting. Cambias el inventario y afecta a la cadena de suministro. Cambias la UI y hay que re-testear todo el flujo de usuario.

Y el problema no acaba ahí. Cada cambio en un sistema interconectado tiene efectos colaterales que nadie anticipó. Ajustas el cálculo de precios para incluir el coste del papel de regalo, y de repente los descuentos por volumen se calculan mal. Modificas el inventario para rastrear el papel de regalo, y el sistema de reposición automática empieza a hacer pedidos innecesarios. Son bugs que no aparecen de inmediato; aparecen días o semanas después, cuando ya nadie recuerda qué cambió.

Este es el coste real de una arquitectura sin planificación.

Las dos caras del problema de abstracción

Cuando los proyectos crecen sin una guía arquitectónica clara, los desarrolladores suelen caer en uno de dos extremos igualmente malos.

El primero es la jerarquía de clases infernal: se crean herencias muy profundas para maximizar la reutilización de código, y el resultado es un sistema frágil donde un cambio en un ancestro tiene consecuencias imposibles de predecir en todos sus descendientes. Has visto código así: cinco niveles de herencia, métodos que sobreescriben otros métodos, constructores que llaman a super() en cadena y nadie sabe qué versión de qué método se está ejecutando realmente. Cuando hay un bug, rastrear su origen a través de cinco niveles de herencia es una pesadilla.

El segundo extremo es la clase que hace todo: sin ninguna abstracción, aparecen clases monstruosas de 2.000 líneas que mezclan lógica de negocio, acceso a datos, validaciones, formateo de respuestas y a veces hasta envío de emails. Son imposibles de testear, imposibles de modificar sin romper algo, y completamente incomprensibles para cualquiera que no las haya escrito ayer. Estas clases se llaman «god objects» o «blob classes», y son un signo inequívoco de que algo ha ido muy mal en el proceso de diseño.

La solución está en el medio, y tiene un nombre: separación de responsabilidades.

La trampa de la velocidad

Hay otro factor que hace que el código se degrade: la presión por entregar rápido. En un entorno ágil, la velocidad de entrega es crucial. Y Python es muy bueno para entregar rápido, lo que puede ser un arma de doble filo.

Sin una arquitectura sólida, las primeras semanas de un proyecto son increíblemente productivas. Las features salen a ritmo de vértigo. Todo el mundo está contento. Pero después de unos meses, los desarrolladores empiezan a pasar más tiempo entendiendo el código existente que escribiendo código nuevo. La estructura del código ya no está clara. Cuando no está claro dónde añadir algo nuevo ni cómo debe interactuar con lo que ya existe, la gente toma atajos bajo presión. Esos atajos acumulan deuda técnica. La deuda técnica ralentiza el ritmo. Y el ciclo continúa.

La metáfora del coche de carreras es perfecta aquí: construir un coche que sea muy rápido en línea recta es fácil. Construir uno que también frene bien, gire con precisión y se mantenga fiable durante toda la carrera requiere ingeniería de verdad. Un mal diseño puede ser muy veloz al principio, pero la primera curva te lo cobra todo.

Como dijo alguien muy sabio: el diseño grande desde el principio es una tontería, pero no hacer ningún diseño desde el principio es aún más tonto. La clave está en encontrar el equilibrio entre planificación y agilidad. Y eso es exactamente lo que ofrece Clean Architecture.

El problema de la tecnología como protagonista

Hay un error sutil pero devastador que cometen muchos equipos: dejar que la tecnología dicte la estructura de la aplicación.

Cuando abres un proyecto y la estructura de carpetas es models/, views/, templates/, static/, lo que ves es la estructura de Django, no la estructura de tu negocio. Cuando alguien nuevo llega al equipo, tiene que aprender primero qué hace el negocio y luego dónde está eso en el código Django.

El problema se agrava cuando la tecnología cambia. Si el day-uno usaste SQLAlchemy directamente en todas partes, y ahora hay que migrar a otro ORM o a una base de datos diferente, el trabajo es enorme porque la tecnología está entretejida con la lógica de negocio.

La arquitectura limpia invierte esta relación: la estructura del código refleja el negocio, y la tecnología es un detalle de implementación que puede cambiar sin afectar el corazón del sistema.


La evolución de la arquitectura de software

Para entender bien Clean Architecture, ayuda saber de dónde viene. Las ideas que sintetiza llevan décadas siendo desarrolladas por la comunidad de ingeniería de software.

El modelo en cascada y sus limitaciones

Los primeros intentos de estructurar el desarrollo de software vinieron del mundo de la ingeniería civil y la manufactura. El modelo en cascada (Waterfall) propone una secuencia lineal: análisis, diseño, implementación, pruebas, despliegue. Antes de escribir una línea de código, diseñas todo el sistema al detalle.

El problema es que el software no funciona como un puente. Los requisitos cambian mientras construyes. El cliente no sabe exactamente lo que quiere hasta que ve algo funcionar. Los supuestos que hiciste en el diseño resultan incorrectos cuando llegas a la implementación. El resultado es que los proyectos grandes en cascada frecuentemente llegaban al final con meses de retraso, presupuestos desbordados y software que no hacía lo que el cliente necesitaba.

El movimiento ágil

A principios de los 2000, el movimiento ágil propuso una respuesta: no intentes diseñar todo desde el principio. Trabaja en iteraciones cortas. Entrega valor frecuentemente. Responde al cambio sobre seguir un plan.

Esto fue una mejora enorme para la productividad y la satisfacción del cliente. Pero introdujo nuevos problemas: sin ninguna guía arquitectónica, los equipos ágiles podían entregar rápido... durante los primeros meses. Después, la acumulación de deuda técnica ralentizaba el ritmo hasta hacerlo insostenible.

La agilidad sin arquitectura es como conducir muy rápido sin saber a dónde vas. Llegarás lejos, pero no necesariamente adonde querías.

Clean Architecture como síntesis

Clean Architecture recoge décadas de sabiduría en diseño de software y las organiza en un conjunto cohesivo de principios. No es revolucionaria en sus ideas individuales; muchas de ellas existen desde los años 70 y 80. Lo que hace es sintetizarlas de una forma que funciona bien en el desarrollo moderno, donde la agilidad es un requisito y no un lujo.

La clave de la síntesis es esta: puedes ser ágil Y tener buena arquitectura. No son opuestos. La buena arquitectura, de hecho, facilita la agilidad porque hace que los cambios sean predecibles y baratos. Es la mala arquitectura la que mata la agilidad a largo plazo.


Qué es Clean Architecture

Clean Architecture es un conjunto de principios para estructurar aplicaciones de software de manera que sean mantenibles, testables y adaptables al cambio. La idea central es simple pero poderosa: organizar el código en capas concéntricas, como una cebolla, donde las capas interiores son independientes de las exteriores.

La regla más importante de toda la arquitectura se llama la Regla de Dependencia, y dice lo siguiente: las dependencias de código solo pueden apuntar hacia adentro. Las capas interiores no pueden saber nada sobre las capas exteriores.

Esto significa que tu lógica de negocio no sabe si está siendo invocada por una API REST, una interfaz de línea de comandos o un mensaje de una cola de mensajes. No sabe si los datos se guardan en PostgreSQL, en MongoDB o en un archivo CSV. Esas son decisiones externas que no le incumben al corazón de la aplicación.

Cuando tu lógica de negocio no depende de ningún framework ni de ninguna tecnología específica, tienes un poder enorme: puedes cambiar la base de datos, cambiar el framework web, cambiar el sistema de mensajería, y el corazón de tu aplicación no se entera. El cambio queda contenido en la capa más externa.

Las capas de la cebolla

Visualiza tu aplicación como una serie de anillos concéntricos. De dentro a fuera:

Entidades: En el centro más puro de la aplicación están las entidades. Son los objetos de negocio fundamentales, los conceptos que existen en tu dominio independientemente de cualquier tecnología. En un e-commerce serían Producto, Cliente, Pedido. En una aplicación de gestión de tareas serían Usuario, Tarea, Proyecto. En un sistema bancario serían Cuenta, Transacción, Cliente.

Las entidades encapsulan las reglas de negocio más generales y permanentes: un pedido no puede tener un precio negativo, una tarea debe tener un título, una transacción bancaria debe tener una cuenta origen. Estas reglas no cambian porque cambies de framework. Son verdades del negocio, y las verdades del negocio cambian menos que cualquier tecnología.

Una entidad bien diseñada puede vivir durante décadas. Los frameworks web tienen una vida media de pocos años. ¿Cuál merece más cuidado?

Casos de uso: El siguiente anillo hacia fuera contiene los casos de uso. Aquí se define cómo el sistema responde a acciones concretas del mundo real: crear una nueva tarea, asignar una tarea a un usuario, completar un pedido, enviar una notificación de confirmación, procesar un reembolso.

Los casos de uso orquestan el flujo de datos hacia y desde las entidades. Contienen la lógica específica de la aplicación, no la lógica universal del negocio. La diferencia es sutil pero importante: la regla de que un email debe tener el formato correcto es una regla de entidad. La lógica de qué pasa exactamente cuando un usuario registra una cuenta nueva (crear entidad, verificar que el email no existe, enviar email de bienvenida, añadir a lista de marketing) es un caso de uso.

Los casos de uso conocen las entidades, pero no saben nada sobre cómo se presentan los datos al usuario ni cómo se guardan en disco.

Adaptadores de interfaz: Este anillo actúa como traductor entre los casos de uso y el mundo exterior. Aquí viven los controladores que reciben peticiones HTTP y las convierten en llamadas a casos de uso. Aquí viven los presentadores que toman el resultado de un caso de uso y lo formatean para enviárselo al cliente. Aquí viven los repositorios que definen (en abstracto) cómo se accede a la persistencia.

La palabra clave es «definen»: en esta capa defines la interfaz abstracta del repositorio (qué métodos tiene, qué parámetros toma, qué devuelve), pero no la implementas. La implementación concreta, con el SQL real o las llamadas al ORM, va en la capa exterior.

Esta separación es lo que permite cambiar de MySQL a PostgreSQL sin tocar nada más que la implementación del repositorio.

Frameworks y drivers: El anillo más externo es el más volátil. Aquí vive Django o FastAPI, aquí están los ORM concretos, los clientes de bases de datos, las librerías de terceros para envío de emails o procesamiento de pagos. Esta capa conecta tu aplicación con el mundo exterior: bases de datos, servicios HTTP, sistemas de archivos, colas de mensajes.

Esta es la capa que más cambia con el tiempo. Los frameworks evolucionan, a veces cambian de licencia, a veces simplemente dejan de tener soporte. Las bases de datos migran. Las APIs de terceros deprecan versiones. Al mantener esta capa en el exterior, proteges tu inversión en la lógica de negocio.

La Regla de Dependencia en la práctica

Para que esto quede cristalino, mira este ejemplo de un sistema de biblioteca:

# Capa de entidades - no depende de absolutamente nada externo
class Book:
    def __init__(self, isbn: str, title: str, author: str, available: bool = True):
        self.isbn = isbn
        self.title = title
        self.author = author
        self.available = available

    def checkout(self) -> None:
        """Regla de negocio: un libro no puede estar prestado dos veces."""
        if not self.available:
            raise ValueError(f"El libro '{self.title}' ya está prestado")
        self.available = False

    def return_book(self) -> None:
        self.available = True


# Capa de casos de uso - depende solo de entidades y abstracciones
class BookInventory:
    def __init__(self, repository):
        # 'repository' es una abstracción, no una implementación concreta
        self.repository = repository

    def add_book(self, isbn: str, title: str, author: str) -> Book:
        book = Book(isbn, title, author)
        self.repository.save(book)
        return book

    def checkout_book(self, isbn: str) -> Book:
        book = self.repository.find_by_isbn(isbn)
        if not book:
            raise ValueError(f"Libro con ISBN {isbn} no encontrado")
        book.checkout()  # Aplica la regla de negocio de la entidad
        self.repository.save(book)
        return book

    def return_book(self, isbn: str) -> Book:
        book = self.repository.find_by_isbn(isbn)
        if not book:
            raise ValueError(f"Libro con ISBN {isbn} no encontrado")
        book.return_book()
        self.repository.save(book)
        return book


# Capa de adaptadores - traduce entre casos de uso y el mundo exterior
class BookController:
    def __init__(self, inventory: BookInventory):
        self.inventory = inventory

    def handle_checkout_request(self, request_data: dict) -> dict:
        try:
            book = self.inventory.checkout_book(request_data["isbn"])
            return {
                "success": True,
                "book": {"isbn": book.isbn, "title": book.title}
            }
        except ValueError as e:
            return {"success": False, "error": str(e)}

La clase Book no sabe nada de BookInventory. BookInventory usa Book pero no sabe nada de BookController. BookController sabe de BookInventory pero no de ninguna tecnología web concreta. Si mañana cambias de Flask a FastAPI, solo cambias cómo llamas a BookController, no el controlador mismo.

Por qué esto importa a largo plazo

La arquitectura limpia te da algo valioso que la mayoría de arquitecturas no dan: la capacidad de posponer decisiones de implementación.

Al principio de un proyecto, no siempre sabes qué base de datos vas a usar, qué framework es el más adecuado, si el servicio va a ser REST o GraphQL. Con Clean Architecture, puedes empezar a implementar la lógica de negocio y los casos de uso antes de tomar esas decisiones. Las decisiones de implementación se pueden posponer hasta el momento en que tienes más información para tomarlas bien.

Y cuando tienes que cambiar algo, los cambios son locales y predecibles. Cambias la implementación de la base de datos y solo cambias esa implementación. El resto del sistema no necesita saber que pasó algo.


Los beneficios concretos

Lista los beneficios tangibles para que tengas claro por qué vale la pena el esfuerzo inicial:

Testabilidad real: Como la lógica de negocio no depende de ninguna tecnología externa, puedes testearla en total aislamiento. No necesitas levantar una base de datos para testear que un pedido calcula bien su precio con descuentos. No necesitas un servidor SMTP para testear que se construye correctamente el email de bienvenida. Los tests son rápidos, precisos y no tienen efectos secundarios. Un test que tarda 50 milisegundos en lugar de 5 segundos significa que lo ejecutarás cien veces más durante el desarrollo.

Flexibilidad tecnológica: ¿El framework que elegiste hace dos años ya no tiene soporte? ¿La startup que hacía tu base de datos favorita cerró y la tecnología quedó sin mantenimiento? ¿El cliente quiere migrar a la nube y eso implica cambiar el motor de persistencia? Con Clean Architecture, cambiar de tecnología es un esfuerzo predecible y acotado. Sin ella, es potencialmente una reescritura completa.

Velocidad de desarrollo sostenida: Al principio parece que la arquitectura limpia ralentiza el desarrollo porque hay más código que escribir y más decisiones que tomar. Pero a largo plazo, la velocidad de desarrollo no solo se mantiene sino que aumenta, porque los cambios son predecibles, los tests son fiables y el código es comprensible. Los equipos que invierten en arquitectura al principio suelen entregar más features en el segundo año que equipos que no invirtieron.

Arquitectura que grita su propósito: Cuando alguien nuevo llega al equipo y mira la estructura de carpetas de tu proyecto, debería ver inmediatamente que es una tienda online, o un sistema de gestión de tareas, o una plataforma de cursos. No debería ver «es una aplicación Django» o «usa FastAPI». La tecnología es un detalle de implementación; el negocio es el alma del proyecto.

Paralelización del trabajo: Cuando los módulos están bien separados, diferentes miembros del equipo pueden trabajar en diferentes capas sin pisarse. Un desarrollador puede trabajar en la lógica de negocio mientras otro implementa la API y un tercero trabaja en la capa de persistencia. Los conflictos de merge son menos frecuentes y menos graves.

Mantenimiento a largo plazo: El software bien arquitecturado puede vivir durante décadas. Los sistemas mal arquitecturados acumulan deuda técnica hasta que eventualmente hay que reescribirlos. La reescritura completa de un sistema es cara, arriesgada y demoralizante. La inversión en arquitectura desde el principio es siempre más barata.


Clean Architecture en Python: un ajuste natural

Python y Clean Architecture se llevan bien. La filosofía de Python encaja de manera sorprendente con los principios de arquitectura limpia: simplicidad, legibilidad, claridad de intención.

El Zen de Python dice cosas como «explícito es mejor que implícito», «simple es mejor que complejo», «la legibilidad cuenta». Estos valores son exactamente los que Clean Architecture intenta promover a nivel arquitectónico.

Abstract Base Classes: la forma clásica

Python tiene un mecanismo nativo para definir interfaces: las clases base abstractas (ABC). Permiten definir contratos que las subclases deben cumplir, sin implementar la lógica concreta.

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send_notification(self, message: str) -> None:
        """Envía una notificación con el mensaje dado."""
        pass

class EmailNotifier(Notifier):
    def send_notification(self, message: str) -> None:
        # Aquí iría la lógica real de envío por email
        print(f"[EMAIL] Enviando: {message}")

class SMSNotifier(Notifier):
    def send_notification(self, message: str) -> None:
        # Aquí iría la integración con el proveedor de SMS
        print(f"[SMS] Enviando: {message}")

class PushNotifier(Notifier):
    def send_notification(self, message: str) -> None:
        # Aquí iría la integración con servicios de push notifications
        print(f"[PUSH] Enviando: {message}")

class NotificationService:
    def __init__(self, notifier: Notifier):
        self.notifier = notifier

    def notify(self, message: str) -> None:
        self.notifier.send_notification(message)

# El caso de uso no sabe qué tipo de notificador tiene
# Solo sabe que puede llamar a send_notification
email_notifier = EmailNotifier()
service = NotificationService(email_notifier)
service.notify("Tu pedido ha sido enviado")

# Cambiar a SMS no requiere modificar NotificationService
sms_notifier = SMSNotifier()
service = NotificationService(sms_notifier)
service.notify("Tu pedido ha sido enviado")

Aquí NotificationService depende de la abstracción Notifier, no de la implementación concreta. Si mañana añades PushNotifier o WhatsAppNotifier, no cambias NotificationService en absoluto. Simplemente creas una nueva clase que implementa Notifier.

La clave está en dónde vive Notifier: en la capa interior. Las implementaciones concretas (EmailNotifier, SMSNotifier) viven en la capa exterior. Las dependencias apuntan hacia adentro.

Cuando intentas crear una instancia de Notifier directamente sin implementar sus métodos abstractos, Python te lo impide con un error claro:

notifier = Notifier()
# TypeError: Can't instantiate abstract class Notifier
# with abstract method send_notification

Este error en tiempo de instanciación es mucho mejor que un error silencioso que explota en tiempo de ejecución.

Protocols: duck typing con seguridad de tipos

Python también ofrece una alternativa más flexible: los Protocol. Introducidos en Python 3.8 mediante PEP 544, permiten definir interfaces sin necesidad de herencia explícita. Es duck typing con ventajas de análisis estático.

Duck typing es una de las características más queridas de Python: si un objeto tiene el método que necesitas, puede usarlo, independientemente de su tipo. El nombre viene del dicho «si camina como un pato y grazna como un pato, entonces es un pato». Los Protocol llevan esta idea un paso más allá: le permiten a las herramientas estáticas verificar que el «pato» tiene todas las características necesarias antes de que el código se ejecute.

from typing import Protocol

class Notifier(Protocol):
    def send_notification(self, message: str) -> None:
        ...

# Sin herencia explícita: solo necesitan tener el método correcto
class EmailNotifier:
    def send_notification(self, message: str) -> None:
        print(f"[EMAIL] Enviando: {message}")

class SMSNotifier:
    def send_notification(self, message: str) -> None:
        print(f"[SMS] Enviando: {message}")

# Una clase de librería de terceros que no controlas
class TwilioAdapter:
    def send_notification(self, message: str) -> None:
        # Integración real con Twilio
        print(f"[TWILIO] Enviando: {message}")

class NotificationService:
    def __init__(self, notifier: Notifier):
        self.notifier = notifier

    def notify(self, message: str) -> None:
        self.notifier.send_notification(message)

# Todos funcionan, aunque ninguno herede de Notifier
service1 = NotificationService(EmailNotifier())
service2 = NotificationService(TwilioAdapter())  # Clase de terceros, sin modificar

La ventaja de Protocol es que puedes usar clases de librerías de terceros que ya tienen el método correcto, sin modificarlas para que hereden de tu interfaz. Esto es especialmente útil cuando trabajas con librerías externas que no controlas.

¿Cuándo usar ABC y cuándo Protocol? Usa ABC cuando quieres que el incumplimiento del contrato falle en tiempo de ejecución y cuando la relación de herencia es semánticamente significativa. Usa Protocol cuando quieres máxima flexibilidad, especialmente con clases de terceros o cuando la herencia explícita añadiría complejidad innecesaria.

En ambos casos, el tipo hint documenta la expectativa y las herramientas pueden verificarla. Eso es mucho mejor que comentarios que nadie lee.

La estructura de un proyecto

Un proyecto Python con Clean Architecture tiene una estructura de carpetas que refleja las capas arquitectónicas:

proyecto/
├── entities/
│   ├── __init__.py
│   ├── user.py         # Entidad User y sus reglas de negocio
│   ├── order.py        # Entidad Order y sus reglas
│   └── product.py      # Entidad Product y sus reglas
├── use_cases/
│   ├── __init__.py
│   ├── create_user.py      # Caso de uso: crear usuario
│   ├── place_order.py      # Caso de uso: realizar pedido
│   └── process_payment.py  # Caso de uso: procesar pago
├── interfaces/
│   ├── __init__.py
│   ├── repositories/
│   │   ├── user_repository.py   # Interfaz abstracta del repositorio de usuarios
│   │   └── order_repository.py  # Interfaz abstracta del repositorio de pedidos
│   ├── controllers/
│   │   └── order_controller.py  # Traduce HTTP a llamadas a casos de uso
│   └── presenters/
│       └── order_presenter.py   # Formatea la respuesta para el cliente
├── frameworks/
│   ├── __init__.py
│   ├── database/
│   │   ├── models.py    # Modelos del ORM (SQLAlchemy, Django ORM, etc.)
│   │   └── repositories/
│   │       └── sql_user_repository.py  # Implementación concreta del repositorio
│   └── web/
│       └── routes.py    # Rutas de Django/FastAPI/Flask
└── tests/
    ├── test_entities/
    │   └── test_user.py
    ├── test_use_cases/
    │   └── test_place_order.py
    └── test_interfaces/
        └── test_order_controller.py

Cuando miras esta estructura, ves inmediatamente que el proyecto tiene usuarios, pedidos y productos. Ves que hay casos de uso concretos. No ves «es Django» o «usa SQLAlchemy» en la capa central. Esos son detalles externos.

La carpeta de tests espeja la estructura de la aplicación. Puedes testear cada capa de forma independiente, empezando por las entidades (sin ninguna dependencia externa) y progresando hacia afuera.

Consideraciones específicas de Python

Python tiene algunas particularidades que hay que tener en cuenta al implementar Clean Architecture.

La primera es la facilidad de importación. En Python, todos los paquetes son efectivamente públicos. No hay mecanismo de acceso privado entre módulos como en Java o C#. Esto significa que el compilador no te va a impedir importar desde una capa exterior en una capa interior. Tienes que ser disciplinado. Herramientas como import-linter pueden ayudarte a verificar automáticamente que no se violan las reglas de dependencia.

La segunda es la filosofía de baterías incluidas. Python tiene una librería estándar enormemente rica, y la tentación es usar directamente smtplib en tu lógica de negocio, o sqlite3 en tus casos de uso. Resiste esa tentación. Aunque la librería estándar es más estable que las librerías de terceros, sigue siendo un detalle de implementación externo. Crea abstracciones alrededor de ella.

La tercera es la naturaleza dinámica del lenguaje. Python no te obliga a declarar tipos, y eso puede llevar a dependencias ocultas que no son evidentes al leer el código. El sistema de type hints, que veremos en detalle más adelante, es tu aliado aquí.


Los principios SOLID: las reglas del juego

Clean Architecture es el marco general. Los principios SOLID son las reglas concretas que te dicen cómo escribir cada clase y módulo. SOLID es un acrónimo de cinco principios, y vamos a verlos uno por uno con ejemplos reales en Python.

Una aclaración antes de empezar: los principios SOLID operan principalmente al nivel de clases y módulos. Son las herramientas que construyen los ladrillos con los que luego erige la arquitectura. Sin SOLID, tu arquitectura limpia se llena de código sucio. Con SOLID, tienes piezas que encajan bien.

El orden en que los veremos no es el del acrónimo (SRP, OCP, LSP, ISP, DIP), sino un orden pedagógico que crea una progresión natural: empezamos por cómo escribir clases enfocadas (SRP), luego vemos cómo extender comportamiento sin modificar código (OCP), luego cómo diseñar interfaces precisas (ISP), luego cómo garantizar que las jerarquías son confiables (LSP), y finalmente cómo invertir las dependencias para desacoplar todo (DIP).

Principio de Responsabilidad Única (SRP)

La definición es simple: cada módulo o clase debe tener una única razón para cambiar.

Nótese que la definición no dice «debe hacer una sola cosa». Dice «debe tener una única razón para cambiar». Esa distinción es importante.

Mira esta clase:

class User:
    def __init__(self, user_id: str, username: str, email: str):
        self.user_id = user_id
        self.username = username
        self.email = email
        self.posts = []

    def create_post(self, content: str) -> dict:
        post = {
            "id": len(self.posts) + 1,
            "content": content,
            "likes": 0
        }
        self.posts.append(post)
        return post

    def get_timeline(self) -> list:
        # Lógica compleja para obtener y ordenar posts de usuarios seguidos
        pass

    def update_profile(self, new_username: str = None, new_email: str = None):
        if new_username:
            self.username = new_username
        if new_email:
            self.email = new_email

A primera vista parece razonable. Pero pregúntate: ¿cuántas razones tiene esta clase para cambiar?

Si el equipo de producto cambia cómo se generan los IDs de los posts, cambia. Si el algoritmo del timeline cambia (por ejemplo, incluir posts de hashtags seguidos), cambia. Si se añade verificación de email en las actualizaciones de perfil, cambia. Si se añade un campo de teléfono al usuario, cambia. Si cambian las reglas de validación del username (mínimo 3 caracteres, sin espacios, etc.), cambia.

Cinco razones para cambiar. Eso significa cinco historias de usuario distintas, cinco pull requests distintos, cinco posibilidades de conflicto de merge. Y si dos personas tocan la clase al mismo tiempo por razones distintas, uno de los dos va a tener que resolver un conflicto desagradable.

La solución es separar responsabilidades:

class User:
    """
    Entidad central: solo gestiona los datos fundamentales del usuario.
    Razón de cambio: si cambia la definición fundamental de qué es un usuario.
    """
    def __init__(self, user_id: str, username: str, email: str):
        self.user_id = user_id
        self.username = username
        self.email = email

    def __repr__(self) -> str:
        return f"User(id={self.user_id}, username={self.username})"


class PostManager:
    """
    Razón de cambio: si cambia la lógica de creación y gestión de posts.
    """
    def create_post(self, user: User, content: str) -> dict:
        post = {
            "id": self._generate_post_id(),
            "user_id": user.user_id,
            "content": content,
            "likes": 0
        }
        return post

    def like_post(self, post: dict, user_id: str) -> dict:
        post["likes"] += 1
        return post

    def _generate_post_id(self) -> str:
        import uuid
        return str(uuid.uuid4())


class TimelineService:
    """
    Razón de cambio: si cambia el algoritmo para construir el timeline.
    """
    def get_timeline(self, user: User, following: list) -> list:
        # Lógica para obtener posts de usuarios seguidos,
        # ordenados por relevancia y fecha
        all_posts = []
        for followed_user_id in following:
            # Aquí iría la lógica de obtención
            pass
        return sorted(all_posts, key=lambda p: p.get("timestamp", 0), reverse=True)


class ProfileManager:
    """
    Razón de cambio: si cambian las reglas de actualización del perfil.
    """
    def update_profile(
        self,
        user: User,
        new_username: str = None,
        new_email: str = None
    ) -> None:
        if new_username:
            self._validate_username(new_username)
            user.username = new_username
        if new_email:
            self._validate_email(new_email)
            user.email = new_email

    def _validate_username(self, username: str) -> None:
        if len(username) < 3:
            raise ValueError("El username debe tener al menos 3 caracteres")
        if " " in username:
            raise ValueError("El username no puede contener espacios")

    def _validate_email(self, email: str) -> None:
        if "@" not in email or "." not in email.split("@")[-1]:
            raise ValueError(f"Email inválido: {email}")

Ahora cada clase tiene exactamente una razón para cambiar. Los cambios son predecibles. Los tests son simples. Los conflictos de merge son raros.

El beneficio en testing es inmediato. Cada clase se puede testear de forma completamente independiente:

import unittest

class TestPostManager(unittest.TestCase):
    def setUp(self):
        self.user = User("123", "maria", "[email protected]")
        self.post_manager = PostManager()

    def test_create_post_sets_correct_user_id(self):
        post = self.post_manager.create_post(self.user, "Hola mundo!")
        self.assertEqual(post["user_id"], "123")

    def test_create_post_sets_content(self):
        post = self.post_manager.create_post(self.user, "Mi primer post")
        self.assertEqual(post["content"], "Mi primer post")

    def test_new_post_has_zero_likes(self):
        post = self.post_manager.create_post(self.user, "Contenido")
        self.assertEqual(post["likes"], 0)

    def test_like_post_increments_likes(self):
        post = self.post_manager.create_post(self.user, "Contenido")
        liked_post = self.post_manager.like_post(post, "otro_user_id")
        self.assertEqual(liked_post["likes"], 1)


class TestProfileManager(unittest.TestCase):
    def setUp(self):
        self.user = User("123", "maria", "[email protected]")
        self.profile_manager = ProfileManager()

    def test_update_username_successfully(self):
        self.profile_manager.update_profile(self.user, new_username="nueva_maria")
        self.assertEqual(self.user.username, "nueva_maria")

    def test_username_too_short_raises_error(self):
        with self.assertRaises(ValueError):
            self.profile_manager.update_profile(self.user, new_username="ab")

    def test_invalid_email_raises_error(self):
        with self.assertRaises(ValueError):
            self.profile_manager.update_profile(self.user, new_email="no_es_email")

Los tests son claros, directos, sin setup complicado. Cada test verifica una cosa concreta sobre una clase que hace una cosa concreta. Así de simple debería ser.

Una advertencia importante: no lleves SRP al extremo. Si tienes una clase de 50 líneas y la rompes en 10 clases de 5 líneas cada una, el resultado es peor que el problema original. La granularidad correcta depende del contexto. La pregunta siempre es: ¿cuántas razones independientes tiene esta clase para cambiar? Si la respuesta es «muchas», separa. Si la respuesta es «una», deja quieto.

Principio Abierto/Cerrado (OCP)

El Principio Abierto/Cerrado dice que las entidades de software deben estar abiertas a la extensión pero cerradas a la modificación.

En cristiano: deberías poder añadir comportamiento nuevo sin cambiar el código que ya existe y funciona.

La palabra «cerrado» aquí no significa que el código no puede cambiar nunca. Significa que no debería necesitar cambiar cuando añades nuevas funcionalidades. Si tienes que modificar código existente (y ya testeado) cada vez que añades algo nuevo, estás en problemas.

Imagina que tienes un sistema de descuentos:

# Mal diseño: viola OCP
class DiscountCalculator:
    def calculate_discount(self, customer_type: str, amount: float) -> float:
        if customer_type == "regular":
            return amount * 0.05
        elif customer_type == "premium":
            return amount * 0.10
        elif customer_type == "vip":
            return amount * 0.20
        # Cada vez que añades un tipo de cliente, modificas esta clase
        # ¡Estás tocando código que ya funciona y podría romperse!
        return 0.0

Cada vez que el equipo de negocio inventa un nuevo tipo de cliente con un descuento diferente, tienes que modificar DiscountCalculator. Cada modificación es una oportunidad de romper el cálculo para los tipos de cliente existentes. Y si hay tests para los tipos existentes, tienes que asegurarte de que siguen pasando después de tu modificación.

La solución respeta OCP:

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    """Interfaz abstracta para estrategias de descuento."""

    @abstractmethod
    def calculate(self, amount: float) -> float:
        pass

    @abstractmethod
    def description(self) -> str:
        pass


class RegularDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.05

    def description(self) -> str:
        return "Descuento cliente regular (5%)"


class PremiumDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.10

    def description(self) -> str:
        return "Descuento cliente premium (10%)"


class VIPDiscount(DiscountStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.20

    def description(self) -> str:
        return "Descuento cliente VIP (20%)"


class SeasonalDiscount(DiscountStrategy):
    """Nuevo tipo de descuento: no modifica nada existente."""
    def __init__(self, percentage: float, season: str):
        self.percentage = percentage
        self.season = season

    def calculate(self, amount: float) -> float:
        return amount * (self.percentage / 100)

    def description(self) -> str:
        return f"Descuento temporada {self.season} ({self.percentage}%)"


class DiscountCalculator:
    """Esta clase NUNCA cambia aunque añadas nuevos tipos de descuento."""

    def apply_discount(self, amount: float, strategy: DiscountStrategy) -> float:
        discount = strategy.calculate(amount)
        print(f"Aplicando: {strategy.description()} = -{discount:.2f}€")
        return amount - discount


# Uso
calculator = DiscountCalculator()
base_amount = 100.0

# Tipos existentes
calculator.apply_discount(base_amount, RegularDiscount())   # 95€
calculator.apply_discount(base_amount, PremiumDiscount())  # 90€

# Nuevo tipo: no tocamos DiscountCalculator
calculator.apply_discount(base_amount, SeasonalDiscount(15, "verano"))  # 85€
calculator.apply_discount(base_amount, SeasonalDiscount(25, "rebajas"))  # 75€

DiscountCalculator nunca cambia. Nuevos tipos de descuento son extensiones, no modificaciones. El código existente y sus tests permanecen intactos.

Este patrón se llama Strategy Pattern, y es uno de los patrones de diseño más directamente relacionados con OCP. El comportamiento variable (cómo calcular el descuento) se extrae a una jerarquía de clases, y el código que usa ese comportamiento depende de la abstracción.

En el contexto de Clean Architecture, OCP protege el código interno de los cambios en los requerimientos externos. La lógica central no necesita cambiar cada vez que el negocio añade una nueva variante de algo.

Principio de Segregación de Interfaces (ISP)

ISP dice: no obligues a ninguna clase a implementar métodos que no va a usar.

Las interfaces deben ser pequeñas y específicas. En lugar de una interfaz gigante que hace todo, crea varias interfaces enfocadas.

Este principio aplica directamente SRP al nivel de las interfaces: cada interfaz debe tener una única razón de existir.

El ejemplo clásico es un sistema multimedia. Imagina que defines una interfaz monolítica:

from abc import ABC, abstractmethod

class MultimediaPlayer(ABC):
    @abstractmethod
    def play_media(self, file: str) -> None:
        pass

    @abstractmethod
    def stop_media(self) -> None:
        pass

    @abstractmethod
    def display_lyrics(self, file: str) -> None:
        pass

    @abstractmethod
    def apply_video_filter(self, filter: str) -> None:
        pass

    @abstractmethod
    def record_audio(self) -> None:
        pass

    @abstractmethod
    def take_screenshot(self) -> bytes:
        pass


class MusicPlayer(MultimediaPlayer):
    def play_media(self, file: str) -> None:
        print(f"Reproduciendo música: {file}")

    def stop_media(self) -> None:
        print("Parando música")

    def display_lyrics(self, file: str) -> None:
        print(f"Mostrando letras de: {file}")

    # Estos métodos no tienen ningún sentido para un reproductor de audio:
    def apply_video_filter(self, filter: str) -> None:
        raise NotImplementedError("MusicPlayer no soporta filtros de vídeo")

    def record_audio(self) -> None:
        raise NotImplementedError("MusicPlayer no graba audio")

    def take_screenshot(self) -> bytes:
        raise NotImplementedError("MusicPlayer no hace capturas de pantalla")

MusicPlayer se ve obligado a implementar cuatro métodos que no tienen ningún sentido para un reproductor de audio. Esto es confuso para quien usa la clase, genera código muerto (raise NotImplementedError), y si alguien llama a apply_video_filter de buena fe confiando en que lo implementa (porque la interfaz lo declara), obtiene una excepción en tiempo de ejecución que debería ser imposible.

La solución aplica ISP: interfaces pequeñas y enfocadas.

from abc import ABC, abstractmethod

class MediaPlayable(ABC):
    """Capacidad de reproducir y parar medios."""
    @abstractmethod
    def play_media(self, file: str) -> None:
        pass

    @abstractmethod
    def stop_media(self) -> None:
        pass


class LyricsDisplayable(ABC):
    """Capacidad de mostrar letras."""
    @abstractmethod
    def display_lyrics(self, file: str) -> None:
        pass


class VideoFilterable(ABC):
    """Capacidad de aplicar filtros de vídeo."""
    @abstractmethod
    def apply_video_filter(self, filter: str) -> None:
        pass


class AudioRecordable(ABC):
    """Capacidad de grabar audio."""
    @abstractmethod
    def record_audio(self) -> None:
        pass


# MusicPlayer solo implementa lo que realmente puede hacer
class MusicPlayer(MediaPlayable, LyricsDisplayable):
    def play_media(self, file: str) -> None:
        print(f"Reproduciendo música: {file}")

    def stop_media(self) -> None:
        print("Parando música")

    def display_lyrics(self, file: str) -> None:
        print(f"Mostrando letras de: {file}")


# VideoPlayer implementa lo que le corresponde
class VideoPlayer(MediaPlayable, VideoFilterable):
    def play_media(self, file: str) -> None:
        print(f"Reproduciendo vídeo: {file}")

    def stop_media(self) -> None:
        print("Parando vídeo")

    def apply_video_filter(self, filter: str) -> None:
        print(f"Aplicando filtro: {filter}")


# Un reproductor básico solo necesita reproducir
class BasicAudioPlayer(MediaPlayable):
    def play_media(self, file: str) -> None:
        print(f"Reproduciendo audio: {file}")

    def stop_media(self) -> None:
        print("Parando audio")


# Una grabadora de podcast graba pero también reproduce
class PodcastRecorder(MediaPlayable, AudioRecordable):
    def play_media(self, file: str) -> None:
        print(f"Reproduciendo podcast: {file}")

    def stop_media(self) -> None:
        print("Parando")

    def record_audio(self) -> None:
        print("Grabando audio...")

Ahora cada clase implementa exactamente lo que promete. No hay raise NotImplementedError escondidos. La API de cada clase es honesta y clara.

La ganancia en testing es significativa: crear un mock de MediaPlayable para un test que solo necesita reproducción es trivial. No necesitas implementar métodos de grabación ni filtros de vídeo que no son relevantes para el test.

ISP en Clean Architecture conecta directamente con los casos de uso. Cada caso de uso necesita una interfaz específica para su función. El caso de uso «mostrar letras» solo necesita LyricsDisplayable. El caso de uso «aplicar filtro» solo necesita VideoFilterable. No hay contaminación entre casos de uso independientes.

Principio de Sustitución de Liskov (LSP)

LSP es el más técnico de los cinco, pero también uno de los más importantes para construir jerarquías de herencia que funcionen bien.

La formulación es: los objetos de una subclase deben poder reemplazar objetos de la superclase sin alterar el correcto funcionamiento del programa.

Dicho de forma más directa: si tienes código que funciona con una clase base, debe funcionar igual de bien con cualquier subclase. Sin sorpresas, sin excepciones inesperadas, sin comportamientos diferentes.

El ejemplo más famoso del problema es el cuadrado y el rectángulo. Matemáticamente, un cuadrado es un rectángulo donde todos los lados son iguales. Pero en código, heredar Square de Rectangle puede violar LSP de formas sutiles.

Veamos un ejemplo más directo con vehículos:

# Diseño problemático: viola LSP
class Vehicle:
    def __init__(self, fuel_capacity: float):
        self._fuel_capacity = fuel_capacity
        self._fuel_level = fuel_capacity

    def fuel_level(self) -> float:
        return self._fuel_level

    def refuel(self, amount: float) -> None:
        self._fuel_level = min(self._fuel_capacity, self._fuel_level + amount)

    def consume_fuel(self, distance: float) -> None:
        fuel_consumed = distance / 10  # 10 km por litro
        if self._fuel_level - fuel_consumed < 0:
            raise ValueError("Combustible insuficiente para esa distancia")
        self._fuel_level -= fuel_consumed


class ElectricCar(Vehicle):
    def consume_fuel(self, distance: float) -> None:
        # Problema: "combustible" aquí significa carga de batería,
        # una semántica completamente diferente
        energy_consumed = distance / 5  # 5 km por kWh
        if self._fuel_level - energy_consumed < 0:
            raise ValueError("Carga insuficiente")
        self._fuel_level -= energy_consumed

    def refuel(self, amount: float) -> None:
        # "Recargar" no es lo mismo que "repostar"
        # La semántica cambia, violando LSP
        raise NotImplementedError("Los coches eléctricos no se repostan, se recargan")

El problema es que ElectricCar hereda de Vehicle pero cambia la semántica de métodos fundamentales. Alguien que escribe código esperando un Vehicle y recibe un ElectricCar puede encontrarse con un NotImplementedError cuando intenta repostar, o con números inconsistentes en fuel_level() que en realidad representan carga de batería.

El diseño correcto separa la fuente de energía en una abstracción:

from abc import ABC, abstractmethod

class PowerSource(ABC):
    """Abstracción para fuentes de energía."""

    @abstractmethod
    def consume(self, distance: float) -> None:
        """Consume la energía necesaria para recorrer la distancia dada."""
        pass

    @abstractmethod
    def replenish(self, amount: float) -> None:
        """Repone energía."""
        pass

    @abstractmethod
    def level(self) -> float:
        """Devuelve el nivel actual de energía."""
        pass

    @abstractmethod
    def is_sufficient_for(self, distance: float) -> bool:
        """Verifica si hay suficiente energía para la distancia dada."""
        pass


class FuelTank(PowerSource):
    """Tanque de combustible para vehículos de gasolina/diésel."""

    def __init__(self, capacity_liters: float):
        self._capacity = capacity_liters
        self._level = capacity_liters

    def consume(self, distance: float) -> None:
        fuel = distance / 10  # 10 km por litro
        if not self.is_sufficient_for(distance):
            raise ValueError(f"Combustible insuficiente: necesitas {fuel:.1f}L, tienes {self._level:.1f}L")
        self._level -= fuel

    def replenish(self, amount: float) -> None:
        self._level = min(self._capacity, self._level + amount)

    def level(self) -> float:
        return self._level

    def is_sufficient_for(self, distance: float) -> bool:
        return self._level >= distance / 10


class Battery(PowerSource):
    """Batería para vehículos eléctricos."""

    def __init__(self, capacity_kwh: float):
        self._capacity = capacity_kwh
        self._charge = capacity_kwh

    def consume(self, distance: float) -> None:
        energy = distance / 5  # 5 km por kWh
        if not self.is_sufficient_for(distance):
            raise ValueError(f"Carga insuficiente: necesitas {energy:.1f}kWh, tienes {self._charge:.1f}kWh")
        self._charge -= energy

    def replenish(self, amount: float) -> None:
        self._charge = min(self._capacity, self._charge + amount)

    def level(self) -> float:
        return self._charge

    def is_sufficient_for(self, distance: float) -> bool:
        return self._charge >= distance / 5


class Vehicle:
    """Vehículo genérico que funciona con cualquier fuente de energía."""

    def __init__(self, power_source: PowerSource):
        self.power_source = power_source

    def drive(self, distance: float) -> None:
        self.power_source.consume(distance)
        print(f"Recorridos {distance}km. Energía restante: {self.power_source.level():.1f}")

    def refill(self, amount: float) -> None:
        self.power_source.replenish(amount)
        print(f"Reposto/recargado. Nivel actual: {self.power_source.level():.1f}")

    def can_reach(self, distance: float) -> bool:
        return self.power_source.is_sufficient_for(distance)


# La misma función funciona igual de bien con cualquier vehículo
def plan_trip(vehicle: Vehicle, distance: float) -> str:
    if vehicle.can_reach(distance):
        vehicle.drive(distance)
        return f"Viaje completado"
    else:
        return f"Sin suficiente energía para {distance}km"


# Uso
gasoline_car = Vehicle(FuelTank(50.0))
electric_car = Vehicle(Battery(75.0))

# plan_trip funciona igual con ambos, LSP respetado
print(plan_trip(gasoline_car, 100))
print(plan_trip(electric_car, 100))

# Añadir vehículo de hidrógeno no requiere modificar Vehicle ni plan_trip
class HydrogenCell(PowerSource):
    def __init__(self, capacity: float):
        self._capacity = capacity
        self._level = capacity

    def consume(self, distance: float) -> None:
        hydrogen = distance / 8  # 8 km por unidad
        self._level -= hydrogen

    def replenish(self, amount: float) -> None:
        self._level = min(self._capacity, self._level + amount)

    def level(self) -> float:
        return self._level

    def is_sufficient_for(self, distance: float) -> bool:
        return self._level >= distance / 8

hydrogen_car = Vehicle(HydrogenCell(40.0))
print(plan_trip(hydrogen_car, 200))  # Funciona sin cambiar nada

LSP garantiza que las abstracciones sean realmente sustituibles. La función plan_trip funciona con cualquier Vehicle, independientemente de qué PowerSource tenga por dentro. Eso es polimorfismo real.

En Clean Architecture, LSP garantiza que puedes intercambiar implementaciones sin romper el comportamiento esperado. El repositorio concreto que usa PostgreSQL debe comportarse exactamente igual (desde la perspectiva del caso de uso) que el repositorio en memoria que usas en tests. Si el de PostgreSQL tiene comportamientos distintos (lanza excepciones diferentes, devuelve objetos con estructura diferente), tienes un problema de LSP.

Principio de Inversión de Dependencias (DIP)

DIP es la pieza que ata todo lo demás y es el principio más directamente relacionado con la Regla de Dependencia de Clean Architecture.

La formulación es:

  1. Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.
  2. Las abstracciones no deben depender de detalles. Los detalles deben depender de abstracciones.

Primero, aclaremos qué significa «depender de» en código. Cuando una clase A crea internamente una instancia de clase B, o importa directamente desde el módulo donde vive B, entonces A depende de B. Si B cambia su interfaz, A puede romperse.

El punto clave de DIP no es simplemente «usa interfaces». Es que las interfaces deben ser definidas por los módulos de alto nivel, no por los de bajo nivel. El módulo de alto nivel (la lógica de negocio) es el dueño del contrato. Los módulos de bajo nivel (las implementaciones) son quienes se conforman a él.

Esto es lo que lo hace una «inversión»: históricamente, los módulos de alto nivel esperaban que los de bajo nivel les proveyeran las interfaces. DIP invierte eso: los módulos de alto nivel definen qué necesitan, y los de bajo nivel implementan ese contrato.

El problema clásico:

# Acoplamiento directo: la lógica de negocio depende de un detalle de implementación
class MySQLDatabase:
    def connect(self) -> None:
        print("Conectando a MySQL...")

    def insert(self, table: str, data: dict) -> None:
        print(f"INSERT INTO {table} VALUES {data}")

    def find(self, table: str, condition: str) -> dict:
        print(f"SELECT * FROM {table} WHERE {condition}")
        return {}  # Simplificado


class UserEntity:
    def __init__(self, user_id: str):
        self.user_id = user_id
        # Dependencia directa: UserEntity conoce MySQLDatabase
        self.database = MySQLDatabase()
        self.database.connect()

    def save(self) -> None:
        self.database.insert("users", {"id": self.user_id})

    def load(self) -> dict:
        return self.database.find("users", f"id = '{self.user_id}'")

Problemas con este diseño:

  • UserEntity está atado a MySQL. Cambiar a PostgreSQL requiere modificar la lógica de negocio.
  • No puedes testear UserEntity sin una base de datos MySQL real.
  • Si MySQL tiene problemas de conexión, los tests fallan aunque la lógica de negocio sea correcta.
  • La lógica de negocio está contaminada con un detalle de infraestructura.

DIP propone la solución:

from abc import ABC, abstractmethod
from typing import Optional

# La abstracción está definida en el módulo de alto nivel
# UserEntity es el dueño del contrato DatabaseInterface
class DatabaseInterface(ABC):
    @abstractmethod
    def insert(self, table: str, data: dict) -> None:
        pass

    @abstractmethod
    def find_by_id(self, table: str, id: str) -> Optional[dict]:
        pass

    @abstractmethod
    def update(self, table: str, id: str, data: dict) -> None:
        pass

    @abstractmethod
    def delete(self, table: str, id: str) -> None:
        pass


class UserEntity:
    def __init__(self, user_id: str, database: DatabaseInterface):
        self.user_id = user_id
        # Depende de la abstracción, no de la implementación
        # La dependencia se inyecta desde fuera (Dependency Injection)
        self.database = database

    def save(self) -> None:
        self.database.insert("users", {"id": self.user_id})

    def load(self) -> Optional[dict]:
        return self.database.find_by_id("users", self.user_id)

    def update_email(self, new_email: str) -> None:
        self.database.update("users", self.user_id, {"email": new_email})


# Las implementaciones concretas dependen de la abstracción (capas externas)
class MySQLDatabase(DatabaseInterface):
    def insert(self, table: str, data: dict) -> None:
        print(f"[MySQL] INSERT INTO {table} VALUES {data}")

    def find_by_id(self, table: str, id: str) -> Optional[dict]:
        print(f"[MySQL] SELECT * FROM {table} WHERE id = '{id}'")
        return {"id": id}  # Simplificado

    def update(self, table: str, id: str, data: dict) -> None:
        print(f"[MySQL] UPDATE {table} SET {data} WHERE id = '{id}'")

    def delete(self, table: str, id: str) -> None:
        print(f"[MySQL] DELETE FROM {table} WHERE id = '{id}'")


class PostgreSQLDatabase(DatabaseInterface):
    def insert(self, table: str, data: dict) -> None:
        print(f"[PostgreSQL] INSERT INTO {table} VALUES {data}")

    def find_by_id(self, table: str, id: str) -> Optional[dict]:
        print(f"[PostgreSQL] SELECT * FROM {table} WHERE id = '{id}'")
        return {"id": id}

    def update(self, table: str, id: str, data: dict) -> None:
        print(f"[PostgreSQL] UPDATE {table} SET {data} WHERE id = '{id}'")

    def delete(self, table: str, id: str) -> None:
        print(f"[PostgreSQL] DELETE FROM {table} WHERE id = '{id}'")


# Mock para tests: sin base de datos real
class InMemoryDatabase(DatabaseInterface):
    def __init__(self):
        self._storage: dict = {}

    def insert(self, table: str, data: dict) -> None:
        if table not in self._storage:
            self._storage[table] = {}
        self._storage[table][data.get("id", "")] = data

    def find_by_id(self, table: str, id: str) -> Optional[dict]:
        return self._storage.get(table, {}).get(id)

    def update(self, table: str, id: str, data: dict) -> None:
        if table in self._storage and id in self._storage[table]:
            self._storage[table][id].update(data)

    def delete(self, table: str, id: str) -> None:
        if table in self._storage:
            self._storage[table].pop(id, None)


# En producción
mysql_db = MySQLDatabase()
user = UserEntity("123", mysql_db)
user.save()

# Migrar a PostgreSQL: solo cambias la inyección
postgres_db = PostgreSQLDatabase()
user = UserEntity("123", postgres_db)
user.save()

# En tests: rápido, sin base de datos
import unittest

class TestUserEntity(unittest.TestCase):
    def setUp(self):
        self.db = InMemoryDatabase()
        self.user = UserEntity("test_123", self.db)

    def test_save_stores_user(self):
        self.user.save()
        stored = self.db.find_by_id("users", "test_123")
        self.assertIsNotNone(stored)
        self.assertEqual(stored["id"], "test_123")

    def test_update_email_modifies_stored_user(self):
        self.user.save()
        self.user.update_email("[email protected]")
        stored = self.db.find_by_id("users", "test_123")
        self.assertEqual(stored["email"], "[email protected]")

El test es limpio, rápido, sin efectos secundarios, sin dependencias externas. Eso es DIP en acción.

La inyección de dependencias (el hecho de pasar las dependencias como parámetros en lugar de crearlas internamente) es el mecanismo principal para implementar DIP. El lugar donde se «conectan» los módulos (donde decides qué implementación concreta usar) se llama el punto de composición o composition root, y generalmente está en la capa más externa de la aplicación.


El sistema de tipos de Python: type hints de verdad

Hasta aquí hemos visto cómo estructurar la arquitectura y cómo escribir clases bien diseñadas. Ahora añade una herramienta más que va a mejorar la calidad de tu código de forma tangible: el sistema de tipos de Python.

El problema del tipado dinámico en proyectos grandes

Python es un lenguaje dinámicamente tipado. Eso significa que las variables pueden cambiar de tipo durante la ejecución, y Python no se queja:

x = 5        # x es int
x = "hola"   # ahora x es str. Python no dice nada.
x = [1, 2]   # ahora es list. Sigue sin decir nada.
x = None     # ahora es NoneType. Silencio total.

Esto ofrece flexibilidad enorme para scripts y prototipos. Pero en proyectos grandes con múltiples módulos y múltiples desarrolladores, puede ser una fuente de errores sutiles que solo aparecen en tiempo de ejecución.

def add_numbers(a, b):
    return a + b

# Funciona
result = add_numbers(5, 3)   # 8

# Falla en tiempo de ejecución... a las 3 de la madrugada
result = add_numbers(5, "3")
# TypeError: unsupported operand type(s) for +: 'int' and 'str'

O peor aún:

def process_user(user):
    # Aquí asumimos que user tiene atributo 'email'
    # Pero nadie lo documenta, nadie lo verifica
    send_welcome_email(user.email)

# En algún lugar del código, alguien pasa un dict en lugar de un objeto User
process_user({"email": "[email protected]"})  # Funciona de casualidad

# En otro contexto, alguien pasa None
process_user(None)  # AttributeError: 'NoneType' object has no attribute 'email'

El segundo error solo se descubre cuando alguien ejecuta ese código en producción.

Qué son los type hints

Los type hints, introducidos en Python 3.5 con PEP 484, son anotaciones que puedes añadir a variables, parámetros y valores de retorno para indicar qué tipo se espera:

def add_numbers(a: int, b: int) -> int:
    return a + b

Los puntos clave sobre los type hints:

  • No cambian el comportamiento en tiempo de ejecución. Python sigue siendo dinámicamente tipado. El intérprete ignora las anotaciones al ejecutar el código.
  • Sirven como documentación activa. A diferencia de los comentarios, están vinculados directamente al código y las herramientas los usan.
  • Permiten análisis estático. Herramientas como mypy o pyright analizan el código sin ejecutarlo y detectan inconsistencias de tipos.
  • Mejoran el soporte del IDE. Con type hints, el autocompletado es más preciso y los errores se marcan en tiempo real.

La diferencia entre Python y lenguajes como TypeScript (que lleva tipo forzado a JavaScript de forma similar) es que Python los hace completamente opcionales. Puedes adoptar los type hints gradualmente, módulo por módulo, función por función.

Sintaxis básica de type hints

Para tipos simples:

# Variables
nombre: str = "Ana"
edad: int = 25
precio: float = 9.99
activo: bool = True

# Funciones - parámetros y tipo de retorno
def saludar(nombre: str, formal: bool = False) -> str:
    saludo = "Buenos días" if formal else "Hola"
    return f"{saludo}, {nombre}"

# None como tipo de retorno (función que no devuelve nada)
def log_error(message: str) -> None:
    print(f"ERROR: {message}")

Para colecciones (Python 3.9+ permite usar los tipos directamente):

# Python 3.9+
def get_user_ids() -> list[int]:
    return [1, 2, 3]

def get_config() -> dict[str, str]:
    return {"host": "localhost", "port": "5432"}

def get_coordinates() -> tuple[float, float]:
    return (40.4168, -3.7038)  # Madrid

# Para versiones anteriores, usa typing
from typing import List, Dict, Tuple
def get_user_ids() -> List[int]:
    return [1, 2, 3]

Para valores opcionales:

from typing import Optional

# Optional[X] es equivalente a X | None (Python 3.10+)
def find_user(user_id: int) -> Optional[str]:
    users = {1: "Ana", 2: "Carlos"}
    return users.get(user_id)  # Devuelve None si no existe

# Python 3.10+
def find_user(user_id: int) -> str | None:
    users = {1: "Ana", 2: "Carlos"}
    return users.get(user_id)

Para tipos que pueden ser varios:

from typing import Union

def parse_id(raw_id: Union[str, int]) -> int:
    return int(raw_id)

# Python 3.10+
def parse_id(raw_id: str | int) -> int:
    return int(raw_id)

TypedDict: tipado para diccionarios estructurados

En Python es muy común trabajar con diccionarios que tienen una estructura bien definida. TypedDict te permite documentar esa estructura y que las herramientas la verifiquen:

from typing import TypedDict

class UserData(TypedDict):
    id: int
    username: str
    email: str
    is_active: bool

class OrderData(TypedDict):
    id: str
    user_id: int
    total: float
    items: list[str]

def create_user(data: UserData) -> UserData:
    # El IDE sabe exactamente qué campos tiene 'data'
    # Si intentas acceder a data["telefono"], el IDE te avisa
    return {
        "id": data["id"],
        "username": data["username"],
        "email": data["email"],
        "is_active": True
    }

Tipos genéricos en repositorios

En Clean Architecture, los repositorios son una abstracción muy común. Con tipos genéricos, puedes crear un repositorio base reutilizable que mantiene la seguridad de tipos:

from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Optional, List

T = TypeVar('T')  # Variable de tipo genérica

class Repository(ABC, Generic[T]):
    """Repositorio genérico para cualquier entidad."""

    @abstractmethod
    def get_by_id(self, id: str) -> Optional[T]:
        pass

    @abstractmethod
    def get_all(self) -> List[T]:
        pass

    @abstractmethod
    def save(self, entity: T) -> None:
        pass

    @abstractmethod
    def delete(self, id: str) -> None:
        pass


# Repositorio específico para usuarios
class UserRepository(Repository[User]):
    def get_by_id(self, id: str) -> Optional[User]:
        pass  # implementación concreta

    def get_all(self) -> List[User]:
        pass

    def save(self, entity: User) -> None:
        pass

    def delete(self, id: str) -> None:
        pass


# El tipo genérico garantiza que el repositorio de usuarios
# trabaja con objetos User, no con cualquier cosa
user_repo = UserRepository()
user = user_repo.get_by_id("123")  # El IDE sabe que es Optional[User]
if user:
    print(user.username)  # El IDE sabe que User tiene atributo username

Type hints en Clean Architecture

Los type hints fortalecen todos los principios que hemos visto:

Con SRP: los type hints documentan exactamente qué hace cada método y qué necesita. Si ves def calculate_discount(amount: float, strategy: DiscountStrategy) -> float, está clarísimo que este método solo calcula descuentos usando una estrategia dada.

Con OCP: los type hints hacen visible la abstracción. shape: Shape en una firma documenta que el código trabaja con cualquier Shape, no con una implementación específica.

Con ISP: las interfaces pequeñas son más fáciles de tipar correctamente. Una interfaz con 15 métodos y tipos complejos es una señal de que viola ISP.

Con LSP: el type checker puede detectar si una subclase cambia los tipos de retorno de manera incompatible con la superclase.

Con DIP: cuando una función tiene repository: UserRepository en su firma, estás documentando explícitamente que depende de la abstracción, no de una implementación concreta.

from abc import ABC, abstractmethod
from typing import List, Optional
import math

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        """Calcula y devuelve el área de la forma."""
        pass

    @abstractmethod
    def perimeter(self) -> float:
        """Calcula y devuelve el perímetro de la forma."""
        pass


class Rectangle(Shape):
    def __init__(self, width: float, height: float) -> None:
        if width <= 0 or height <= 0:
            raise ValueError("Las dimensiones deben ser positivas")
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)


class Circle(Shape):
    def __init__(self, radius: float) -> None:
        if radius <= 0:
            raise ValueError("El radio debe ser positivo")
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        return 2 * math.pi * self.radius


class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area()

    def calculate_total_area(self, shapes: List[Shape]) -> float:
        return sum(shape.area() for shape in shapes)

    def find_largest(self, shapes: List[Shape]) -> Optional[Shape]:
        if not shapes:
            return None
        return max(shapes, key=lambda s: s.area())

Cada pieza de información importante está en la firma del método. Sin necesidad de leer la implementación, sabes qué acepta y qué devuelve.


mypy: el verificador de tipos que necesitas

Añadir type hints sin una herramienta que los verifique es como poner cinturones de seguridad en un coche y luego conducir sin abrocharlos. La forma de la protección está ahí, pero no cumple su función.

La herramienta más usada para verificación estática de tipos en Python es mypy.

Instalación y uso básico

pip install mypy

Supón que tienes este archivo user_service.py:

def get_user(user_id: int) -> dict:
    return {
        "id": user_id,
        "name": "Juan Pérez",
        "email": "[email protected]"
    }

def send_welcome_email(user: dict, subject: str) -> None:
    print(f"Enviando email a {user['email']}: {subject}")

def process_new_registration(raw_id: str) -> None:
    # Bug: pasamos string donde se espera int
    user = get_user(raw_id)
    send_welcome_email(user, "Bienvenido!")

Ejecutas mypy:

$ mypy user_service.py
user_service.py:12: error: Argument 1 to "get_user" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

mypy te dice exactamente en qué línea está el problema, qué tipo se esperaba y qué tipo se recibió. Sin ejecutar el código, sin datos de prueba, sin levantar ningún servidor.

Corriges el error añadiendo int(raw_id) y mypy lo confirma:

$ mypy user_service.py
Success: no issues found in 1 source file

Un ejemplo más realista con Optional:

from typing import Optional

def find_user_by_email(email: str) -> Optional[dict]:
    users_db = {"[email protected]": {"id": 1, "name": "Juan"}}
    return users_db.get(email)

def get_user_name(email: str) -> str:
    user = find_user_by_email(email)
    # Bug: no verificamos si user es None antes de acceder a sus campos
    return user["name"]  # mypy detectará este error
$ mypy user_service.py
user_service.py:9: error: Value of type "dict[str, int | str] | None" is not indexable  [index]
Found 1 error in 1 file (checked 1 source file)

mypy detecta que user puede ser None y que acceder a None["name"] causaría un TypeError en tiempo de ejecución. La solución:

def get_user_name(email: str) -> Optional[str]:
    user = find_user_by_email(email)
    if user is None:
        return None
    return user["name"]

Ahora mypy está satisfecho y el código maneja correctamente el caso de usuario no encontrado.

Configuración de mypy para proyectos reales

Para proyectos más serios, crea un archivo mypy.ini en la raíz del proyecto:

[mypy]
# Configuración general
python_version = 3.11
ignore_missing_imports = True

# Verificaciones estrictas que recomiendo activar
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_return_any = True
warn_unreachable = True
no_implicit_optional = True
check_untyped_defs = True

# Para proyectos con código legacy, excluye módulos específicos
[mypy.legacy_module]
ignore_errors = True

[mypy.old_package.*]
ignore_errors = True

Estas opciones:

  • strict_optional = True: fuerza que manejes None explícitamente. Si una función devuelve Optional[str], tienes que comprobar si es None antes de usar el resultado.
  • warn_return_any = True: avisa cuando una función devuelve Any. Any es básicamente «apaga el type checking aquí», y quieres saber cuándo pasa.
  • check_untyped_defs = True: verifica incluso funciones sin anotaciones de tipo, usando inferencia.
  • no_implicit_optional = True: en Python, def f(x: str = None) convierte implícitamente x a Optional[str]. Esta opción requiere que lo hagas explícito.

mypy en el pipeline de CI/CD

El valor real de mypy se multiplica cuando lo integras en tu pipeline de integración continua. Con esto, ningún código sin tipos correctos puede llegar a producción:

# .github/workflows/quality.yml
name: Python Quality Checks
on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install mypy pytest pytest-cov
        pip install -r requirements.txt

    - name: Run mypy type checking
      run: mypy src/

    - name: Run tests with coverage
      run: pytest tests/ --cov=src --cov-report=term-missing

    - name: Fail if coverage below 80%
      run: pytest tests/ --cov=src --cov-fail-under=80

Con este workflow, cada push y cada pull request pasan automáticamente por verificación de tipos y tests. Si alguien sube código con errores de tipos, el pipeline falla y no puede mergear.

También puedes añadir un pre-commit hook para verificación local antes de cada commit:

# .git/hooks/pre-commit
#!/bin/bash

echo "Ejecutando mypy..."
mypy src/
MYPY_EXIT=$?

echo "Ejecutando tests..."
pytest tests/ -x -q
PYTEST_EXIT=$?

if [ $MYPY_EXIT -ne 0 ] || [ $PYTEST_EXIT -ne 0 ]; then
    echo ""
    echo "ERROR: El commit fue rechazado. Corrije los errores antes de hacer commit."
    exit 1
fi

echo "Todo correcto. Haciendo commit..."

Así los errores se detectan antes de que lleguen al repositorio remoto.

Pylance en VS Code: feedback instantáneo

La verificación en terminal es útil, pero lo ideal es ver los errores mientras escribes. La extensión Pylance para VS Code (que usa pyright como motor) hace exactamente eso.

Cuando escribes código en VS Code con Pylance instalado:

  • Los errores de tipos aparecen subrayados en rojo inmediatamente, sin guardar el archivo.
  • Al pasar el ratón sobre un error, aparece la explicación precisa.
  • El autocompletado sugiere solo métodos y atributos que existen para ese tipo.
  • Al escribir user., el IDE sugiere username, email, user_id, etc., porque sabe que user es de tipo User.

Configura el nivel de verificación en settings.json:

{
    "python.analysis.typeCheckingMode": "basic",
    "python.analysis.autoImportCompletions": true,
    "python.analysis.inlayHints.functionReturnTypes": true,
    "python.analysis.inlayHints.variableTypes": true
}

El modo basic es el punto de entrada recomendado. El modo strict es más exigente y apropiado cuando el equipo está cómodo con el tipado completo.


Estrategia de adopción gradual

Si estás leyendo esto y tienes un proyecto existente con decenas de miles de líneas de código sin type hints ni arquitectura clara, no te desanimes. No tienes que reescribir todo de una vez. El camino es la adopción gradual e incremental.

Para la arquitectura

El camino típico para introducir arquitectura limpia en un proyecto existente:

Paso 1: Identifica el corazón del negocio. ¿Cuáles son las reglas de negocio más importantes de tu aplicación? ¿Qué código cambiaría si cambiaras de framework? Esas son las entidades y casos de uso que necesitas aislar.

Paso 2: Introduce tests primero. Antes de refactorizar, escribe tests que cubren el comportamiento actual. Los tests son tu red de seguridad durante la refactorización.

Paso 3: Extrae entidades. Crea clases simples para los conceptos principales del negocio. En esta fase, no te preocupes por la perfección; solo saca la lógica de negocio de los frameworks.

Paso 4: Define interfaces para las dependencias. Cuando la lógica de negocio depende de algo externo (base de datos, email, APIs de terceros), define una interfaz abstracta y empieza a inyectar la dependencia.

Paso 5: Mueve las implementaciones hacia afuera. Gradualmente mueve las implementaciones concretas (las que dependen de Django, SQLAlchemy, etc.) a la capa más externa.

No tienes que hacer todo de una vez. Cada módulo que refactorizas es una mejora. Cada test que añades reduce el riesgo de regresiones.

Para el tipado

La estrategia recomendada para introducir type hints en un proyecto existente:

Paso 1: Adopta la política de «código nuevo siempre tipado». Todo el código nuevo lleva type hints desde el día uno. No añades type hints al código viejo por ahora; solo al nuevo.

Paso 2: Instala mypy con configuración relajada. Configura ignore_missing_imports = True y excluye los módulos existentes con ignore_errors = True. Esto te permite usar mypy en el código nuevo sin bloquearte con el código viejo.

[mypy]
ignore_missing_imports = True

# Excluye módulos legacy hasta que los refactorices
[mypy.app.legacy.*]
ignore_errors = True

Paso 3: Prioriza las interfaces y contratos. Empieza añadiendo type hints a las interfaces abstractas (ABCs, Protocols) y a los casos de uso. Estas son las piezas que más se benefician del tipado porque documentan los contratos entre capas.

Paso 4: Crea tareas periódicas de deuda técnica. En cada sprint, incluye una o dos tareas de «añadir type hints al módulo X» y «habilitar mypy en el módulo Y». El progreso es incremental pero constante.

Paso 5: Aumenta la severidad gradualmente. Cuando un módulo esté completamente tipado y mypy pase sin errores, activa opciones más estrictas para ese módulo.

[mypy.app.entities.*]
strict = True  # Módulos completamente tipados: verificación estricta

[mypy.app.legacy.*]
ignore_errors = True  # Módulos pendientes: ignorar por ahora

La clave es no intentar hacer todo perfecto desde el primer día. Cada mejora incremental tiene valor real. Un módulo bien tipado es mejor que cero módulos tipados.


Errores comunes y cómo evitarlos

Después de entender los principios, hay errores frecuentes que vale la pena señalar explícitamente:

Error 1: Sobre-arquitectura en proyectos pequeños

No todo proyecto necesita cuatro capas, repositorios abstractos y un composition root explícito. Un script de 200 líneas o un microservicio con tres endpoints simples no necesitan Clean Architecture completa.

La regla práctica: empieza simple. Si el proyecto va a crecer, la arquitectura crecerá con él. Si no va a crecer, el código simple funciona perfectamente.

Error 2: Clases de servicio que hacen todo

Un anti-patrón común al aprender Clean Architecture es crear una capa de «servicios» donde cada servicio tiene 50 métodos y varios de ellos no están relacionados. Eso viola SRP igual que antes, solo que ahora la clase se llama «service» en lugar de «model».

Un servicio (o caso de uso) debería representar una acción concreta: CreateUserUseCase, SendNotificationUseCase, no UserService con 20 métodos.

Error 3: Violar la Regla de Dependencia por conveniencia

El error más común y más sutil: importar desde una capa exterior en una capa interior porque «es más fácil». Una entidad que importa django.db.models está violando la Regla de Dependencia aunque todo lo demás esté correcto. Usa herramientas de lint que verifiquen automáticamente las dependencias entre capas.

Error 4: Type hints como decoración

Añadir type hints pero no usar ninguna herramienta que los verifique. def procesar(datos: dict) -> None con el tipo dict es demasiado vago para ser útil. Si el diccionario tiene una estructura específica, define un TypedDict. Si la función acepta cualquier objeto con ciertos métodos, usa un Protocol.

Error 5: Tests que prueban la implementación, no el comportamiento

Un test que usa mocks para verificar que se llamó al método correcto con los parámetros correctos está probando la implementación, no el comportamiento. Esto hace los tests frágiles: si refactorizas la implementación (aunque el comportamiento sea el mismo), el test falla. Testea comportamiento, no implementación.


El patrón completo: todo junto

Para cerrar, mira cómo todo encaja en un ejemplo integrado. Un servicio de notificaciones que combina Clean Architecture, SOLID y type hints:

from abc import ABC, abstractmethod
from typing import List, Optional
from dataclasses import dataclass, field
import uuid
from datetime import datetime

# ============ ENTIDADES (capa más interna) ============

@dataclass
class Notification:
    """
    Entidad: representa una notificación en el dominio de negocio.
    Razón de cambio: si cambia la definición de una notificación.
    """
    recipient_email: str
    subject: str
    body: str
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    created_at: datetime = field(default_factory=datetime.now)
    sent: bool = False
    sent_at: Optional[datetime] = None

    def mark_as_sent(self) -> None:
        """Regla de negocio: marcar una notificación como enviada."""
        if self.sent:
            raise ValueError(f"La notificación {self.id} ya fue enviada")
        self.sent = True
        self.sent_at = datetime.now()


# ============ INTERFACES (capa de adaptadores) ============

class NotificationSender(ABC):
    """
    Interfaz abstracta para envío de notificaciones.
    Principio: ISP - interfaz específica para una capacidad.
    Principio: DIP - definida por el módulo de alto nivel.
    """
    @abstractmethod
    def send(self, notification: Notification) -> bool:
        """Envía la notificación. Devuelve True si fue exitoso."""
        pass


class NotificationRepository(ABC):
    """
    Interfaz abstracta para persistencia de notificaciones.
    Principio: DIP - la lógica de negocio define qué necesita.
    """
    @abstractmethod
    def save(self, notification: Notification) -> None:
        pass

    @abstractmethod
    def get_by_id(self, notification_id: str) -> Optional[Notification]:
        pass

    @abstractmethod
    def get_pending(self) -> List[Notification]:
        pass

    @abstractmethod
    def get_sent_count(self) -> int:
        pass


# ============ CASOS DE USO ============

class SendNotificationUseCase:
    """
    Caso de uso: enviar una notificación.
    Principio: SRP - una única responsabilidad.
    Principio: DIP - solo depende de abstracciones.
    """

    def __init__(
        self,
        sender: NotificationSender,
        repository: NotificationRepository
    ) -> None:
        self._sender = sender
        self._repository = repository

    def execute(
        self,
        recipient_email: str,
        subject: str,
        body: str
    ) -> Notification:
        """
        Crea y envía una notificación, guardando el resultado.
        Devuelve la notificación con su estado actualizado.
        """
        notification = Notification(
            recipient_email=recipient_email,
            subject=subject,
            body=body
        )

        success = self._sender.send(notification)

        if success:
            notification.mark_as_sent()

        self._repository.save(notification)

        return notification


class GetNotificationStatsUseCase:
    """
    Caso de uso: obtener estadísticas de notificaciones.
    Principio: SRP - responsabilidad diferente a SendNotification.
    """

    def __init__(self, repository: NotificationRepository) -> None:
        self._repository = repository

    def execute(self) -> dict:
        pending = self._repository.get_pending()
        sent_count = self._repository.get_sent_count()

        return {
            "pending_count": len(pending),
            "sent_count": sent_count,
            "total": len(pending) + sent_count
        }


# ============ IMPLEMENTACIONES CONCRETAS (capa más externa) ============

class EmailSender(NotificationSender):
    """
    Implementación concreta para email.
    Principio: OCP - se añade sin modificar NotificationSender.
    """
    def send(self, notification: Notification) -> bool:
        try:
            print(f"[EMAIL] To: {notification.recipient_email}")
            print(f"[EMAIL] Subject: {notification.subject}")
            print(f"[EMAIL] Body: {notification.body[:100]}...")
            return True
        except Exception as e:
            print(f"[EMAIL] Error: {e}")
            return False


class SMSSender(NotificationSender):
    """Implementación concreta para SMS."""
    def send(self, notification: Notification) -> bool:
        # El SMS solo puede tener 160 caracteres
        sms_body = notification.body[:160]
        print(f"[SMS] To: {notification.recipient_email}")
        print(f"[SMS] Message: {sms_body}")
        return True


class InMemoryNotificationRepository(NotificationRepository):
    """
    Repositorio en memoria para tests y desarrollo.
    Principio: LSP - se puede usar donde se espere NotificationRepository.
    """
    def __init__(self) -> None:
        self._storage: dict[str, Notification] = {}

    def save(self, notification: Notification) -> None:
        self._storage[notification.id] = notification

    def get_by_id(self, notification_id: str) -> Optional[Notification]:
        return self._storage.get(notification_id)

    def get_pending(self) -> List[Notification]:
        return [n for n in self._storage.values() if not n.sent]

    def get_sent_count(self) -> int:
        return sum(1 for n in self._storage.values() if n.sent)


# ============ COMPOSICIÓN (punto donde se inyectan las dependencias) ============

def create_notification_service() -> SendNotificationUseCase:
    """
    Factory function: aquí se decide qué implementaciones concretas usar.
    En producción podrías leer configuración para decidir entre EmailSender y SMSSender.
    """
    sender = EmailSender()
    repository = InMemoryNotificationRepository()
    return SendNotificationUseCase(sender, repository)


# ============ TESTS ============

import unittest

class TestSendNotificationUseCase(unittest.TestCase):
    def setUp(self) -> None:
        self.repo = InMemoryNotificationRepository()
        self.sender = EmailSender()
        self.use_case = SendNotificationUseCase(self.sender, self.repo)

    def test_send_creates_notification(self) -> None:
        notification = self.use_case.execute(
            recipient_email="[email protected]",
            subject="Bienvenido",
            body="Hola, bienvenido a la plataforma."
        )

        self.assertIsNotNone(notification.id)
        self.assertEqual(notification.recipient_email, "[email protected]")

    def test_sent_notification_is_marked_as_sent(self) -> None:
        notification = self.use_case.execute(
            "[email protected]", "Test", "Cuerpo del test"
        )

        self.assertTrue(notification.sent)
        self.assertIsNotNone(notification.sent_at)

    def test_sent_notification_is_saved_in_repository(self) -> None:
        notification = self.use_case.execute(
            "[email protected]", "Test", "Cuerpo"
        )

        saved = self.repo.get_by_id(notification.id)
        self.assertIsNotNone(saved)
        self.assertEqual(saved.recipient_email, "[email protected]")

    def test_stats_reflect_sent_notifications(self) -> None:
        self.use_case.execute("[email protected]", "Test 1", "Cuerpo 1")
        self.use_case.execute("[email protected]", "Test 2", "Cuerpo 2")

        stats_use_case = GetNotificationStatsUseCase(self.repo)
        stats = stats_use_case.execute()

        self.assertEqual(stats["sent_count"], 2)
        self.assertEqual(stats["pending_count"], 0)
        self.assertEqual(stats["total"], 2)


# ============ USO EN PRODUCCIÓN ============

if __name__ == "__main__":
    service = create_notification_service()

    notification = service.execute(
        recipient_email="[email protected]",
        subject="Tu pedido ha sido enviado",
        body="Tu pedido #1234 saldrá mañana entre las 9:00 y las 13:00."
    )

    print(f"\nNotificación enviada: {notification.id}")
    print(f"Estado: {'enviada' if notification.sent else 'pendiente'}")

Este ejemplo demuestra todos los principios en acción:

  • SRP: SendNotificationUseCase y GetNotificationStatsUseCase son casos de uso separados con responsabilidades distintas.
  • OCP: añadir WhatsAppSender no modifica SendNotificationUseCase ni NotificationSender.
  • ISP: NotificationSender y NotificationRepository son interfaces específicas y pequeñas.
  • LSP: EmailSender, SMSSender e InMemoryNotificationRepository son intercambiables con sus respectivas abstracciones.
  • DIP: SendNotificationUseCase recibe sus dependencias por el constructor, nunca las crea.
  • Clean Architecture: la Regla de Dependencia se cumple; el caso de uso no sabe nada de la implementación concreta.
  • Type hints: todo está tipado, mypy puede verificarlo sin ejecutar nada.

Los tests son limpios, sin bases de datos, sin servidores SMTP, sin configuración compleja. Y si algún día necesitas cambiar de email a SMS, cambias una línea en la función create_notification_service().


Conclusión: el código que aguanta

Todo lo que has leído en este artículo se puede resumir en una idea: el código que importa, el que contiene las reglas de tu negocio, merece protección.

Protección de los cambios tecnológicos. Hoy usas Django, mañana puede que no. Hoy usas PostgreSQL, mañana puede que migreis a cloud. Con Clean Architecture, esos cambios son cirugía localizada, no trasplante de corazón.

Protección de los errores humanos. Con SOLID escribes clases que hacen lo que prometen, que no tienen sorpresas ocultas, que se pueden intercambiar con confianza. Con type hints y mypy detectas errores antes de que lleguen a producción. Con tests bien escritos (que solo son posibles gracias a la arquitectura y los principios) detectas regresiones antes de que lleguen al cliente.

Protección del tiempo. Cada hora que inviertes en arquitectura hoy te ahorra tres horas de debugging mañana. Es una inversión con retorno garantizado.

Los principios que has aprendido aquí no son opiniones. Son el destilado de décadas de experiencia colectiva en el campo de la ingeniería de software. SRP, OCP, ISP, LSP y DIP existen porque miles de equipos aprendieron a golpes que violarlos tiene consecuencias concretas y dolorosas.

El camino no es perfecto ni inmediato. No vas a reescribir tu proyecto entero esta semana. Pero puedes empezar hoy con lo que puedas:

  • Aplica SRP a la próxima clase que escribas.
  • Añade type hints a la siguiente función.
  • Extrae la lógica de acceso a datos a un repositorio con interfaz abstracta.
  • Instala mypy y hazlo pasar en los módulos nuevos.

Cada decisión pequeña en la dirección correcta acumula. Y cuando mires hacia atrás en seis meses, verás la diferencia entre el código que aguanta y el código que duele.

El software que importa se construye con intención. Ahora tienes las herramientas.


Los ejemplos de este artículo están probados con Python 3.10 y superiores. Se recomienda Python 3.11+ para sacar el máximo partido a las características de tipado. Para verificación estática, instala mypy con pip install mypy y configúralo en mypy.ini según las necesidades de tu proyecto.


Patrones de diseño que complementan Clean Architecture

Los principios SOLID y la estructura de capas de Clean Architecture son el fundamento. Sobre ese fundamento, hay un conjunto de patrones de diseño que aparecen frecuentemente en proyectos bien arquitecturados. No son obligatorios, pero conocerlos te ayuda a resolver problemas recurrentes de manera elegante.

El patrón Repository

Ya lo hemos visto en los ejemplos, pero merece una explicación más detallada. El repositorio es la abstracción que separa la lógica de acceso a datos del resto de la aplicación.

La idea es simple: desde el punto de vista de los casos de uso, los datos «vienen de algún sitio» y «se guardan en algún sitio». El repositorio encapsula ese «algún sitio». Los casos de uso no necesitan saber si ese sitio es una base de datos relacional, un servicio externo, un archivo CSV o la memoria.

Un repositorio bien diseñado tiene un API orientado al dominio de negocio, no a la tecnología subyacente. No expone métodos como execute_query o cursor.fetchall(). Expone métodos como find_by_email, get_active_orders, save, delete. El lenguaje del repositorio es el lenguaje del negocio.

from abc import ABC, abstractmethod
from typing import List, Optional
from datetime import date

class Order:
    def __init__(self, order_id: str, customer_id: str, total: float):
        self.order_id = order_id
        self.customer_id = customer_id
        self.total = total
        self.status = "pending"
        self.created_at = date.today()


class OrderRepository(ABC):
    """
    El repositorio habla el lenguaje del negocio, no de la base de datos.
    Nótese: no hay cursors, no hay SQL, no hay ORM calls en esta interfaz.
    """

    @abstractmethod
    def get_by_id(self, order_id: str) -> Optional[Order]:
        pass

    @abstractmethod
    def get_by_customer(self, customer_id: str) -> List[Order]:
        pass

    @abstractmethod
    def get_pending_orders(self) -> List[Order]:
        pass

    @abstractmethod
    def get_orders_above_total(self, minimum_total: float) -> List[Order]:
        pass

    @abstractmethod
    def save(self, order: Order) -> None:
        pass

    @abstractmethod
    def delete(self, order_id: str) -> None:
        pass

La implementación concreta con SQLAlchemy podría verse así:

from sqlalchemy.orm import Session
from typing import List, Optional

class SQLAlchemyOrderRepository(OrderRepository):
    """
    Implementación concreta que usa SQLAlchemy.
    Esta clase vive en la capa externa; la interfaz vive en la capa interior.
    """

    def __init__(self, session: Session) -> None:
        self._session = session

    def get_by_id(self, order_id: str) -> Optional[Order]:
        # Aquí va el SQL/ORM real
        result = self._session.query(OrderModel).filter_by(id=order_id).first()
        if not result:
            return None
        return self._to_domain(result)

    def get_pending_orders(self) -> List[Order]:
        results = self._session.query(OrderModel).filter_by(status="pending").all()
        return [self._to_domain(r) for r in results]

    def save(self, order: Order) -> None:
        model = self._to_model(order)
        self._session.merge(model)
        self._session.commit()

    def delete(self, order_id: str) -> None:
        self._session.query(OrderModel).filter_by(id=order_id).delete()
        self._session.commit()

    def get_by_customer(self, customer_id: str) -> List[Order]:
        results = self._session.query(OrderModel).filter_by(customer_id=customer_id).all()
        return [self._to_domain(r) for r in results]

    def get_orders_above_total(self, minimum_total: float) -> List[Order]:
        results = self._session.query(OrderModel).filter(
            OrderModel.total >= minimum_total
        ).all()
        return [self._to_domain(r) for r in results]

    def _to_domain(self, model) -> Order:
        """Convierte el modelo de ORM a la entidad de dominio."""
        order = Order(model.id, model.customer_id, model.total)
        order.status = model.status
        return order

    def _to_model(self, order: Order):
        """Convierte la entidad de dominio al modelo de ORM."""
        model = OrderModel()
        model.id = order.order_id
        model.customer_id = order.customer_id
        model.total = order.total
        model.status = order.status
        return model

La conversión entre el modelo de ORM y la entidad de dominio (los métodos _to_domain y _to_model) es importante. Las entidades de dominio no deben ser modelos de ORM. Son objetos Python simples que encapsulan reglas de negocio. Los modelos de ORM son objetos técnicos que mapean a tablas de base de datos. Mantenerlos separados te da libertad para evolucionar cada uno independientemente.

El patrón Use Case (o Interactor)

Hemos mencionado los casos de uso varias veces. Aquí un patrón más completo para estructurarlos.

Cada caso de uso típicamente:

  1. Recibe un objeto de entrada (input) con los datos necesarios para ejecutar la acción.
  2. Ejecuta la lógica de negocio, usando entidades y repositorios.
  3. Devuelve un objeto de salida (output) con el resultado.
from dataclasses import dataclass
from typing import Optional

# Input: lo que el caso de uso necesita recibir
@dataclass(frozen=True)
class PlaceOrderInput:
    customer_id: str
    product_ids: list[str]
    discount_code: Optional[str] = None


# Output: lo que el caso de uso devuelve
@dataclass(frozen=True)
class PlaceOrderOutput:
    order_id: str
    total: float
    estimated_delivery: str
    success: bool
    error_message: Optional[str] = None


class PlaceOrderUseCase:
    def __init__(
        self,
        order_repo: OrderRepository,
        product_repo,  # ProductRepository
        customer_repo,  # CustomerRepository
        discount_service  # DiscountService
    ) -> None:
        self._order_repo = order_repo
        self._product_repo = product_repo
        self._customer_repo = customer_repo
        self._discount_service = discount_service

    def execute(self, input_data: PlaceOrderInput) -> PlaceOrderOutput:
        # 1. Verificar que el cliente existe
        customer = self._customer_repo.get_by_id(input_data.customer_id)
        if not customer:
            return PlaceOrderOutput(
                order_id="",
                total=0.0,
                estimated_delivery="",
                success=False,
                error_message=f"Cliente {input_data.customer_id} no encontrado"
            )

        # 2. Obtener los productos y calcular total
        products = [
            self._product_repo.get_by_id(pid)
            for pid in input_data.product_ids
            if self._product_repo.get_by_id(pid)
        ]
        base_total = sum(p.price for p in products)

        # 3. Aplicar descuento si hay código
        final_total = base_total
        if input_data.discount_code:
            discount = self._discount_service.calculate(
                input_data.discount_code, base_total
            )
            final_total = base_total - discount

        # 4. Crear la orden
        import uuid
        order = Order(str(uuid.uuid4()), input_data.customer_id, final_total)
        self._order_repo.save(order)

        # 5. Devolver el resultado
        return PlaceOrderOutput(
            order_id=order.order_id,
            total=final_total,
            estimated_delivery="3-5 días laborables",
            success=True
        )

Esta estructura tiene varias ventajas:

  • Testabilidad: puedes testear el caso de uso completo pasando mocks de los repositorios.
  • Claridad: el nombre del caso de uso expresa la intención de negocio.
  • Tipado: el input y el output son objetos tipados, no diccionarios genéricos.
  • Aislamiento: si el PlaceOrderUseCase falla en tests, sabes que el problema está en la lógica de negocio, no en la base de datos ni en el framework web.

El patrón Factory / Composition Root

Ya mencionamos el Composition Root: el lugar donde se «conectan» todos los módulos inyectando las dependencias concretas. En proyectos pequeños, puede ser tan simple como una función:

def create_place_order_use_case(db_session) -> PlaceOrderUseCase:
    return PlaceOrderUseCase(
        order_repo=SQLAlchemyOrderRepository(db_session),
        product_repo=SQLAlchemyProductRepository(db_session),
        customer_repo=SQLAlchemyCustomerRepository(db_session),
        discount_service=DiscountServiceImpl()
    )

En proyectos más grandes, puede ser un contenedor de inyección de dependencias. Python tiene varias librerías para esto:

  • dependency-injector: muy completo y configurable.
  • punq: más simple y ligero.
  • lagom: pythónico y con buen soporte de type hints.

El contenedor se configura una vez (generalmente al arrancar la aplicación) y luego resuelve automáticamente las dependencias cuando se pide una instancia:

from dependency_injector import containers, providers

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()

    db_session = providers.Singleton(
        create_db_session,
        connection_string=config.database.connection_string
    )

    order_repository = providers.Factory(
        SQLAlchemyOrderRepository,
        session=db_session
    )

    place_order_use_case = providers.Factory(
        PlaceOrderUseCase,
        order_repo=order_repository,
        # ... otras dependencias
    )

El contenedor resuelve el árbol de dependencias automáticamente. Cuando pides place_order_use_case, el contenedor sabe que necesita un order_repository, que a su vez necesita una db_session, y los construye en el orden correcto.


Testing en una arquitectura limpia

Los tests son una parte central de Clean Architecture, no un añadido opcional. La arquitectura limpia está diseñada específicamente para ser testeable, y los buenos tests te dicen si tu arquitectura está bien diseñada.

Pirámide de tests

En una arquitectura limpia, la distribución natural de tests sigue la pirámide:

Tests unitarios (base amplia): prueban entidades y casos de uso de forma aislada, con mocks para las dependencias externas. Son rápidos (milisegundos), deterministas y no tienen efectos secundarios. Son la mayoría de tus tests.

Tests de integración (capa media): prueban que los adaptadores (repositorios, controladores) funcionan correctamente con las implementaciones reales. Por ejemplo, verificar que SQLAlchemyOrderRepository realmente guarda y recupera órdenes correctamente en una base de datos de test. Son más lentos pero todavía manejables.

Tests end-to-end (capa alta, pocos): prueban el sistema completo desde la interfaz de usuario hasta la base de datos. Son lentos, frágiles y caros de mantener. Deben ser pocos y cubrir los flujos críticos.

Cómo escribir buenos tests unitarios de casos de uso

import unittest
from unittest.mock import MagicMock, call
from typing import Optional, List

class TestPlaceOrderUseCase(unittest.TestCase):
    def setUp(self) -> None:
        # Mocks de todas las dependencias
        self.order_repo = MagicMock(spec=OrderRepository)
        self.product_repo = MagicMock()
        self.customer_repo = MagicMock()
        self.discount_service = MagicMock()

        # El caso de uso recibe los mocks
        self.use_case = PlaceOrderUseCase(
            order_repo=self.order_repo,
            product_repo=self.product_repo,
            customer_repo=self.customer_repo,
            discount_service=self.discount_service
        )

    def test_returns_error_when_customer_not_found(self) -> None:
        # Configuramos el mock para devolver None (cliente no encontrado)
        self.customer_repo.get_by_id.return_value = None

        input_data = PlaceOrderInput(
            customer_id="cliente_inexistente",
            product_ids=["prod_1"]
        )

        result = self.use_case.execute(input_data)

        self.assertFalse(result.success)
        self.assertIn("no encontrado", result.error_message)
        # Verificamos que NO se guardó ninguna orden
        self.order_repo.save.assert_not_called()

    def test_saves_order_when_everything_is_valid(self) -> None:
        # Configuramos mocks para un caso exitoso
        mock_customer = MagicMock()
        mock_customer.customer_id = "cliente_1"
        self.customer_repo.get_by_id.return_value = mock_customer

        mock_product = MagicMock()
        mock_product.price = 50.0
        self.product_repo.get_by_id.return_value = mock_product

        input_data = PlaceOrderInput(
            customer_id="cliente_1",
            product_ids=["prod_1", "prod_2"]
        )

        result = self.use_case.execute(input_data)

        self.assertTrue(result.success)
        self.assertEqual(result.total, 100.0)  # 2 productos a 50€ cada uno
        # Verificamos que se guardó la orden
        self.order_repo.save.assert_called_once()

    def test_applies_discount_when_code_provided(self) -> None:
        mock_customer = MagicMock()
        self.customer_repo.get_by_id.return_value = mock_customer

        mock_product = MagicMock()
        mock_product.price = 100.0
        self.product_repo.get_by_id.return_value = mock_product

        # El servicio de descuentos devuelve 10€ de descuento
        self.discount_service.calculate.return_value = 10.0

        input_data = PlaceOrderInput(
            customer_id="cliente_1",
            product_ids=["prod_1"],
            discount_code="DESCUENTO10"
        )

        result = self.use_case.execute(input_data)

        self.assertEqual(result.total, 90.0)  # 100 - 10 de descuento
        self.discount_service.calculate.assert_called_once_with("DESCUENTO10", 100.0)

Estos tests:

  • Son completamente independientes de bases de datos, servicios externos o configuración.
  • Ejecutan en milisegundos.
  • Prueban el comportamiento exacto del caso de uso, no su implementación interna.
  • Son claros y legibles: cada test tiene un nombre que describe exactamente qué verifica.

Los tests como señal de diseño

Una cosa importante: los tests no solo verifican que el código funciona. También te dicen si tu arquitectura es buena.

Si un test de caso de uso requiere un setup de 100 líneas con mocks complejos, es una señal de que el caso de uso tiene demasiadas dependencias. Probablemente viola SRP.

Si un test de entidad necesita levantar una base de datos, es una señal de que la entidad tiene dependencias de infraestructura. Probablemente viola DIP.

Si es difícil crear los mocks porque las interfaces son muy grandes, es una señal de que las interfaces violan ISP.

Los tests te dan feedback sobre el diseño incluso antes de que el código llegue a producción. Aprende a leer esas señales.


Herramientas del ecosistema Python para arquitectura limpia

Para terminar, una lista de herramientas que te ayudan a mantener una arquitectura limpia en Python:

import-linter: verifica que las importaciones entre módulos respetan las reglas de dependencia que definas. Puedes configurar que entities no puede importar desde frameworks, y la herramienta verifica esto automáticamente en cada CI run.

pip install import-linter
# setup.cfg
[importlinter]
root_package = myapp

[importlinter:contract:entities-dont-import-frameworks]
name = Las entidades no pueden importar de frameworks
type = forbidden
source_modules =
    myapp.entities
forbidden_modules =
    myapp.frameworks
    myapp.interfaces

mypy: ya lo hemos visto en detalle. Verificación estática de tipos.

pydantic: validación de datos con type hints. Muy útil para los objetos de entrada y salida de casos de uso, y para validar datos que llegan desde APIs externas.

from pydantic import BaseModel, EmailStr

class CreateUserInput(BaseModel):
    username: str
    email: EmailStr  # Pydantic verifica el formato del email
    age: int

    class Config:
        # Convierte el modelo a inmutable
        frozen = True

pytest: el framework de testing más popular en Python. Con fixtures, puede gestionar el ciclo de vida de las dependencias de forma elegante.

dataclasses: ya los hemos usado. Reducen el boilerplate de las entidades simples. @dataclass(frozen=True) crea objetos inmutables que son perfectos para los input/output de casos de uso.


Con este conjunto de herramientas y los principios que hemos visto a lo largo del artículo, tienes todo lo necesario para construir aplicaciones Python que escalen bien, sean fáciles de mantener y aguanten el paso del tiempo. El camino es gradual, pero cada paso en la dirección correcta tiene valor inmediato.


Preguntas frecuentes que todo estudiante tiene

Antes de cerrar definitivamente, hay preguntas que aparecen siempre cuando alguien empieza a aplicar estos conceptos. Las respondo directamente.

¿Tengo que aplicar todos los principios SOLID en cada proyecto?

No. Los principios son guías, no reglas absolutas. En un script de 100 líneas, aplicar todos los principios es sobre-ingeniería. En un proyecto con 10 desarrolladores y tres años de vida, no aplicarlos es negligencia. El buen criterio está en saber cuánto de cada principio aplica en tu contexto específico.

Lo que sí puedes hacer siempre, independientemente del tamaño del proyecto, es pensar en SRP al diseñar cada clase. Es el principio con mayor retorno de inversión en proyectos de cualquier tamaño.

¿Clean Architecture es lo mismo que Domain-Driven Design (DDD)?

No son lo mismo, pero se complementan muy bien. Clean Architecture es una propuesta de estructura en capas con una regla de dependencia. DDD es un conjunto de patrones para modelar el dominio de negocio: Aggregates, Value Objects, Domain Events, Bounded Contexts.

Puedes usar Clean Architecture sin DDD (simplemente tendrás entidades más simples). Puedes usar DDD dentro de Clean Architecture (la capa de entidades se convierte en un dominio rico con los patrones de DDD). Juntos son muy potentes para sistemas complejos.

¿Cuándo es demasiado tarde para introducir arquitectura limpia en un proyecto existente?

Nunca es demasiado tarde, pero el coste aumenta con el tiempo. El mejor momento para refactorizar es cuando tienes una historia de usuario nueva que va a tocar el módulo problemático. En ese momento, primero refactorizas el módulo para que tenga buena arquitectura, luego añades la nueva funcionalidad. El coste del refactoring se amortiza con la nueva feature.

Lo que definitivamente no funciona es intentar refactorizar todo el proyecto de una vez mientras el equipo sigue añadiendo features. Eso es el caos. Hazlo gradualmente, módulo por módulo, oportunidad por oportunidad.

¿Los type hints afectan el rendimiento de mi aplicación?

No. Las anotaciones de tipo son metadatos que Python carga en memoria pero no usa durante la ejecución. El impacto en rendimiento es prácticamente nulo en la mayoría de aplicaciones. Si tienes una aplicación extremadamente sensible al rendimiento, puedes usar from __future__ import annotations para hacer que las anotaciones sean strings lazy-evaluated.

¿mypy ralentiza el proceso de desarrollo?

Al principio puede dar esa sensación, especialmente cuando encuentras errores que no esperabas. Pero después de unas semanas, mypy acelera el desarrollo porque detecta errores que de otra forma habrías encontrado en tests, en review o peor, en producción. El tiempo que «pierdes» corrigiendo errores de tipos que encuentra mypy es una fracción del tiempo que habrías perdido debugging esos mismos errores en tiempo de ejecución.

¿Tiene sentido usar type hints en tests?

Sí, tiene sentido aunque hay debate. Los tests se benefician de los type hints de la misma manera que el código principal: el IDE te ayuda más, los errores de tipos se detectan antes. Además, los tests son clientes de tu código; si los tests están tipados, son una verificación adicional de que las interfaces de tu código son claras y usables.

¿Qué pasa con el performance de mypy en proyectos grandes?

mypy puede ser lento en proyectos muy grandes (cientos de miles de líneas). La solución es usar dmypy, el servidor daemon de mypy que mantiene el estado entre ejecuciones y solo re-analiza los archivos que han cambiado. La segunda ejecución de dmypy es mucho más rápida que la primera porque reutiliza el análisis previo.

# Primera vez: inicia el daemon y hace el análisis completo
dmypy run -- src/

# Siguientes veces: solo re-analiza lo que cambió
dmypy run -- src/

¿Vale la pena aprender todo esto si trabajo solo?

Absolutamente. Trabajando solo también tienes que entender tu propio código seis meses después de escribirlo. También necesitas que los tests sean rápidos para que los ejecutes con frecuencia. También te beneficias de poder cambiar implementaciones sin romper la lógica de negocio.

La arquitectura no es solo para equipos grandes. Es para cualquier proyecto que quieras mantener en el tiempo.


Recursos para seguir aprendiendo

El camino no termina aquí. Estos son los recursos que más pueden complementar lo que has aprendido en este artículo:

Para profundizar en principios SOLID con ejemplos en Python, el tutorial de Real Python sobre SOLID principles es uno de los más completos disponibles en la web: explora cada principio con ejemplos paso a paso y muestra cómo se aplican en código real.

Para tipado en Python, la documentación oficial de mypy (mypy.readthedocs.io) es la referencia definitiva. El cheatsheet de tipos de mypy es especialmente útil como referencia rápida cuando no recuerdas la sintaxis exacta de un tipo complejo.

Para testing en Python, la documentación de pytest y unittest.mock son esenciales. Aprender a usar fixtures de pytest y a crear mocks precisos transforma cómo escribes tests.

Para patrones de diseño que complementan la arquitectura limpia, el libro de patrones de Python de Brandon Rhodes (disponible en python-patterns.guide) cubre decenas de patrones con explicaciones claras de cuándo y cómo aplicarlos.

Para la integración continua y cómo estructurar pipelines de calidad que incluyan mypy, pytest y otros checks automáticos, la guía de Real Python sobre CI con Python cubre la integración con GitHub Actions y otras plataformas.

Y para practicar: la mejor forma de aprender arquitectura es aplicarla. Toma un proyecto que tengas, aunque sea pequeño, e intenta identificar las entidades, definir las interfaces de los repositorios, separar los casos de uso. Cada iteración te enseña más que leer diez artículos.

El código bien estructurado no es un destino, es una práctica diaria. Cada clase que escribes es una oportunidad para aplicar lo que sabes. Empieza hoy.

Sobre el autor

Sergio Perea — Fundador & Editor en Eurekadev
Sergio Perea· Fundador & Editor

Apasionado por la tecnología y la innovación, con más de 20 años de experiencia en desarrollo de software y consultoría tecnológica. Su trayectoria profesional comenzó en 2001 como programador, evolucionando desde entonces combinando su amor por el código con una sólida visión de negocio.

Ha trabajado tanto en España como en el extranjero, en sectores diversos como telecomunicaciones, banca, seguros y marketing digital. Esta experiencia multidisciplinar le permite entender los retos técnicos desde una perspectiva de negocio real.

Hoy aporta su experiencia asesorando en la modernización de procesos y la implementación de herramientas tecnológicas que optimizan la gestión y las relaciones con clientes. Se especializa en ayudar a equipos a integrar inteligencia artificial de forma práctica y responsable.

Cree firmemente en el aprendizaje continuo y que el verdadero progreso solo se logra creciendo juntos.