Módulo 6: Code Quality Patterns para AI

8. Resumen y Troubleshooting del Módulo 6

Descripción

Este módulo transformó código funcional pero monolítico en una arquitectura mantenible de 4 capas. Esta cápsula consolida todos los patterns aprendidos, los errores más comunes del refactoring, y el checklist completo para verificar que la arquitectura es correcta.


Lo que construiste en este módulo

ANTES (código monolítico funcional):

src/app/main.py (300 líneas)
├── Prompts hardcodeados como strings en el módulo
├── from openai import OpenAI directamente en el endpoint
├── json.loads() inline mezclado con la lógica
├── os.getenv() disperso por el código
├── God function: hace guadrails + LLM + parse + validate + log
└── Tests requieren patch("openai.chat.completions.create")

DESPUÉS (clean architecture de 4 capas):

prompts/sentiment/v1.yaml    ← Template YAML versionable
src/config.py                ← pydantic-settings type-safe
src/domain/
  └── sentiment_service.py  ← 20 líneas, solo orquestación
src/infrastructure/
  ├── llm_provider.py        ← Protocol LLMProvider
  └── openai_provider.py    ← Implementación desacoplada
src/processing/
  └── sentiment_parser.py   ← Parser testeable independientemente
src/app/
  ├── main.py                ← App factory
  ├── dependencies.py        ← Wiring de DI
  └── routers/sentiment.py  ← Endpoint limpio con Depends()

Tests:
  test_sentiment_service.py  ← MockProvider, sin patch()
  test_sentiment_parser.py   ← Parser en aislamiento
  test_config.py             ← Validaciones de producción

Mapa de decisiones: ¿dónde va cada cosa?

