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:

🔄

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

🌐 API (Presentación HTTP) rutas_categorias.py · esquemas.py · dependencias.py 📋 Aplicación (Casos de Uso) crear_categoria.py obtener_categoria.py listar · actualizar · eliminar 🔧 Infraestructura (Implementación técnica) repositorio_categoria_mysql.py conexion.py · pymysql 💎 Dominio dependencias → centro

Las flechas de dependencia van de afuera hacia adentro →

Regla de oro: Las dependencias siempre apuntan hacia el centro. La capa externa (API) conoce a la interna (Dominio), pero nunca al revés. El Dominio no sabe que existe MySQL, FastAPI o internet.

¿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.

Estructura del proyecto:
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)
🌐 api/ — Presentación HTTP
Rutas, Esquemas Pydantic, Dependencias
📋 aplicacion/ — Casos de Uso
CrearCategoria, ObtenerCategoria, ListarCategorias, ActualizarCategoria, EliminarCategoria
🔧 infraestructura/ — Implementación Técnica
RepositorioMySQL, Conexión BD, JWT
💎 dominio/ — El Núcleo
Entidades, Interfaces de Repositorio, Excepciones, Validaciones

Archivos de Categoría en cada capa

CapaCarpetaArchivos
Dominiodominio/entidades/categoria.py, repositorios/repositorio_categoria.py, excepciones.py, validaciones.py
Infraestr.infraestructura/repositorios/repositorio_categoria_mysql.py, basedatos/conexion.py
Aplicaciónaplicacion/casos_de_uso/categorias/crear_categoria.py, obtener_categoria.py, listar_categorias.py, actualizar_categoria.py, eliminar_categoria.py
APIapi/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."
        )
⚠️ Regla absoluta: Esta capa NUNCA importa de las capas externas. No sabe que existe FastAPI, MySQL, ni HTTP. Solo importa de sí misma.

¿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:

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()
Patrones MySQL específicos:
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)
Patrón clave: Fijate que todos los casos de uso reciben 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étodoRutaFunciónCaso de UsoHTTP Status
POST/api/categorias/crear()CrearCategoria201 Created
GET/api/categorias/listar()ListarCategorias200 OK
GET/api/categorias/{id}obtener()ObtenerCategoria200 OK / 404
PUT/api/categorias/{id}actualizar()ActualizarCategoria200 OK / 404
DELETE/api/categorias/{id}eliminar()EliminarCategoria204 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:

🌐 API (HTTP) 📋 Aplicación (Caso de Uso) 💎 Dominio (Entidad) 🔧 Infraestructura (SQL) 🗄️ Base de Datos
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.

🌐 POST /api/categorias/ — JSON: {"nombre": "Limpieza"}
Pydantic valida: CategoriaCrear(nombre="Limpieza")
Depends() inyecta RepositorioCategoriaMySQL
📋 CrearCategoria.ejecutar("Limpieza")
💎 Categoria(nombre="Limpieza").validar()
🔧 repo.crear(categoria)
MySQL: INSERT INTO categorias (nombre) VALUES ('Limpieza')
cursor.lastrowid → id_categoria = 7
HTTP 201: {"id_categoria": 7, "nombre": "Limpieza"}
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 /api/categorias/3
📋 ObtenerCategoria.ejecutar(3)
🔧 repo.obtener_por_id(3)
MySQL: SELECT * FROM categorias WHERE id_categoria = 3
¿fila es None?
SÍ → raise EntidadNoEncontrada
→ HTTP 404
NO → return Categoria()
→ HTTP 200
GET LISTAR — Listar Todas

Obtiene todas las categorías de la base de datos y las retorna como un array JSON.

🌐 GET /api/categorias/
📋 ListarCategorias.ejecutar()
🔧 repo.obtener_todos()
MySQL: SELECT * FROM categorias ORDER BY id_categoria
Cursor.fetchall() → lista de diccionarios
Convierte cada fila en objeto Categoria
HTTP 200: [{id:1, nombre:"Limpieza"}, ...]
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.

🌐 PUT /api/categorias/3 — JSON: {"nombre": "Higiene"}
📋 ActualizarCategoria.ejecutar(3, "Higiene")
💎 PRIMERO: verificar que exista (obtener_por_id)
MySQL: SELECT * FROM categorias WHERE id_categoria = 3
¿Existe?
NO → raise EntidadNoEncontrada
→ HTTP 404
SÍ → validar + UPDATE
💎 Categoria(nombre="Higiene", id=3).validar()
🔧 repo.actualizar(categoria)
MySQL: UPDATE categorias SET nombre='Higiene' WHERE id_categoria=3
HTTP 200: {"id_categoria": 3, "nombre": "Higiene"}
DELETE ELIMINAR — Eliminar Categoría

