Guía de Arquitectura Limpia
Proyecto CRUDrugstore — Python / FastAPI — Entidad de ejemplo: Categoría
1. ¿Qué es Clean Architecture?
La Arquitectura Limpia (Clean Architecture) es un diseño de software propuesto por Robert C. Martin ("Uncle Bob"). Su objetivo es separar el código en capas concéntricas donde las dependencias siempre apuntan hacia el centro.
En el contexto de CRUDrugstore, esto significa que nuestra API para gestionar una drugstore está organizada de manera que:
- El dominio define qué es una categoría, un producto, un cliente
- La aplicación define qué operaciones se pueden hacer (crear, obtener, listar, etc.)
- La infraestructura sabe cómo guardar eso en MySQL
- La API expone esos procesos como endpoints HTTP
Separación de Concerns
Cada capa tiene UNA sola responsabilidad
Testabilidad
Se puede testear cada capa de forma aislada
Independencia
El núcleo no depende de frameworks ni bases de datos
Cambios Fáciles
Cambiar MySQL por PostgreSQL solo toca 1 capa
Diagrama de Círculos Concéntricos
Las flechas de dependencia van de afuera hacia adentro →
¿Por qué importa en la práctica?
Imaginá que mañana decidís cambiar de MySQL a PostgreSQL. Sin Clean Architecture, tendrías que buscar y modificar todos los archivos que contengan código SQL. Con Clean Architecture, solo modificás los archivos de la capa de Infraestructura — los otros 3 niveles ni se enteran.
Otro ejemplo: si querés agregar un endpoint de Telegram para consultar categorías, solo agregás una nueva capa de presentación. Los casos de uso y el dominio ya están listos para reusarse.
Sin Clean Arch
Cambiar BD = tocar 50+ archivos, reescribir lógica de negocio, arriesgar bugs
Con Clean Arch
Cambiar BD = modificar 2-3 archivos en infraestructura, cero riesgo
2. Las 4 Capas del Proyecto
El proyecto CRUDrugstore está organizado en 4 carpetas principales, cada una representando una capa de la arquitectura. Cada capa solo puede "ver" hacia adentro (hacia el dominio), nunca hacia afuera.
CRUDrugstore/
├──
api/ — Rutas, Esquemas Pydantic, Dependencias
├──
aplicacion/ — Casos de Uso (un directorio por entidad)
├──
infraestructura/ — Repositorios MySQL, Conexión BD, JWT
├──
dominio/ — Entidades, Interfaces ABC, Excepciones, Validaciones
└──
main.py — Punto de entrada (FastAPI + CORS)
Archivos de Categoría en cada capa
| Capa | Carpeta | Archivos |
|---|---|---|
| Dominio | dominio/ | entidades/categoria.py, repositorios/repositorio_categoria.py, excepciones.py, validaciones.py |
| Infraestr. | infraestructura/ | repositorios/repositorio_categoria_mysql.py, basedatos/conexion.py |
| Aplicación | aplicacion/ | casos_de_uso/categorias/crear_categoria.py, obtener_categoria.py, listar_categorias.py, actualizar_categoria.py, eliminar_categoria.py |
| API | api/ | rutas/rutas_categorias.py, esquemas.py, dependencias.py |
3. Capa de Dominio — El Corazón
El dominio es el núcleo de la aplicación. Define qué es una categoría, cómo se valida, y qué operaciones se pueden hacer con ella. Nunca importa de fuera.
# dominio/entidades/categoria.py
# Representa una fila de la tabla 'categorias'.
# El dominio define QUÉ es una categoría y sus reglas.
from dominio.validaciones import validar_no_vacio
class Categoria:
def __init__(self, nombre: str, id_categoria: int = None):
self.id_categoria = id_categoria
self.nombre = nombre
def validar(self):
validar_no_vacio(self.nombre, 'nombre')
# dominio/repositorios/repositorio_categoria.py
# Este es un CONTRATO. Define qué operaciones se pueden hacer
# con categorías, pero NO dice cómo se hacen.
from abc import ABC, abstractmethod
from dominio.entidades.categoria import Categoria
class RepositorioCategoria(ABC):
@abstractmethod
def crear(self, categoria: Categoria) -> Categoria:
pass
@abstractmethod
def obtener_por_id(self, id_categoria: int) -> Categoria | None:
pass
@abstractmethod
def obtener_todos(self) -> list[Categoria]:
pass
@abstractmethod
def actualizar(self, categoria: Categoria) -> Categoria:
pass
@abstractmethod
def eliminar(self, id_categoria: int) -> None:
pass
# dominio/excepciones.py
# Son errores propios del negocio, NO errores técnicos.
class ErrorDeValidacion(Exception):
"""Se lanza cuando un dato no cumple las reglas del negocio."""
pass
class EntidadNoEncontrada(Exception):
"""Se lanza cuando se busca algo que no existe."""
pass
class EntidadDuplicada(Exception):
"""Se lanza cuando se intenta crear algo que ya existe."""
pass
# dominio/validaciones.py
# Funciones que verifican que los datos cumplan las reglas
# del negocio ANTES de guardarlos en la base de datos.
import re
from dominio.excepciones import ErrorDeValidacion
def validar_solo_letras(valor: str, nombre_campo: str):
if not re.match(r'^[a-zA-ZáéíóúñÑ\s]+$', valor):
raise ErrorDeValidacion(
f"El campo '{nombre_campo}' solo puede contener letras."
)
def validar_no_vacio(valor: str, nombre_campo: str):
if valor is None or str(valor).strip() == '':
raise ErrorDeValidacion(
f"El campo '{nombre_campo}' es obligatorio."
)
¿Por qué una interfaz abstracta?
La interfaz RepositorioCategoria es como un contrato. Dice "cualquier repositorio de categorías DEBE tener estos 5 métodos". Pero no dice cómo funcionan internamente.
Esto permite crear múltiples implementaciones:
RepositorioCategoriaMySQL— para producción con MySQLRepositorioCategoriaPostgres— futura migración a PostgreSQLRepositorioCategoriaMock— para tests unitariosRepositorioCategoriaArchivo— para pruebas con archivos CSV
Todas implementan los mismos métodos. El caso de uso no nota la diferencia.
4. Capa de Infraestructura — La Implementación Técnica
Infraestructura contiene el código concreto: cómo se conecta a la base de datos y cómo se ejecutan las consultas SQL. Implementa los contratos definidos en el dominio.
# infraestructura/basedatos/conexion.py
# Usa pymysql para conectarse a MySQL.
# DictCursor devuelve diccionarios en lugar de tuplas.
import os
import pymysql
from pymysql.cursors import DictCursor
from dotenv import load_dotenv
load_dotenv(override=True)
def obtener_conexion():
"""Crea y devuelve una conexión a MySQL."""
conexion = pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USUARIO', 'root'),
password=os.getenv('DB_PASSWORD', ''),
database=os.getenv('DB_NOMBRE', 'drugstore'),
cursorclass=DictCursor,
autocommit=True,
)
return conexion
# infraestructura/repositorios/repositorio_categoria_mysql.py
# Implementa el contrato RepositorioCategoria con código real de MySQL.
from dominio.entidades.categoria import Categoria
from dominio.excepciones import EntidadDuplicada
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
from infraestructura.basedatos.conexion import obtener_conexion
class RepositorioCategoriaMySQL(RepositorioCategoria):
def crear(self, categoria: Categoria) -> Categoria:
conexion = obtener_conexion()
try:
with conexion.cursor() as cursor:
sql = "INSERT INTO categorias (nombre) VALUES (%s)"
cursor.execute(sql, (categoria.nombre,))
categoria.id_categoria = cursor.lastrowid
return categoria
except Exception as e:
if 'Duplicate' in str(e):
raise EntidadDuplicada(
f"Ya existe una categoría con nombre '{categoria.nombre}'."
)
raise
finally:
conexion.close()
def obtener_por_id(self, id_categoria: int) -> Categoria | None:
conexion = obtener_conexion()
try:
with conexion.cursor() as cursor:
sql = "SELECT * FROM categorias WHERE id_categoria = %s"
cursor.execute(sql, (id_categoria,))
fila = cursor.fetchone()
if fila is None:
return None
return Categoria(
id_categoria=fila['id_categoria'],
nombre=fila['nombre'],
)
finally:
conexion.close()
def obtener_todos(self) -> list[Categoria]:
conexion = obtener_conexion()
try:
with conexion.cursor() as cursor:
sql = "SELECT * FROM categorias ORDER BY id_categoria"
cursor.execute(sql)
filas = cursor.fetchall()
return [
Categoria(id_categoria=f['id_categoria'], nombre=f['nombre'])
for f in filas
]
finally:
conexion.close()
def actualizar(self, categoria: Categoria) -> Categoria:
conexion = obtener_conexion()
try:
with conexion.cursor() as cursor:
sql = "UPDATE categorias SET nombre = %s WHERE id_categoria = %s"
cursor.execute(sql, (categoria.nombre, categoria.id_categoria))
return categoria
except Exception as e:
if 'Duplicate' in str(e):
raise EntidadDuplicada(
f"Ya existe una categoría con nombre '{categoria.nombre}'."
)
raise
finally:
conexion.close()
def eliminar(self, id_categoria: int) -> None:
conexion = obtener_conexion()
try:
with conexion.cursor() as cursor:
sql = "DELETE FROM categorias WHERE id_categoria = %s"
cursor.execute(sql, (id_categoria,))
finally:
conexion.close()
•
cursor.lastrowid — obtiene el ID generado tras un INSERT
•
DictCursor — devuelve diccionarios {'id_categoria': 1, 'nombre': 'Limpieza'}
•
%s — placeholders para parámetros (previene SQL injection)
•
'Duplicate' in str(e) — detecta violación de UNIQUE en MySQL
Estructura de cada método del repositorio
Cada método sigue el mismo patrón: abrir conexión, ejecutar SQL, procesar resultado, cerrar conexión.
def obtener_por_id(self, id_categoria: int) -> Categoria | None:
conexion = obtener_conexion() # 1. Abrir conexión
try:
with conexion.cursor() as cursor: # 2. Crear cursor
sql = "SELECT * FROM categorias WHERE id_categoria = %s"
cursor.execute(sql, (id_categoria,)) # 3. Ejecutar SQL
fila = cursor.fetchone() # 4. Obtener resultado
if fila is None:
return None # 5a. No encontrado → None
return Categoria( # 5b. Encontrado → entidad
id_categoria=fila['id_categoria'],
nombre=fila['nombre'],
)
finally:
conexion.close() # 6. Siempre cerrar
El bloque try/finally garantiza que la conexión se cierre incluso si hay un error. Esto evita fugas de conexiones que podrían bloquear la base de datos.
5. Capa de Aplicación — Los Casos de Uso
Cada caso de uso es una clase con un solo método ejecutar(). Orquesta la lógica de negocio: recibe datos, crea la entidad, la valida y delega al repositorio. Recibe la interfaz (no la implementación concreta).
# aplicacion/casos_de_uso/categorias/crear_categoria.py
from dominio.entidades.categoria import Categoria
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
class CrearCategoria:
def __init__(self, repositorio: RepositorioCategoria):
self.repositorio = repositorio
def ejecutar(self, nombre: str) -> Categoria:
categoria = Categoria(nombre=nombre)
categoria.validar()
return self.repositorio.crear(categoria)
# aplicacion/casos_de_uso/categorias/obtener_categoria.py
from dominio.entidades.categoria import Categoria
from dominio.excepciones import EntidadNoEncontrada
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
class ObtenerCategoria:
def __init__(self, repositorio: RepositorioCategoria):
self.repositorio = repositorio
def ejecutar(self, id_categoria: int) -> Categoria:
categoria = self.repositorio.obtener_por_id(id_categoria)
if categoria is None:
raise EntidadNoEncontrada(
f"No se encontró la categoría con id {id_categoria}."
)
return categoria
# aplicacion/casos_de_uso/categorias/listar_categorias.py
from dominio.entidades.categoria import Categoria
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
class ListarCategorias:
def __init__(self, repositorio: RepositorioCategoria):
self.repositorio = repositorio
def ejecutar(self) -> list[Categoria]:
return self.repositorio.obtener_todos()
# aplicacion/casos_de_uso/categorias/actualizar_categoria.py
from dominio.entidades.categoria import Categoria
from dominio.excepciones import EntidadNoEncontrada
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
class ActualizarCategoria:
def __init__(self, repositorio: RepositorioCategoria):
self.repositorio = repositorio
def ejecutar(self, id_categoria: int, nombre: str) -> Categoria:
existente = self.repositorio.obtener_por_id(id_categoria)
if existente is None:
raise EntidadNoEncontrada(
f"No se encontró la categoría con id {id_categoria}."
)
categoria = Categoria(nombre=nombre, id_categoria=id_categoria)
categoria.validar()
return self.repositorio.actualizar(categoria)
# aplicacion/casos_de_uso/categorias/eliminar_categoria.py
from dominio.excepciones import EntidadNoEncontrada
from dominio.repositorios.repositorio_categoria import RepositorioCategoria
class EliminarCategoria:
def __init__(self, repositorio: RepositorioCategoria):
self.repositorio = repositorio
def ejecutar(self, id_categoria: int) -> None:
existente = self.repositorio.obtener_por_id(id_categoria)
if existente is None:
raise EntidadNoEncontrada(
f"No se encontró la categoría con id {id_categoria}."
)
self.repositorio.eliminar(id_categoria)
RepositorioCategoria (la interfaz abstracta), nunca RepositorioCategoriaMySQL. Esto permite cambiar la implementación sin tocar una sola línea de código en esta capa.
Anatomía de un caso de uso
Cada caso de uso sigue el mismo patrón estructural:
class CrearCategoria: # 1. Nombre = verbo + entidad
def __init__(self, repositorio: RepositorioCategoria): # 2. Recibe la INTERFAZ
self.repositorio = repositorio # 3. La guarda como atributo
def ejecutar(self, nombre: str) -> Categoria: # 4. Un solo método público
categoria = Categoria(nombre=nombre) # 5. Crea la entidad
categoria.validar() # 6. Valida reglas de negocio
return self.repositorio.crear(categoria) # 7. Delega al repositorio
Los casos de uso de obtener, actualizar y eliminar incluyen un paso adicional: verifican que la entidad exista antes de operar, lanzando EntidadNoEncontrada si no la encuentran.
6. Capa de API — La Puerta de Entrada
FastAPI expone los endpoints HTTP. Cada request pasa por: validación Pydantic → dependencia (repositorio) → caso de uso → respuesta JSON.
Mapa de endpoints de Categoría
| Método | Ruta | Función | Caso de Uso | HTTP Status |
|---|---|---|---|---|
| POST | /api/categorias/ | crear() | CrearCategoria | 201 Created |
| GET | /api/categorias/ | listar() | ListarCategorias | 200 OK |
| GET | /api/categorias/{id} | obtener() | ObtenerCategoria | 200 OK / 404 |
| PUT | /api/categorias/{id} | actualizar() | ActualizarCategoria | 200 OK / 404 |
| DELETE | /api/categorias/{id} | eliminar() | EliminarCategoria | 204 No Content |
Cómo funciona Depends()
Depends() es un mecanismo de FastAPI que ejecuta una función antes del endpoint y pasa su resultado como parámetro. Es como un "middleware" por endpoint:
# FastAPI hace esto internamente:
# 1. Llama a obtener_repo_categoria() → crea RepositorioCategoriaMySQL
# 2. Llama a obtener_usuario_actual() → verifica JWT, devuelve payload
# 3. Pasa ambos resultados como parámetros al endpoint
@router.post("/")
def crear(
datos: CategoriaCrear, # ← del JSON body
repo=Depends(obtener_repo_categoria), # ← inyectado por FastAPI
usuario=Depends(obtener_usuario_actual), # ← verificado por FastAPI
):
# Acá 'repo' ya es una instancia de RepositorioCategoriaMySQL
# Acá 'usuario' ya es el payload del JWT verificado
...
# api/rutas/rutas_categorias.py
from fastapi import APIRouter, Depends, HTTPException, status
from api.esquemas import CategoriaCrear, CategoriaRespuesta
from api.dependencias import obtener_repo_categoria, obtener_usuario_actual
from aplicacion.casos_de_uso.categorias.crear_categoria import CrearCategoria
from aplicacion.casos_de_uso.categorias.obtener_categoria import ObtenerCategoria
from aplicacion.casos_de_uso.categorias.listar_categorias import ListarCategorias
from aplicacion.casos_de_uso.categorias.actualizar_categoria import ActualizarCategoria
from aplicacion.casos_de_uso.categorias.eliminar_categoria import EliminarCategoria
from dominio.excepciones import ErrorDeValidacion, EntidadNoEncontrada, EntidadDuplicada
router = APIRouter(prefix="/categorias", tags=["Categorías"])
@router.post("/", response_model=CategoriaRespuesta, status_code=201)
def crear(datos: CategoriaCrear,
repo=Depends(obtener_repo_categoria),
usuario=Depends(obtener_usuario_actual)):
try:
caso_de_uso = CrearCategoria(repo)
categoria = caso_de_uso.ejecutar(datos.nombre)
return CategoriaRespuesta(
id_categoria=categoria.id_categoria,
nombre=categoria.nombre
)
except ErrorDeValidacion as e:
raise HTTPException(status_code=400, detail=str(e))
except EntidadDuplicada as e:
raise HTTPException(status_code=409, detail=str(e))
@router.get("/", response_model=list[CategoriaRespuesta])
def listar(repo=Depends(obtener_repo_categoria),
usuario=Depends(obtener_usuario_actual)):
caso_de_uso = ListarCategorias(repo)
categorias = caso_de_uso.ejecutar()
return [CategoriaRespuesta(
id_categoria=c.id_categoria, nombre=c.nombre
) for c in categorias]
@router.get("/{id_categoria}", response_model=CategoriaRespuesta)
def obtener(id_categoria: int,
repo=Depends(obtener_repo_categoria),
usuario=Depends(obtener_usuario_actual)):
try:
caso_de_uso = ObtenerCategoria(repo)
categoria = caso_de_uso.ejecutar(id_categoria)
return CategoriaRespuesta(
id_categoria=categoria.id_categoria,
nombre=categoria.nombre
)
except EntidadNoEncontrada as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{id_categoria}", response_model=CategoriaRespuesta)
def actualizar(id_categoria: int, datos: CategoriaCrear,
repo=Depends(obtener_repo_categoria),
usuario=Depends(obtener_usuario_actual)):
try:
caso_de_uso = ActualizarCategoria(repo)
categoria = caso_de_uso.ejecutar(id_categoria, datos.nombre)
return CategoriaRespuesta(
id_categoria=categoria.id_categoria,
nombre=categoria.nombre
)
except EntidadNoEncontrada as e:
raise HTTPException(status_code=404, detail=str(e))
except ErrorDeValidacion as e:
raise HTTPException(status_code=400, detail=str(e))
except EntidadDuplicada as e:
raise HTTPException(status_code=409, detail=str(e))
@router.delete("/{id_categoria}", status_code=204)
def eliminar(id_categoria: int,
repo=Depends(obtener_repo_categoria),
usuario=Depends(obtener_usuario_actual)):
try:
caso_de_uso = EliminarCategoria(repo)
caso_de_uso.ejecutar(id_categoria)
except EntidadNoEncontrada as e:
raise HTTPException(status_code=404, detail=str(e))
# api/esquemas.py
# Los esquemas Pydantic definen la FORMA del JSON
# que llega (Request) y el que sale (Response).
from pydantic import BaseModel
class CategoriaCrear(BaseModel):
nombre: str
class CategoriaRespuesta(BaseModel):
id_categoria: int
nombre: str
# api/dependencias.py
# FastAPI inyecta el resultado de estas funciones
# en los endpoints usando Depends().
from infraestructura.repositorios.repositorio_categoria_mysql import RepositorioCategoriaMySQL
from infraestructura.seguridad.jwt_servicio import verificar_token
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
def obtener_repo_categoria():
"""Crea una instancia del repositorio MySQL concreto."""
return RepositorioCategoriaMySQL()
esquema_seguridad = HTTPBearer()
def obtener_usuario_actual(
credenciales: HTTPAuthorizationCredentials = Depends(esquema_seguridad),
) -> dict:
"""Verifica el token JWT y devuelve los datos del usuario."""
try:
payload = verificar_token(credenciales.credentials)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=401, detail="El token ha expirado.",
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=401, detail="Token inválido.",
)
7. Flujo de Datos — Las 5 Operaciones CRUD
Cada operación CRUD recorre las capas de arriba a abajo. Veamos el flujo exacto de cada una. Los colores indican la capa de origen:
POST CREAR — Crear Categoría
Esta es la operación más completa: recibe datos JSON, los valida, crea la entidad, la guarda en MySQL y retorna el ID generado.
GET OBTENER — Obtener por ID
Busca una categoría por su ID. Si no existe, lanza una excepción que se convierte en HTTP 404.
GET LISTAR — Listar Todas
Obtiene todas las categorías de la base de datos y las retorna como un array JSON.
PUT ACTUALIZAR — Actualizar Categoría
Verifica que exista, valida los nuevos datos y ejecuta un UPDATE. Si el nuevo nombre ya existe en otra categoría, lanza EntidadDuplicada.
DELETE ELIMINAR — Eliminar Categoría
Primero verifica que la categoría exista, luego la elimina permanentemente de la base de datos.
8. Inyección de Dependencias — El Pegamento
La Inyección de Dependencias (DI) es el mecanismo que conecta las capas sin acoplarlas. En lugar de que cada clase cree sus propias dependencias, las recibe "inyectadas" desde fuera.
En CRUDrugstore, la inyección de dependencias se implementa con el sistema Depends() de FastAPI, que es una de sus características más poderosas.
El problema que resuelve
Sin DI, cada endpoint tendría que crear su propio repositorio:
# MAL: Cada endpoint crea su propio repositorio
@router.post("/")
def crear(datos: CategoriaCrear):
repo = RepositorioCategoriaMySQL() # ← Acoplamiento directo
caso = CrearCategoria(repo)
...
Con DI, el repositorio se crea en un solo lugar y se reutiliza:
# BIEN: FastAPI inyecta el repositorio
@router.post("/")
def crear(datos: CategoriaCrear, repo=Depends(obtener_repo_categoria)):
caso = CrearCategoria(repo) # ← Sin acoplamiento
...
Cómo funciona en FastAPI
Código del "pegamento"
# api/dependencias.py — La función que crea el objeto concreto
def obtener_repo_categoria():
"""Crea el repositorio MySQL concreto y lo devuelve."""
return RepositorioCategoriaMySQL()
# En el endpoint, FastAPI lo inyecta automáticamente:
@router.post("/")
def crear(datos: CategoriaCrear,
repo=Depends(obtener_repo_categoria)): # ← ¡Inyectado!
caso_de_uso = CrearCategoria(repo)
# ...
RepositorioCategoria). La capa de API (externa) proporciona la implementación concreta (RepositorioCategoriaMySQL). Las dependencias apuntan hacia el centro.
¿Qué pasaría sin Inyección de Dependencias?
Sin DI, cada clase crearía sus propias dependencias internamente, lo que genera acoplamiento fuerte y dificulta los tests. Veamos la diferencia:
# MAL: El caso de uso crea su propio repositorio
class CrearCategoria:
def __init__(self):
self.repositorio = RepositorioCategoriaMySQL() # ¡Acoplamiento!
def ejecutar(self, nombre):
# ...
# Problemas:
# 1. No se puede testear sin MySQL
# 2. No se puede cambiar la implementación
# 3. El dominio depende de infraestructura
# BIEN: Recibe la dependencia de afuera
class CrearCategoria:
def __init__(self, repositorio: RepositorioCategoria): # Inyección
self.repositorio = repositorio
def ejecutar(self, nombre):
# ...
Con inyección de dependencias, el mismo caso de usa funciona con MySQL, PostgreSQL, un mock de tests, o incluso un archivo CSV. La decisión de qué implementación usar se toma una sola vez, en api/dependencias.py.
9. Principios SOLID Aplicados
Single Responsibility
Cada clase tiene UNA sola responsabilidad:
Categoria → define la entidadRepositorioMySQL → ejecuta SQLCrearCategoria → orquesta el caso de usorutas_categorias → maneja HTTPSi Categoria cambiara la regla de validación, solo se modifica un archivo.
Open/Closed
Abierto para extender, cerrado para modificar. Para agregar la entidad "Proveedor", se crean nuevos archivos sin tocar los de Categoría:
+ proveedor.py+ repositorio_proveedor_mysql.py+ crear_proveedor.pyEl código existente no se modifica, solo se extiende.
Liskov Substitution
RepositorioCategoriaMySQL puede reemplazar a RepositorioCategoria en cualquier lugar sin romper el programa. El caso de uso no nota la diferencia. Esto es posible porque la interfaz ABC define el contrato y la implementación concreta lo cumple.
CrearCategoria(repo_mysql) ✓CrearCategoria(repo_postgres) ✓CrearCategoria(repo_mock) ✓Cualquier implementación de la interfaz funciona.
Interface Segregation
Cada interfaz de repositorio es pequeña y enfocada. RepositorioCategoria solo tiene los 5 métodos CRUD de categorías — no tiene métodos de productos, usuarios, etc.
crear, obtener_por_id, obtener_todosactualizar, eliminarNo obliga a implementar métodos innecesarios.
Dependency Inversion
El dominio (núcleo) define las interfaces. Las capas externas las implementan. Las dependencias apuntan hacia adentro.
dominio define → RepositorioCategoria (ABC)infraestr. implementa → RepositorioCategoriaMySQLaplicación usa → solo la interfaz ABCEl núcleo nunca depende de los detalles externos.
Cómo se conectan los principios en nuestro proyecto
Cuando CrearCategoria recibe RepositorioCategoria (interfaz ABC) en su constructor:
- S — Solo crea y valida categorías, nada más
- O — Para agregar "Proveedor", creamos nueva clase sin modificar CrearCategoria
- L — Cualquier implementación de la interfaz funciona
- I — Solo los 5 métodos CRUD, nothing else
- D — Depende de la abstracción (ABC), no del MySQL concreto
10. Migración MySQL → PostgreSQL
Gracias a la Clean Architecture, migrar de MySQL a PostgreSQL solo requiere cambios en la capa de Infraestructura. Las capas Dominio, Aplicación y API quedan intactas.
Comparación visual: qué cambia y qué no
NO CAMBIA
dominio/
aplicacion/
api/
SÍ CAMBIA
infraestructura/
Solo 2 archivos para Categoría
Diffs de código
conexion.py: pymysql → psycopg2
El archivo de conexión es el punto de partida. Cambia la librería y los parámetros de conexión.
# ANTES (MySQL)
import pymysql
from pymysql.cursors import DictCursor
def obtener_conexion():
return pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USUARIO', 'root'),
password=os.getenv('DB_PASSWORD', ''),
database=os.getenv('DB_NOMBRE', 'drugstore'),
cursorclass=DictCursor,
autocommit=True,
)
# DESPUÉS (PostgreSQL)
import psycopg2
import psycopg2.extras
def obtener_conexion():
return psycopg2.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=os.getenv('DB_PORT', 5432),
user=os.getenv('DB_USUARIO', 'postgres'),
password=os.getenv('DB_PASSWORD', ''),
dbname=os.getenv('DB_NOMBRE', 'drugstore'),
)
# Para obtener cursors con diccionarios en PostgreSQL:
def obtener_cursor(conexion):
return conexion.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
repositorio_categoria_mysql.py → repositorio_categoria_postgres.py
Los cambios principales en cada repositorio son: cómo obtener el ID tras INSERT, cómo detectar duplicados, y cómo crear el cursor.
# CAMBIOS ESPECÍFICOS:
# 1. INSERT: lastrowid → RETURNING
# ANTES (MySQL):
sql = "INSERT INTO categorias (nombre) VALUES (%s)"
cursor.execute(sql, (categoria.nombre,))
categoria.id_categoria = cursor.lastrowid
# DESPUÉS (PostgreSQL):
sql = "INSERT INTO categorias (nombre) VALUES (%s) RETURNING id_categoria"
cursor.execute(sql, (categoria.nombre,))
categoria.id_categoria = cursor.fetchone()[0]
# 2. Detección de duplicados
# ANTES: if 'Duplicate' in str(e):
# DESPUÉS: import psycopg2.errors
if isinstance(e, psycopg2.errors.UniqueViolation):
# 3. DictCursor → RealDictCursor
# ANTES: from pymysql.cursors import DictCursor
# DESPUÉS: from psycopg2.extras import RealDictCursor
# (usar cursor_factory=RealDictCursor al crear cursor)
# 4. Los SELECT, UPDATE y DELETE son casi idénticos
# PostgreSQL y MySQL usan el mismo SQL estándar para estas operaciones
Tabla de todos los puntos de cambio
| # | Archivo | Cambio | Detalle |
|---|---|---|---|
| 1 | conexion.py | pymysql → psycopg2 | Librería de conexión |
| 2 | conexion.py | DictCursor → RealDictCursor | Cursores con diccionarios |
| 3 | conexion.py | port=3306 → port=5432 | Puerto por defecto |
| 4 | conexion.py | database= → dbname= | Nombre del parámetro |
| 5 | repositorio_*.py | lastrowid → RETURNING | Obtener ID tras INSERT |
| 6 | repositorio_*.py | %s placeholders | Mismo syntax, sin cambio |
| 7 | repositorio_*.py | Duplicate → UniqueViolation | Detección de duplicados |
| 8 | repositorio_*.py | cursor class | Ajustar factory de cursor |
| 9-14 | 6 repositorios | Mismos 4 cambios | Cada repo MySQL → Postgres |
| 15 | requirements.txt | pymysql → psycopg2 | Dependencia |
| 16-26 | Otros repos | Mismos patrones | Repetir por cada entidad |
dominio/, aplicacion/, y api/. Solo cambian archivos en infraestructura/.
Script de verificación post-migración
Después de migrar, podés verificar que nada se rompió ejecutando estos checks:
# 1. Verificar que dominio no tiene imports de infraestructura
$ grep -r "infraestructura" dominio/
# Resultado esperado: NADA (0 archivos)
# 2. Verificar que la API solo importa de dependencias.py
$ grep -r "pymysql" api/
# Resultado esperado: NADA (0 archivos)
# 3. Verificar que los casos de uso no importan MySQL
$ grep -r "pymysql" aplicacion/
# Resultado esperado: NADA (0 archivos)
# 4. Verificar que infraestructura SÍ tiene psycopg2
$ grep -r "psycopg2" infraestructura/
# Resultado esperado: archivos de repositorios y conexion
Si algún grep da un resultado inesperado, significa que hay una dependencia cruzada que necesita corregirse.
Consejos para la migración
1. Empezar por conexion.py
Cambiar la librería y los parámetros de conexión primero
2. Un repo a la vez
Migrar cada repositorio individualmente y probar
3. Tests de integración
Ejecutar los tests después de cada cambio
4. No tocar otras capas
Si algo de dominio/aplicación/api cambió, hay un error de diseño
11. Testabilidad
La Clean Architecture permite testear cada capa de forma aislada. Como los casos de uso dependen de una interfaz (no de MySQL), podemos crear un mock del repositorio para testear sin base de datos.
Esto es un beneficio enorme: los tests de la capa de Aplicación son rápidos (milisegundos), determinísticos (no dependen de datos en la BD) y no necesitan infraestructura externa.
Ejemplo: Test unitario de CrearCategoria
# tests/test_crear_categoria.py
from unittest.mock import MagicMock
from aplicacion.casos_de_uso.categorias.crear_categoria import CrearCategoria
from dominio.entidades.categoria import Categoria
def test_crear_categoria_exitosa():
"""Test: crear una categoría válida retorna la categoría con ID."""
# 1. Crear un mock del repositorio (no hay MySQL)
repo_mock = MagicMock()
# 2. Configurar el mock: cuando llamen crear(), devolver una categoría
def simular_crear(categoria):
categoria.id_categoria = 1 # Simula el lastrowid de MySQL
return categoria
repo_mock.crear.side_effect = simular_crear
# 3. Crear el caso de uso con el mock
caso_uso = CrearCategoria(repo_mock)
# 4. Ejecutar
resultado = caso_uso.ejecutar("Limpieza")
# 5. Verificar
assert resultado.nombre == "Limpieza"
assert resultado.id_categoria == 1
repo_mock.crear.assert_called_once()
def test_crear_categoria_nombre_vacio_falla():
"""Test: nombre vacío lanza ErrorDeValidacion."""
repo_mock = MagicMock()
caso_uso = CrearCategoria(repo_mock)
import pytest
from dominio.excepciones import ErrorDeValidacion
with pytest.raises(ErrorDeValidacion):
caso_uso.ejecutar("") # Nombre vacío
# El repo NUNCA fue llamado (fallo antes de llegar a la BD)
repo_mock.crear.assert_not_called()
Qué testear en cada capa
| Capa | Qué testear | Herramienta |
|---|---|---|
| Dominio | Validaciones, reglas de negocio, excepciones | Tests unitarios directos |
| Aplicación | Casos de uso con repositorios mockeados | unittest.mock / MagicMock |
| API | Endpoints HTTP, status codes, request/response | TestClient de FastAPI |
| Infraestr. | Conexión real a BD, queries SQL | Tests de integración con BD de prueba |
Ejemplo: Test de integración de API
# tests/test_api_categorias.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_crear_categoria():
"""Test: POST /api/categorias/ crea una categoría."""
respuesta = client.post(
"/api/categorias/",
json={"nombre": "Test Category"},
headers={"Authorization": "Bearer <token>"}
)
assert respuesta.status_code == 201
assert respuesta.json()["nombre"] == "Test Category"
assert "id_categoria" in respuesta.json()
def test_obtener_categoria_no_existe():
"""Test: GET /api/categorias/99999 retorna 404."""
respuesta = client.get(
"/api/categorias/99999",
headers={"Authorization": "Bearer <token>"}
)
assert respuesta.status_code == 404
Resumen de testabilidad por capa
Dominio
Tests puros, sin mocks. Velocísimos.
Aplicación
Mock del repositorio. Aislados de la BD.
API
TestClient de FastAPI. Tests de endpoint.
Infraestructura
BD de prueba. Tests de integración.
12. Resumen y Buenas Prácticas
Ideas clave para recordar
Estos son los 5 principios fundamentales que debés recordar cuando trabajes con Clean Architecture:
El Dominio es sagrado
Nunca importa de fuera. Define entidades, interfaces y reglas de negocio.
Las dependencias van hacia adentro
API → Aplicación → Dominio. Nunca al revés.
Depende de abstracciones
Los casos de uso reciben interfaces, no implementaciones concretas.
Un caso de uso = una operación
CrearCategoria, ObtenerCategoria, etc. Clases pequeñas y enfocadas.
Anti-patrones a evitar
❌ Lógica de negocio en los endpoints
El error más común: poner la lógica de validación y acceso a datos directamente en los endpoints de FastAPI.
# MAL: Validar y crear directamente en el endpoint
@router.post("/")
def crear(datos: CategoriaCrear, repo=Depends(obtener_repo_categoria)):
if not datos.nombre: # ← Lógica de negocio en la API
raise HTTPException(400)
sql = "INSERT INTO categorias..." # ← SQL en la API
# ... esto rompe la separación de capas
# BIEN: Delegar al caso de uso
@router.post("/")
def crear(datos: CategoriaCrear, repo=Depends(obtener_repo_categoria)):
caso = CrearCategoria(repo)
cat = caso.ejecutar(datos.nombre)
return cat
El endpoint solo debe: recibir JSON → llamar caso de uso → devolver respuesta.
❌ Importar infraestructura desde dominio
Si el dominio importa de infraestructura, las dependencias van en la dirección incorrecta.
# MAL: El dominio importa de infraestructura
from infraestructura.basedatos.conexion import obtener_conexion # ¡NO!
# BIEN: El dominio solo define la interfaz
from abc import ABC, abstractmethod
class RepositorioCategoria(ABC):
@abstractmethod
def crear(self, categoria): ...
El dominio define QUÉ se puede hacer, la infraestructura define CÓMO se hace.
❌ Casos de uso con más de una responsabilidad
Un solo caso de uso que hace todo es difícil de mantener y testear.
# MAL: Un caso de uso que hace todo
class GestionarCategorias:
def crear(...): ...
def obtener(...): ...
def listar(...): ...
def actualizar(...): ...
def eliminar(...): ...
# BIEN: Una clase por operación
class CrearCategoria: ...
class ObtenerCategoria: ...
class ListarCategorias: ...
class ActualizarCategoria: ...
class EliminarCategoria: ...
Un caso de uso = una operación = una clase = un test.
❌ Esquemas Pydantic como entidades de dominio
Los esquemas Pydantic son para HTTP, las entidades del dominio son para la lógica de negocio.
# MAL: Usar Pydantic como entidad
from pydantic import BaseModel
class Categoria(BaseModel): # ¡Esto es un esquema HTTP!
id_categoria: int
nombre: str
# BIEN: Entidad independiente de HTTP
class Categoria: # Clase Python pura
def __init__(self, nombre, id_categoria=None):
self.nombre = nombre
self.id_categoria = id_categoria
def validar(self): ...
La entidad del dominio no depende de ninguna librería externa.
Cómo agregar una nueva entidad (ej: Proveedor)
Seguir estos 6 pasos te garantiza que la nueva entidad respeta la arquitectura y se integra correctamente con el resto del sistema. Cada paso corresponde a una capa distinta:
Mapa de dependencias completo
Este diagrama muestra cómo se conectan todas las piezas del sistema. Las líneas sólidas indican dependencias directas, las líneas punteadas indican implementación:
Conceptos clave para llevar a casa
Separación de Capas
Cada capa tiene su trabajo
Dependencias → Centro
Las externas conocen las internas
Programar a Interfaces
ABC en dominio, impl. en infra
Inyección de Deps
El pegamento entre capas
Testeable
Mock del repo = tests aislados