¿Dónde va este código?
│
├─ ¿Es el texto/contenido que se le dice al LLM?
│   └─ PROMPT TEMPLATES: prompts/*.yaml
│       Ejemplo: "Analiza el sentimiento de: {text}"
│
├─ ¿Es la regla de negocio? ("confidence mínima es 0.3")
│   └─ DOMAIN: src/domain/
│       Ejemplo: analyze_sentiment(), LowConfidenceError
│
├─ ¿Es una llamada a una API externa (OpenAI, Anthropic)?
│   └─ INFRASTRUCTURE: src/infrastructure/
│       Ejemplo: OpenAIProvider.complete(), request HTTP
│
├─ ¿Es transformar texto del LLM en un tipo Python?
│   └─ PROCESSING: src/processing/
│       Ejemplo: parse_sentiment_output(), SentimentOutput
│
├─ ¿Es config del sistema (model, temperature, API key)?
│   └─ CONFIG: src/config.py
│       Ejemplo: Settings.model, Settings.temperature
│
└─ ¿Es el punto de entrada HTTP?
    └─ APP: src/app/
        Ejemplo: FastAPI endpoints, middleware, Depends()

Los 6 anti-patterns eliminados y cómo detectarlos

Anti-pattern 1: Prompt hardcodeado en código

# ❌ Detectar:
grep -r "f\"Analyze" src/
# Si aparece en un .py que no sea loader.py, es un anti-pattern

# ❌ Código problemático:
PROMPT = f"Analyze sentiment: {text}"

# ✅ Solución:
# prompts/sentiment/v1.yaml
# template: "Analyze sentiment: {text}"
# Código: load_prompt("sentiment/v1").render(text=text)

Anti-pattern 2: Import de OpenAI en domain

# ❌ Detectar:
grep -r "from openai" src/domain/
# Si aparece algo, es un anti-pattern

# ❌ Código problemático:
# src/domain/sentiment_service.py
from openai import OpenAI  # ← Domain conoce OpenAI

# ✅ Solución:
# src/domain/sentiment_service.py
from src.infrastructure.llm_provider import LLMProvider  # Solo el Protocol

Anti-pattern 3: Config con os.getenv disperso

# ❌ Detectar:
grep -r "os.getenv" src/ --include="*.py"
# Si aparece fuera de config.py, es un anti-pattern

# ❌ Código problemático:
MODEL = os.getenv("MODEL", "gpt-4o-mini")  # En models.py
TEMP = float(os.getenv("TEMPERATURE", "0.7"))  # En utils.py

# ✅ Solución:
settings = get_settings()
settings.model, settings.temperature

Anti-pattern 4: json.loads() en domain

# ❌ Detectar: parsing de JSON en domain
# src/domain/sentiment_service.py
data = json.loads(response)  # ← Processing concern en domain

# ✅ Solución:
result = parse_sentiment_output(response)  # Delegar al parser

Anti-pattern 5: patch() de OpenAI en tests de unit

# ❌ Tests frágiles:
@patch("openai.chat.completions.create")
def test_analyze(mock_create):
    mock_create.return_value = MagicMock(...)  # 10 líneas de setup

# ✅ Tests con DI:
def test_analyze():
    mock = MockProvider('{"sentiment": "positive", "score": 0.8, "confidence": 0.9}')
    result = analyze_sentiment("Great!", mock)
    assert result["sentiment"] == "positive"  # 3 líneas totales

Anti-pattern 6: God function sin separación

# ❌ Detectar: una función con múltiples responsabilidades
def analyze(text: str) -> dict:
    # guardrail: if len(text) > 5000...
    # prompt construction: prompt = f"..."
    # LLM call: client.chat.completions.create(...)
    # JSON parse: json.loads(raw)
    # validation: if score > 1.0: score = 1.0
    # logging: print(f"Done: {result}")
    return result

# ✅ Solución: cada responsabilidad en su capa
# La función domain solo orquesta
def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    messages = [{"role": "system", ...}, {"role": "user", ...}]
    raw = provider.complete(messages)
    return parse_sentiment_output(raw)  # 3 líneas claras

Los 5 errores más comunes del refactoring

Error 1: Mover código antes de tener tests

Síntoma: Después de mover código, algo falla pero no sabes dónde.
Causa: No tenías tests de la función antes de refactorizar.

Diagnóstico:
- ¿Tienes tests que cubren la función que vas a mover?
- pytest tests/ → ¿cuántos pasan ANTES del cambio?

Fix:
1. PRIMERO: añadir tests de la función actual (en su estado original)
2. LUEGO: mover el código
3. Correr tests → deben seguir pasando

Error 2: Violar la regla de dependencia

# Síntoma: tests del domain requieren API key de OpenAI

# Causa: domain importa de infrastructure
# src/domain/service.py
from src.infrastructure.openai_provider import OpenAIProvider  # ← INCORRECTO

# Diagnóstico:
grep -r "from src.infrastructure" src/domain/

# Fix: domain solo debe importar el Protocol
from src.infrastructure.llm_provider import LLMProvider  # Solo el Protocol
def analyze(text: str, provider: LLMProvider) -> dict: ...  # DI

Error 3: Settings no se cargan con los valores correctos

# Síntoma: los tests leen el .env del proyecto en lugar de la config de test

# Causa: lru_cache guarda la config del primer get_settings()
settings_1 = get_settings()  # Lee .env
settings_2 = get_settings()  # Retorna el mismo objeto cacheado

# Fix: limpiar el cache antes de cada test
@pytest.fixture(autouse=True)
def clear_settings_cache():
    get_settings.cache_clear()
    yield
    get_settings.cache_clear()

Error 4: load_prompt falla en tests porque no encuentra el archivo

# Síntoma: FileNotFoundError: Prompt not found: .../prompts/sentiment/v1.yaml

# Causa: el path relativo en load_prompt() es relativo al directorio de trabajo,
# que puede ser diferente cuando corres pytest desde diferentes directorios

# Fix: usar Path(__file__) en lugar de paths relativos
PROMPTS_DIR = Path(__file__).parent.parent.parent / "prompts"
# Este path es absoluto y funciona desde cualquier directorio de trabajo

# Fix alternativo: en pytest.ini
[pytest]
testpaths = tests
rootdir = .  # Asegura que el directorio raíz es correcto

Error 5: FallbackProvider no funciona porque los errores son del tipo incorrecto

# Síntoma: FallbackProvider no captura el error y no hace fallback

# Causa: el error lanzado no es LLMProviderError sino el error original
# (openai.RateLimitError, httpx.TimeoutException, etc.)

# Fix en OpenAIProvider: siempre envolver en LLMProviderError
try:
    response = self._client.chat.completions.create(...)
    return response.choices[0].message.content
except Exception as e:
    raise LLMProviderError(str(e), original_error=e)  # ← SIEMPRE wrap

Checklist de producción del módulo 6

ARQUITECTURA
[ ] src/domain/ no importa de src/infrastructure/ (solo el Protocol)
[ ] src/domain/ no importa de openai, anthropic, ni httpx
[ ] src/processing/ no importa de src/infrastructure/
[ ] Los prompts están en prompts/*.yaml, no hardcodeados en .py

CONFIG
[ ] Toda la config va a través de get_settings()
[ ] No hay os.getenv() fuera de config.py y startup.py
[ ] openai_api_key usa SecretStr
[ ] model_validator rechaza use_mock_llm=True en producción
[ ] model_validator rechaza log_level=DEBUG en producción
[ ] .env.example documentado con todas las variables

DEPENDENCY INJECTION
[ ] Los endpoints usan Depends(get_llm_provider)
[ ] Domain functions reciben LLMProvider como argumento
[ ] get_llm_provider() retorna MockProvider si use_mock_llm=True
[ ] FallbackProvider disponible si se necesita alta disponibilidad

TESTS
[ ] Unit tests del domain usan MockProvider, no patch("openai...")
[ ] Unit tests del parser son independientes del provider
[ ] Tests de config verifican validaciones de producción
[ ] pytest tests/ → todos pasan después del refactoring

VERIFICACIÓN RÁPIDA
[ ] grep -r "from openai" src/domain/ → sin resultados
[ ] grep -r "os.getenv" src/ (excepto config.py) → sin resultados
[ ] grep -r "f\".*{text}" src/domain/ → sin resultados (prompts en YAML)
[ ] pytest tests/ → todos pasan

Vocabulario del módulo

TérminoDefinición
Clean ArchitectureOrganización de código en capas con dependencias que apuntan hacia adentro
Separation of ConcernsCada módulo/función tiene una responsabilidad clara
God FunctionFunción que hace demasiadas cosas y es difícil de testear y mantener
ProtocolInterface de Python que define una firma sin herencia explícita
Dependency InjectionPasar dependencias como parámetros en lugar de crearlas internamente
pydantic-settingsLibrería para configuration management type-safe
SecretStrTipo de Pydantic que enmascara el valor en repr y logs
lru_cacheDecorator que cachea el resultado de una función
Prompt TemplateArchivo de configuración con el texto del prompt y variables
App FactoryFunción que crea y configura la app FastAPI
Fail fastDetectar y reportar errores de configuración al inicio, no en runtime
.env.{environment}Archivo de configuración específico por entorno

Conexión con el Módulo 7

El Módulo 7 (Reliability Patterns & Production Checklist) añade la última capa técnica: la capacidad del sistema de sobrevivir cuando las cosas fallan.

La clean architecture de este módulo hace que el Módulo 7 sea directo:

  • Retry logic: va en OpenAIProvider.complete() — infrastructure, no domain
  • Circuit breaker: también en infrastructure o en un nuevo CircuitBreakerProvider wrapper
  • Fallback entre modelos: FallbackProvider ya existe, solo hay que configurarlo
  • Budget enforcement: en OpenAIProvider o en un BudgetAwareProvider wrapper
  • Health checks: el startup checks del Módulo 6 son la base de los health checks del Módulo 7

La transición: "Tu código está limpio y organizado → ahora agrégale la capacidad de recuperarse cuando fallan las cosas."


Recursos adicionales del módulo

  1. Clean Architecture (Robert C. Martin) — El framework conceptual completo
  2. pydantic-settings Docs — Configuration management
  3. typing.Protocol — Interfaces en Python
  4. FastAPI Dependency Injection — Depends() en la práctica
  5. Refactoring (Fowler) — Cómo refactorizar con seguridad
  6. 12-Factor App — La filosofía detrás de config management y environment management
  7. Domain-Driven Design (Evans) — Conceptos de domain layer