Notebook

An architecture playbook En español

Cada vez que empiezo un proyecto de cero me doy cuenta de que repito las mismas decisiones de arquitectura, y no las tengo escritas en ningún lado. Este post es ese lugar. Es la destilación de cuatro recetas en una guía genérica.

0. Ports & Adapters (arquitectura hexagonal)

Respetar las siguientes reglas:

  1. Núcleo (core/domain) — la lógica de negocio. No sabe de HTTP, SQL, ni de qué LLM uso.
  2. Puertos — los contratos/interfaces. Definen qué se puede hacer, no cómo.
  3. Adaptadores — la implementación concreta de un puerto (Postgres, Firebase, Gemini, Stripe...). El cómo.

Es buena práctica hacer esta separación mental y proyectarla en las carpetas del proyecto desde el primer día. La disciplina es ubicar en la carpeta correcta cada cosa, no en clases extra.

1. Elegir el stack: la tabla de decisión

Antes de cualquier cosa, es buena práctica hacerse algunas preguntas. Cada fila fuerza a que la elección tenga un por qué explícito:

Pieza Pregunta que responde Ejemplo de criterio
Backend ¿bloqueante o no? Async si voy a esperar mucho I/O (LLM, red)
Frontend ¿necesito SSR? No si es una SPA/dashboard; sí si importa SEO/carga inicial
Paquetes ¿reproducible en otra máquina? Gestor con lockfile (uv, npm con package-lock, etc.)
Streaming ¿reconexión automática, un solo sentido? SSE > WebSockets si el flujo es servidor→cliente
Auth ¿login social, cuánto control necesito? Proveedor gestionado (Firebase/Auth0) > rodar el mío
Base de datos ¿mismo motor en dev y prod? Sí, siempre — evita sorpresas de compatibilidad
Infra local ¿se parece a producción? Docker Compose si prod es un contenedor propio; si la plataforma buildea por mí (Railway, etc.), proceso directo + BD gestionada

Regla general: prioriza piezas aburridas (maduras, bien documentadas) sobre piezas de moda. La complejidad interesante debería estar en la lógica de negocio, no peleando con tooling.

2. El mapa de carpetas — dibújalo antes de escribir código

Aunque construya solo el 10% al principio, defino el árbol completo desde el día 1. Así cada archivo nuevo sabe dónde vive antes de que exista:

app/
│
├── main.py / index.ts        ← arma la app, middlewares, registra rutas
│
├── api/                      🟢 ADAPTADOR DE ENTRADA — HTTP
│   ├── <recurso>.py             endpoints, delgados (5-10 líneas)
│   └── dependencies.py          inyección de sesión/auth
│
├── domain/                   🔵 NÚCLEO — lógica de negocio
│   ├── dtos.py                   contratos de la API (entrada/salida)
│   └── services/
│       └── <recurso>_service.py  validación + autorización + orquestación
│
├── persistence/               🟠 ADAPTADOR DE SALIDA — base de datos
│   ├── models.py                  modelos ORM (forma de la BD)
│   └── repositories/
│       └── <recurso>_repo.py      el único lugar que toca SQL
│
├── agents/ (o integrations/)  🟠 ADAPTADOR DE SALIDA — servicios externos (LLM, pagos, email)
│   └── aislado, swappable
│
└── core/                       ⚙️ PLOMERÍA TRANSVERSAL (no es negocio)
    ├── settings.py               config, fail-fast
    ├── database.py               engine + gestión de transacción
    ├── logging.py                logs estructurados + request_id
    └── auth.py                   verificación de identidad

Cada carpeta tiene un solo trabajo. Cuando dudo dónde poner algo nuevo, me pregunto: ¿esto es HTTP (→ api/), es una regla de negocio (→ domain/), es acceso a un sistema externo (→ persistence/ o el adaptador que corresponda), o es plomería que todos usan pero no es negocio (→ core/)?

3. Reglas de oro por capa

