Arquitectura Limpia en Python: Guía Práctica para Proyectos Escalables
Uno de los problemas más comunes que veo cuando llego a un proyecto Python heredado es lo mismo de siempre: lógica de negocio mezclada con acceso a base de datos, controladores HTTP que validan, calculan y persisten al mismo tiempo, y tests que no existen porque "todo está tan acoplado que es imposible probar nada sin levantar toda la aplicación".
La Arquitectura Limpia (Clean Architecture), propuesta por Robert C. Martin, no es una bala de plata — pero aplicada con criterio en Python resuelve exactamente estos problemas. En este artículo te muestro cómo hacerlo con ejemplos de código reales, sin dogmatismos y adaptado al ecosistema Python moderno.
¿Qué es la Arquitectura Limpia y por qué importa en Python?
La idea central es simple: el código de negocio no debe depender de detalles de infraestructura. Tu lógica de dominio no debe saber si los datos vienen de PostgreSQL, MongoDB, una API externa o un archivo CSV. Tampoco debe saber si la interfaz es un endpoint FastAPI, un comando CLI o un mensaje de Kafka.
Esto se logra organizando el código en capas concéntricas donde las dependencias siempre apuntan hacia adentro:
La regla de dependencia es absoluta: las capas externas pueden conocer las internas, pero nunca al revés. Esto es lo que hace que el código sea testeable, intercambiable y mantenible a largo plazo.
Estructura de carpetas en un proyecto real
Así organizo los proyectos en AC-Consulting cuando aplicamos esta arquitectura:
mi_proyecto/
├── domain/ # Capa de dominio — sin dependencias externas
│ ├── entities/
│ │ └── usuario.py # Clases de negocio puras
│ ├── repositories/
│ │ └── usuario_repo.py # Interfaces (ABCs)
│ └── exceptions.py # Excepciones de dominio
│
├── application/ # Casos de uso — solo depende de domain/
│ └── use_cases/
│ ├── crear_usuario.py
│ └── obtener_usuario.py
│
├── infrastructure/ # Implementaciones concretas
│ ├── database/
│ │ └── sqlalchemy_usuario_repo.py
│ └── external/
│ └── email_service.py
│
├── interfaces/ # Adaptadores de entrada
│ ├── api/
│ │ └── routes/usuario.py
│ └── cli/
│ └── commands.py
│
└── main.py # Composición y arranque (DI)
Implementación paso a paso
1. La Entidad de Dominio
Las entidades son el corazón del sistema. Son clases Python puras — sin ORM, sin validación de HTTP, sin nada externo. Solo lógica de negocio:
# domain/entities/usuario.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID, uuid4
@dataclass
class Usuario:
nombre: str
email: str
id: UUID = field(default_factory=uuid4)
creado_en: datetime = field(default_factory=datetime.utcnow)
activo: bool = True
def desactivar(self) -> None:
"""Lógica de negocio: desactivar usuario."""
if not self.activo:
raise ValueError("El usuario ya está inactivo.")
self.activo = False
def cambiar_email(self, nuevo_email: str) -> None:
if "@" not in nuevo_email:
raise ValueError(f"Email inválido: {nuevo_email}")
self.email = nuevo_email
Nota que Usuario no hereda de nada externo. No tiene decoradores de SQLAlchemy, no importa Pydantic. Es Python puro — y por eso es trivialmente testeable.
2. El Repositorio como Interfaz (Abstracción)
Definimos qué operaciones necesitamos sobre usuarios sin decir cómo se implementan:
# domain/repositories/usuario_repo.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.entities.usuario import Usuario
class UsuarioRepository(ABC):
@abstractmethod
def guardar(self, usuario: Usuario) -> None:
...
@abstractmethod
def obtener_por_id(self, id: UUID) -> Usuario | None:
...
@abstractmethod
def obtener_por_email(self, email: str) -> Usuario | None:
...
domain/ — la capa más interna. La implementación concreta estará en infrastructure/, pero el dominio nunca la importa directamente.
3. El Caso de Uso
Los casos de uso orquestan las entidades y los repositorios. Aquí vive la lógica de aplicación — no de negocio puro, sino del flujo de la operación:
# application/use_cases/crear_usuario.py
from dataclasses import dataclass
from domain.entities.usuario import Usuario
from domain.repositories.usuario_repo import UsuarioRepository
from domain.exceptions import EmailYaRegistrado
@dataclass
class CrearUsuarioInput:
nombre: str
email: str
@dataclass
class CrearUsuarioOutput:
id: str
nombre: str
email: str
class CrearUsuario:
def __init__(self, repo: UsuarioRepository) -> None:
self._repo = repo
def ejecutar(self, datos: CrearUsuarioInput) -> CrearUsuarioOutput:
# Verificar unicidad
if self._repo.obtener_por_email(datos.email):
raise EmailYaRegistrado(datos.email)
usuario = Usuario(nombre=datos.nombre, email=datos.email)
self._repo.guardar(usuario)
return CrearUsuarioOutput(
id=str(usuario.id),
nombre=usuario.nombre,
email=usuario.email,
)
4. La Implementación Concreta del Repositorio
Aquí vive SQLAlchemy, psycopg2, o lo que uses. Esta capa puede cambiar sin tocar nada de la lógica de negocio:
# infrastructure/database/sqlalchemy_usuario_repo.py
from uuid import UUID
from sqlalchemy.orm import Session
from domain.entities.usuario import Usuario
from domain.repositories.usuario_repo import UsuarioRepository
from infrastructure.database.models import UsuarioModel
class SQLAlchemyUsuarioRepository(UsuarioRepository):
def __init__(self, session: Session) -> None:
self._session = session
def guardar(self, usuario: Usuario) -> None:
modelo = UsuarioModel(
id=str(usuario.id),
nombre=usuario.nombre,
email=usuario.email,
activo=usuario.activo,
)
self._session.add(modelo)
self._session.commit()
def obtener_por_id(self, id: UUID) -> Usuario | None:
modelo = self._session.get(UsuarioModel, str(id))
if not modelo:
return None
return Usuario(
id=modelo.id,
nombre=modelo.nombre,
email=modelo.email,
activo=modelo.activo,
)
def obtener_por_email(self, email: str) -> Usuario | None:
modelo = self._session.query(UsuarioModel).filter_by(email=email).first()
if not modelo:
return None
return Usuario(id=modelo.id, nombre=modelo.nombre, email=modelo.email)
5. El Adaptador de Entrada (FastAPI)
# interfaces/api/routes/usuario.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from application.use_cases.crear_usuario import CrearUsuario, CrearUsuarioInput
from domain.exceptions import EmailYaRegistrado
from infrastructure.dependencies import get_crear_usuario # inyección
router = APIRouter(prefix="/usuarios", tags=["usuarios"])
class CrearUsuarioRequest(BaseModel):
nombre: str
email: str
@router.post("/", status_code=201)
def crear_usuario(
body: CrearUsuarioRequest,
use_case: CrearUsuario = Depends(get_crear_usuario),
):
try:
resultado = use_case.ejecutar(
CrearUsuarioInput(nombre=body.nombre, email=body.email)
)
return resultado
except EmailYaRegistrado as e:
raise HTTPException(status_code=409, detail=str(e))
Por qué esto hace tus tests un 10x más fáciles
El mayor beneficio inmediato es el testing. Con este diseño, puedes testear el caso de uso con un repositorio en memoria — sin base de datos, sin fixtures complejos, sin Docker:
# tests/test_crear_usuario.py
import pytest
from application.use_cases.crear_usuario import CrearUsuario, CrearUsuarioInput
from domain.exceptions import EmailYaRegistrado
class RepositorioEnMemoria:
def __init__(self):
self._store = {}
def guardar(self, usuario):
self._store[str(usuario.id)] = usuario
def obtener_por_id(self, id):
return self._store.get(str(id))
def obtener_por_email(self, email):
return next((u for u in self._store.values() if u.email == email), None)
def test_crear_usuario_exitoso():
repo = RepositorioEnMemoria()
use_case = CrearUsuario(repo)
resultado = use_case.ejecutar(
CrearUsuarioInput(nombre="Ángel", email="hola@ac-consulting.net")
)
assert resultado.email == "hola@ac-consulting.net"
assert resultado.nombre == "Ángel"
def test_email_duplicado_lanza_excepcion():
repo = RepositorioEnMemoria()
use_case = CrearUsuario(repo)
datos = CrearUsuarioInput(nombre="Ángel", email="hola@ac-consulting.net")
use_case.ejecutar(datos)
with pytest.raises(EmailYaRegistrado):
use_case.ejecutar(datos)
Tests que corren en milisegundos, sin estado externo, sin mocks frágiles.
¿Cuándo aplicarla y cuándo no?
Sería deshonesto no decirlo: la Arquitectura Limpia añade capas y archivos. Para un script de 200 líneas o un CRUD simple sin lógica de negocio, es sobredimensionada. Úsala cuando:
- El proyecto tiene lógica de negocio real y compleja
- Esperas cambiar la base de datos o el framework en el futuro
- El equipo tiene más de una persona y necesitan fronteras claras
- Necesitas alta cobertura de tests sin depender de infraestructura
- El sistema debe mantenerse durante 2+ años
Para proyectos pequeños, un buen diseño por módulos con separación básica de responsabilidades es suficiente. No siempre necesitas las 4 capas completas — a veces con separar domain/ de infrastructure/ es suficiente para obtener el 80% del beneficio.
Conclusión
La Arquitectura Limpia en Python no es complicada — es disciplinada. El mayor beneficio no es el diseño elegante sino la capacidad de cambiar partes del sistema sin miedo: cambiar de PostgreSQL a MongoDB, de FastAPI a Django, de SQLAlchemy a un ORM diferente, todo sin tocar una línea de lógica de negocio.
En AC-Consulting aplicamos estos principios en proyectos de clientes en Colombia que necesitan software que dure y escale. Si estás construyendo algo que debe sobrevivir más de 6 meses, vale la pena hacerlo bien desde el principio.
¿Necesitas revisar la arquitectura de tu proyecto Python?
En AC-Consulting hacemos auditorías de código y refactoring con foco en mantenibilidad y escalabilidad. Primera sesión sin costo.