Primero verifica que la categoría exista, luego la elimina permanentemente de la base de datos.

🌐 DELETE /api/categorias/3
📋 EliminarCategoria.ejecutar(3)
💎 PRIMERO: verificar que exista (obtener_por_id)
MySQL: SELECT * FROM categorias WHERE id_categoria = 3
¿Existe?
NO → raise EntidadNoEncontrada
→ HTTP 404
SÍ → eliminar
🔧 repo.eliminar(3)
MySQL: DELETE FROM categorias WHERE id_categoria = 3
HTTP 204 (Sin Contenido)

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

🌐 Endpoint: def crear(repo = Depends(obtener_repo_categoria))
FastAPI llama a obtener_repo_categoria()
obtener_repo_categoria() → return RepositorioCategoriaMySQL()
CrearCategoria(repo) — recibe la instancia concreta
Pero CrearCategoria solo conoce RepositorioCategoria (interfaz)
💎 El caso de uso NUNCA importa RepositorioCategoriaMySQL

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)
    # ...
Principio de Inversión de Dependencias (DIP): La capa de Aplicación (interna) define la abstracción (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

S

Single Responsibility

Cada clase tiene UNA sola responsabilidad:

Categoria → define la entidad
RepositorioMySQL → ejecuta SQL
CrearCategoria → orquesta el caso de uso
rutas_categorias → maneja HTTP

Si Categoria cambiara la regla de validación, solo se modifica un archivo.

O

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.py

El código existente no se modifica, solo se extiende.

L

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.

I

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_todos
actualizar, eliminar

No obliga a implementar métodos innecesarios.

D

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 → RepositorioCategoriaMySQL
aplicación usa → solo la interfaz ABC

El 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

#ArchivoCambioDetalle
1conexion.pypymysql → psycopg2Librería de conexión
2conexion.pyDictCursor → RealDictCursorCursores con diccionarios
3conexion.pyport=3306 → port=5432Puerto por defecto
4conexion.pydatabase= → dbname=Nombre del parámetro
5repositorio_*.pylastrowid → RETURNINGObtener ID tras INSERT
6repositorio_*.py%s placeholdersMismo syntax, sin cambio
7repositorio_*.pyDuplicate → UniqueViolationDetección de duplicados
8repositorio_*.pycursor classAjustar factory de cursor
9-146 repositoriosMismos 4 cambiosCada repo MySQL → Postgres
15requirements.txtpymysql → psycopg2Dependencia
16-26Otros reposMismos patronesRepetir por cada entidad
Resultado: Archivos que NO se tocan: todos en 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

CapaQué testearHerramienta
DominioValidaciones, reglas de negocio, excepcionesTests unitarios directos
AplicaciónCasos de uso con repositorios mockeadosunittest.mock / MagicMock
APIEndpoints HTTP, status codes, request/responseTestClient de FastAPI
Infraestr.Conexión real a BD, queries SQLTests 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:

1️⃣ dominio/entidades/proveedor.py → class Proveedor
2️⃣ dominio/repositorios/repositorio_proveedor.py → class ABC
3️⃣ infraestructura/repositorios/repositorio_proveedor_mysql.py
4️⃣ aplicacion/casos_de_uso/proveedores/ → 5 clases CRUD
5️⃣ api/rutas/rutas_proveedores.py + esquemas + dependencias
6️⃣ Registrar router en main.py con prefix="/api"

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:

🌐 API Rutas + Esquemas 📋 Aplicación Casos de Uso 🔧 Infraestructura MySQL + Repos 💎 Dominio Entidad + ABC Depende de (interfaz) Implementa usa provee 🗄️ MySQL Base de Datos conecta 🚀 main.py FastAPI + CORS registra Inyección de Dependencias (el "pegamento") api/dependencias.py crea RepositorioCategoriaMySQL() y lo inyecta en los endpoints con Depends() El caso de uso solo conoce la interfaz ABC del dominio
Conclusión: Clean Architecture no es solo una estructura de carpetas — es una forma de pensar las dependencias. El beneficio principal es que podés cambiar cualquier implementación técnica (BD, framework, API) sin tocar la lógica de negocio. Esto hace al código más mantenible, testeable y profesional.

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