Config (core/settings.py)

  • Una sola fuente de verdad, leída de variables de entorno. Nunca duplico config por feature.
  • Fail fast: si falta una variable obligatoria o no cuadra, la app no arranca. Nunca un None silencioso que explota tres capas más adentro.
  • Orden de resolución: env del sistema > .env > default en código. Así Docker/K8s pueden sobreescribir sin tocar archivos.
  • Secretos nunca van a git. .env.example sí (plantilla sin valores reales).
  • Trato como comprometido cualquier secreto que haya pasado por un chat, screenshot, log o commit (aunque lo borre después): rotarlo es barato, asumir que nadie lo vio es caro.
  • Ojo con instanciar al importar (settings = Settings() a nivel de módulo): el fail-fast es bueno, pero significa que cualquier import del módulo explota si falta una env var — incluyendo un test que solo quería una función pura. Alternativa barata cuando eso empieza a doler: una factory get_settings() con @lru_cache (el patrón que recomienda la doc de FastAPI) — mismo fail-fast, pero diferido al primer uso real y fácil de sobreescribir en tests.

Base de datos / transacciones (core/database.py)

  • Una sola pieza es dueña de la transacción (típicamente la función que inyecta la sesión). Confirma si todo salió bien, revierte si algo falló. Repositorios y services nunca abren ni cierran transacciones a mano.
  • flush() (manda el SQL, no cierra la transacción) vs commit() (cierra y confirma) — solo la capa dueña de la transacción hace commit.
  • Cuidado con el commit() después del yield en la función que inyecta la sesión: corre fuera del try/except del endpoint, así que un error de commit (constraint violada, conexión caída) sale como 500 crudo que ningún handler puede atrapar. Lo asumo y lo monitoreo, o capturo la excepción en la propia función dueña de la transacción y la traduzco ahí.
  • Fechas: UTC en la base, zona horaria en los bordes. Timestamps siempre TIMESTAMPTZ en UTC. Y ojo con la lógica de "hoy": el servidor de producción casi siempre corre en UTC, así que date.today() cambia de día a otra hora que la del usuario (en Chile, a las 20:00–21:00). Toda regla de negocio que dependa del día ("el registro de hoy", "lo de ayer") debe calcular la fecha con la zona horaria del usuario explícita, nunca con la del servidor.
  • Health check real: verifica que la BD responde (SELECT 1), no solo que el proceso corre. Pero distingo liveness (¿el proceso vive? → reiniciarlo puede ayudar) de readiness (¿puede atender tráfico? → si la BD está caída, reiniciar mi app no arregla nada). Si la plataforma reinicia el servicio cuando /health falla, y /health falla porque la BD está caída, entro en un loop de reinicios inútil: reporto el estado de la BD en el body, y decido el status code (200 vs 503) según quién consume el endpoint.

Modelos vs DTOs

  • El modelo interno (ORM) es la forma de la base de datos. El DTO es el contrato de la API. Nunca son el mismo objeto, aunque a veces se parezcan.
  • Cuatro razones para no devolver el modelo directo: no exponer columnas internas, permitir campos calculados que no existen como columna, validar lo que entra sin ifs a mano, y desacoplar — un cambio de esquema no debería romper a quien consume la API.
  • DTOs de entrada validan estructura (longitud, tipos, enums) → error 422/400 automático. Las reglas de negocio (ownership, cuotas, estado) van en el service, no en el DTO.

Repositorios / adaptadores de salida

  • El repositorio es el único lugar que sabe el detalle de la tecnología (SQL, llamada HTTP a un proveedor). Recibe parámetros, devuelve datos — nunca sabe de HTTP ni de reglas de negocio.
  • Cuidado con N+1: si voy a listar N registros y por cada uno hago otra query, son N+1 llamadas. Se resuelve con un JOIN/agregación en una sola query.
  • En Python, la clase-singleton (user_repo = UserRepository()) es ceremonia: funciones planas en un módulo logran exactamente lo mismo mientras no necesite polimorfismo (ej. un repo fake para tests que no quiera resolver con mocks). Empiezo simple; la clase se gana su lugar después.

Services (el núcleo)

  • Acá vive la validación, la autorización y la orquestación. Recibe identificadores primitivos (user_id: int), nunca un objeto HTTP. Devuelve DTOs, nunca modelos internos.
  • Dos tipos de error típicos que el service levanta y la capa de entrada traduce a códigos HTTP: error de validación (→ 400) y error de permisos (→ 403).
  • 403 vs 404 es un trade-off, no una regla: el 403 explícito facilita el debugging, pero le confirma a un atacante que el recurso existe (habilita enumerar IDs); el 404 lo esconde — por eso GitHub responde 404 en repos privados ajenos. Mi criterio: API interna o de pocos usuarios conocidos → 403; API pública → 404. Lo innegociable es decidirlo una vez y ser consistente en todo el proyecto.
  • Cualquier regla de negocio nueva (cuotas, límites de uso, feature flags) entra aquí, nunca en el endpoint ni en el repositorio.

Endpoints / adaptador de entrada

  • Delgadísimos (5-10 líneas): sacar datos del request, llamar al service, traducir excepciones a códigos HTTP, devolver la respuesta. Cero SQL, cero reglas de negocio.
  • Un router/handler solo existe para el mundo cuando se registra explícitamente en el punto de entrada de la app.

Tests

  • Testeo cada capa con el reemplazo natural de sus bordes: services con repos falsos (funciones o clases fake escritas a mano, no mocks de framework), repos contra una BD real (Postgres efímero o de test — no SQLite: cambiar de motor invalida justo lo que el test debía validar), endpoints con el test client sobre la app armada.
  • La pirámide para esta arquitectura: muchos tests de service (ahí vive la lógica y corren rápido), algunos de repo (validan el SQL real), pocos end-to-end (el camino feliz completo).
  • Si un test necesita tres mocks para arrancar, el diseño está gritando: probablemente hay lógica en la capa equivocada.

Servicios externos / LLM / integraciones

  • Viven aislados en su propia carpeta, tratados como cualquier otro adaptador de salida — misma posición en el hexágono que la base de datos, distinta tecnología.
  • El endpoint que los expone (streaming, webhook) es mío: traduce el contrato de la librería externa a mi propio contrato de eventos/respuesta. No dejo que el formato de un proveedor externo se filtre hasta el frontend.
  • Si el proveedor tiene su propio almacenamiento de sesión/estado, le doy su propio espacio (base de datos separada, prefijo de tablas) para que no choque con mis tablas.
  • Si el adaptador es un LLM: registro tokens de entrada y salida por llamada, desde el día uno (cuestan distinto). El costo es un bug silencioso — sin medición, me entero con la factura.

Auth

  • Distingo el mecanismo por tipo de transporte: si el cliente puede mandar headers (fetch), uso un token en Authorization. Si no puede (ej. EventSource/SSE), uso una cookie firmada, con el alcance más chico posible.
  • El proveedor de identidad es un adaptador de salida más: "dame un token, te digo quién es". Cambiar de proveedor no debería tocar el 95% del código de auth.
  • Traduzco el id externo del proveedor a mi propio id interno una sola vez, en un service dedicado.
  • Calculo los atributos de cookie (SameSite, Secure) según el entorno, nunca hardcodeados.

Observabilidad / logs

  • Logs estructurados (JSON), no texto libre.
  • Cada request lleva un id único que viaja por todos sus logs (uso el mecanismo de contexto nativo del lenguaje, no lo paso a mano por cada función).
  • Middleware de timeout: cualquier request colgado debe cortarse solo, para no ocupar recursos para siempre.

Infra / Docker

  • Compose solo si mi producción es un contenedor que yo defino. Si despliego a una plataforma que buildea por mí (Railway, Render, Heroku-like) con base de datos gestionada, un Compose local es andamiaje que no se parece a nada real: corro el proceso directo (uv run uvicorn ...) y me conecto a la BD gestionada. Las reglas de abajo aplican cuando el contenedor sí es mío.
  • El nombre del proyecto de Compose no puede quedar implícito (por defecto usa el nombre de la carpeta) — dos proyectos con carpetas infra/ homónimas pueden compartir volúmenes por accidente. Lo nombro explícito.
  • En el Dockerfile: lo que cambia poco (dependencias) va primero, lo que cambia siempre (código) va al final.
  • depends_on con healthcheck, especialmente para la base de datos.
  • Un solo comando debería levantar todo el stack local.

Migraciones de esquema

  • Nunca toco el esquema a mano. Cambio el modelo → genero la migración → reviso el diff generado → aplico.

Resiliencia

  • Webhooks de entrada: asumo duplicados. Todo proveedor serio (Telegram, Stripe, GitHub) reintenta si no respondo 2xx a tiempo — el mismo evento puede llegarme dos veces. Dedupe por el id del evento (update_id en Telegram, event.id en Stripe, delivery id en GitHub), respondo 200 rápido, y muevo el procesamiento lento fuera del request si amenaza el timeout del proveedor. La meta es idempotencia: procesar el mismo evento dos veces debe dejar el sistema igual que una.
  • Identifico qué errores son transitorios (rate limit, sobrecarga) vs permanentes, y solo reintento los transitorios, con backoff exponencial.
  • Cuidado con el streaming: una vez que empecé a emitir datos al cliente, no puedo reintentar sin duplicar la salida. Reintento solo antes del primer byte/token emitido.
  • Un backup no existe hasta que lo restauré. Sé dónde están los backups de mi BD (gestionada o propia) y pruebo el restore una vez, antes de necesitarlo de verdad.

4. Checklist para arrancar un proyecto nuevo

  1. Lleno la tabla de stack (sección 1) — una fila por pieza, con el por qué.
  2. Dibujo el árbol de carpetas completo (sección 2), aunque solo cree las de la primera etapa.
  3. Settings con fail-fast antes que cualquier otra cosa.
  4. Conexión a datos + gestión de transacción — decido de una vez quién hace commit/rollback.
  5. Logs estructurados + request id — los voy a necesitar desde el primer bug.
  6. Un /health real que valide dependencias, no solo "el proceso vive".
  7. Un comando único para levantar todo (Compose con nombre explícito si prod es un contenedor propio; el proceso directo + BD gestionada si la plataforma buildea por mí).
  8. Recién ahí: modelos → migraciones → repositorios → DTOs → services → endpoints, en ese orden.
  9. Auth cuando haya algo que proteger — no antes, no después.
  10. Adaptadores externos (LLM, pagos, terceros) al final, aislados en su propia carpeta, hablando el contrato que yo definí.

5. Antipatrones a evitar

  • Un endpoint con SQL adentro, o un service que arma HTTPException — mezclaste capas.
  • Un repositorio que decide si un usuario tiene permiso para algo — eso es lógica de negocio, va en el service.
  • Config leída directo de os.environ en medio del código de negocio.
  • Devolver el modelo ORM/interno directo desde un endpoint.
  • Cualquier db.begin() fuera de la única función dueña de la transacción.
  • Cookies/tokens con atributos hardcodeados que "funcionan en mi máquina" pero no en producción.
  • Reintentar un stream completo después de haber emitido datos parciales.
  • Un webhook que procesa eventos sin dedupe por id — el primer reintento del proveedor duplica datos.
  • Dos proyectos Docker con el mismo nombre implícito compartiendo volúmenes sin darse cuenta.

Crédito: la arquitectura de base viene de la serie "Building a Production-Ready Agent Stack" de Bedir Tapkan (bedirtapkan.com), que adapté en una serie de proyectos anteriores. Esto es la generalización de esas reglas para cualquier proyecto nuevo.