Tags (71)
- Agile
- Alta disponibilidad
- Alternativas cloud
- Aop
- Arquitectura
- Arquitectura distribuida
- Automatizacion
- Azure devops
- Base de datos
- Buenas practicas
- Cloud
- Colas
- Competing consumers
- Convenciones
- Copilot
- Diseno
- Docker
- Docker compose
- Documentacion
- Eda
- Equipos
- Escalabilidad
- Flujo de negocio
- Flujo de trabajo
- Flyway
- Git
- Gradle
- Herramientas digitales
- Ia
- Iam
- Infraestructura
- Java
- Jerarquia tecnica
- Jpa
- Jsonb
- Kafka
- Kubernetes
- Liderazgo en software
- Lineamientos
- Log
- Logging
- Microservicios
- Mongodb
- Monitoreo
- Nosql
- Observabilidad
- Open source
- Plugins
- Postgresql
- Privacidad
- Programacion funcional
- Programacion reactiva
- Rabbitmq
- Rotacion de talento
- Saga
- Scrum
- Security
- Seguridad
- Self hosting
- Sistemas legados
- Spring boot
- Spring mvc
- Sql
- Streams
- Threadlocal
- Trazabilidad
- Versionado
- Web
- Webflux
- Websockets
- Zero trust
El fin de la 'Jaula de Oro': recuperar el control de tu infraestructura
- Mauricio ECR
- DevOps
- 22 Mar, 2026
Seguramente has pasado por esa etapa de "luna de miel" con las plataformas propietarias. Al principio, todo es idílico: lanzas una aplicación en Firebase en minutos, gestionas tus notas en Notion sin
El fin de la 'Jaula de Oro': recuperar el control de tu infraestructura
- Mauricio ECR
- DevOps
- 22 Mar, 2026
Seguramente has pasado por esa etapa de "luna de miel" con las plataformas propietarias. Al principio, todo es idílico: lanzas una aplicación en Firebase en minutos, gestionas tus notas en Notion sin fricciones y despliegas en Vercel con un simple clic. Sin embargo, a medida que el proyecto crece, la realidad empieza a morder. Llega una factura inesperada por un exceso de uso o te das cuenta de que tus datos están atrapados en una estructura que no puedes exportar fácilmente. Es la famosa "jaula de oro".
Esta sensación de vulnerabilidad es lo que está impulsando a miles de desarrolladores a buscar el código abierto. Pero no lo hacen solo por ahorro; lo hacen por soberanía. Quieren ser dueños del motor, no solo conductores. Para lograrlo, el primer paso es elegir los cimientos sobre los que construirás tu próxima idea.
El corazón de la App: ¿Qué motor elegimos para nuestros datos?
Cuando decides alejarte de soluciones cerradas, la primera gran pregunta es cómo sustituir la comodidad de un Backend como Servicio (BaaS). Aquí es donde el camino se bifurca según la complejidad de lo que tienes en mente.
Si buscas el rascacielos de las bases de datos, Supabase es tu respuesta. Al estar construido sobre PostgreSQL y Deno, te ofrece integridad relacional, autenticación y hasta capacidades de Inteligencia Artificial con bases de datos vectoriales. Es la opción para quien no quiere sacrificar nada. Pero quizás sientas que es "demasiada herramienta" para un proyecto personal. En ese caso, PocketBase es una revelación: un solo archivo ejecutable que usa SQLite y almacena todo localmente. Es sencillez pura, aunque requiere que tú mismo configures detalles como el servidor de correos (SMTP).
Para quienes prefieren un enfoque moderno y reactivo, Convex permite hacer consultas directamente en TypeScript, mientras que Appwrite destaca por su enfoque en la IA y su marketplace de integraciones, permitiéndote incluso alojar tu frontend en el mismo sitio. Y si eres de los que prefiere ensuciarse las manos con arquitecturas más puras, siempre puedes optar por alternativas basadas en GraphQL que se conectan a Postgres y ofrecen extensiones para gRPC o Kafka, aunque esto te obligará a escribir mucho más código propio.
Elegir el motor es vital, pero surge la duda inmediata: ¿Dónde vamos a poner a funcionar todo esto sin depender de las nubes tradicionales?
El despliegue: De inquilinos a dueños del servidor
Pasar de Vercel a un servidor propio (VPS) solía ser un proceso árido de comandos. Hoy, herramientas como Coolify actúan como tu propio panel de control privado. Con una interfaz gráfica, puedes desplegar proyectos de Node, React o bases de datos como MongoDB sin límites. Es el puente perfecto para quien quiere la facilidad de la nube en hardware propio.
Si buscas algo todavía más intuitivo y ligero, Dokploy ofrece una administración de usuarios y copias de seguridad que te hará olvidar que estás gestionando un servidor. Para los nostálgicos de Heroku que prefieren la terminal, Dokku sigue siendo el estándar del minimalismo, mientras que CapRover es la opción para el desarrollador avanzado que no quiere que una interfaz le oculte el acceso a la configuración de Nginx o Docker Swarm.
Una vez que la app está "viva", el flujo de trabajo diario nos lleva a la comunicación con el servidor. ¿Cómo probamos nuestras APIs de forma privada?
El laboratorio de APIs: Pruebas sin intermediarios
El malestar creció cuando herramientas como Postman forzaron la nube. Como respuesta, Bruno propone algo brillante: tus colecciones de API se guardan localmente en Git. Si quieres que la documentación viaje con el código, esta es la vía. Si prefieres algo más tradicional, Insomnia ofrece una experiencia casi idéntica a Postman, aunque su capa gratuita sea limitada para equipos.
Para pruebas rápidas desde el navegador, Hoppscotch (antes hop.io) es imbatible por su minimalismo. Pero el ecosistema es amplio: tienes a Yaak para despliegues en Docker, HTTPie para quienes aman la terminal legible estilo Curl, o Red Fox, un cliente minimalista para peticiones rápidas de gRPC o GraphQL sin distracciones.
Con las tuberías conectadas, el siguiente paso es organizar la mente del equipo. ¿Cómo gestionar el conocimiento sin regalar nuestras ideas a terceros?
Productividad y Datos: El cerebro del equipo
Es contradictorio construir un backend seguro si luego toda la estrategia vive en Notion. Para recuperar ese espacio, AppFlowy es el clon más fiel: tableros y bloques con la rapidez de Rust. Si necesitas algo más robusto para documentación técnica, Docmost soporta hasta ecuaciones y corre bajo Docker con Postgres y Redis.
Para quienes piensan de forma visual y conectada, Logseq usa un grafo de conocimiento y pizarras infinitas que rompen con la jerarquía de carpetas. Si el foco es la colaboración en tiempo real, Affine.pro es el especialista, mientras que BookStack se mantiene como el clásico para organizar notas anidadas de forma sencilla.
A veces, sin embargo, no necesitas notas, sino una base de datos visual. NocoDB transforma cualquier base de datos en una hoja de cálculo tipo Airtable, permitiéndote manejar imágenes y formatos complejos. Si buscas automatizar procesos, Baserow brilla con sus flujos visuales de eventos, mientras que Grist sigue siendo la opción veterana para consultas sencillas.
Pero un proyecto que crece se convierte en una organización. ¿Cómo escalamos la comunicación y los procesos de negocio?
El ecosistema empresarial: Gestión y Comunicación
Cuando Jira o ClickUp se vuelven lentos, Plane aparece como un reemplazo directo y ágil, con tableros e IA para gestionar tickets. Para la comunicación interna, Mattermost es el "Slack privado" por excelencia, ofreciendo canales y tableros en tu servidor.
Si necesitas videollamadas, Jitsi te permite tener tu propio "Zoom", aunque es de las herramientas que más hardware exige. Y para el control total de la empresa (ventas, pagos, logística), gigantes como Odoo (modular y en Python) o ERPNext te permiten administrar áreas completas bajo tu propio control.
Estimación de Recursos: ¿Qué nos cuesta la libertad?
Autohospedar te da control total, pero requiere que planifiques bien el hardware. Aquí tienes una estimación de lo que consumirá tu servidor según el uso:
| Herramienta | Reemplaza a | Uso | RAM Mínima (1-5 usuarios) | RAM Equipo (20+) | Desafío Honesto |
|---|---|---|---|---|---|
| Supabase | Firebase | Backend Pro | 4 GB RAM | 8 GB+ | Arquitectura compleja (Docker). |
| PocketBase | Firebase | MVP / Personal | 512 MB RAM | 2 GB | SQLite (límite de concurrencia). |
| Coolify | Vercel | PaaS / Despliegue | 2 GB RAM | 4 GB | Consume recursos al compilar apps. |
| Insomnia / Bruno | Postman | API Testing | Local | - | La colaboración es vía Git. |
| AppFlowy | Notion | Notas | 1 GB RAM | 2 GB | Todavía puliendo integraciones. |
| Plane | Jira | Proyectos | 4 GB RAM | 8 GB | Múltiples servicios corriendo. |
| NocoDB | Airtable | DB Visual | 1 GB RAM | 4 GB | Depende del volumen de datos. |
| Mattermost | Slack | Chat | 2 GB RAM | 4 GB | Búsquedas en tiempo real. |
| Jitsi | Zoom | Video | 4 GB / 4 vCPU | 8 GB+ | Muy sensible a la CPU y red. |
| Odoo / ERPNext | SAP / Oracle | ERP | 2 GB RAM | 8 GB | Curva de aprendizaje técnica. |
Conclusión: El círculo de la autonomía
Empezamos hablando de esa factura inesperada y de la fragilidad de no ser dueños de nuestra casa tecnológica. El recorrido por este ecosistema demuestra que la alternativa existe y es madura. No tienes que mudarte de golpe; la soberanía tecnológica es un hábito. Puedes empezar cambiando tus pruebas de API a Bruno hoy mismo, o levantando un pequeño PocketBase para tu próximo prototipo. Cada herramienta que recuperas es una llave que vuelve a tu bolsillo.
Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad
- Mauricio ECR
- Arquitectura
- 21 Mar, 2026
Has estado ahí. Es lunes por la mañana, abres el proyecto en tu IDE, y necesitas modificar cómo se procesa un pedido. Treinta minutos después, todavía estás navegando entre carpetas intentando encontr
Arquitectura Modular por Contexto: Cuando la Teoría se Encuentra con la Realidad
- Mauricio ECR
- Arquitectura
- 21 Mar, 2026
Has estado ahí. Es lunes por la mañana, abres el proyecto en tu IDE, y necesitas modificar cómo se procesa un pedido. Treinta minutos después, todavía estás navegando entre carpetas intentando encontrar todas las piezas del rompecabezas. El caso de uso está en algún lugar del módulo de dominio, el controlador REST disperso en los entry points, el adaptador de base de datos perdido en persistencia, y probablemente algunos DTOs compartidos en carpetas que juraste que recordarías. Este es el dilema que enfrentamos constantemente: las herramientas que usamos nos imponen una estructura técnica impecable, pero nuestro cerebro humano necesita algo diferente. Necesitamos que todo lo relacionado con "procesar un pedido" esté junto, fácil de encontrar, fácil de entender, fácil de modificar.
La Estructura que las Herramientas Imponen
Para entender el problema, primero necesitamos entender cómo funcionan las herramientas de scaffolding modernas, particularmente Scaffolding of Clean Architecture—una herramienta que muchas organizaciones adoptan porque estandariza proyectos y acelera su inicio. Esta herramienta genera automáticamente una estructura basada en Clean Architecture, pero con una característica particular: todo se organiza estrictamente por naturaleza técnica a través de módulos independientes de Gradle. No son simples carpetas; son módulos que se compilan independientemente, gestionan sus propias dependencias, y establecen fronteras arquitectónicas reales. La estructura generada típicamente incluye: Un módulo de dominio completamente independiente, sin dependencias hacia otros módulos del proyecto. Aquí viven las entidades, los casos de uso, los servicios de dominio, y crucialmente, las interfaces (gateways) que definen qué operaciones necesita el dominio sin especificar cómo se implementan. Es el núcleo puro de la lógica de negocio. Un módulo de infraestructura que se subdivide en dos grandes grupos. Por un lado, los "driven adapters"—módulos para implementar persistencia (jpa-repository), para consumir servicios externos (rest-consumer), para publicar mensajes (message-sender), y otros adaptadores que implementan los contratos que el dominio define. Por otro lado, los "entry points"—módulos para exponer APIs REST (api-rest), para consumir eventos (message-listener), para tareas programadas (scheduled-task), y otros puntos de entrada al sistema. Un módulo de aplicación que ensambla todo, conteniendo la configuración que conecta las piezas, los aspectos transversales como logging y auditoría, y el punto de arranque que levanta el sistema. Desde una perspectiva arquitectónica pura, es hermoso. Inversión de dependencias impecable: el dominio define contratos, la infraestructura los implementa. Separación clara de responsabilidades: cada módulo tiene su propósito bien definido. Fronteras forzadas por el sistema de build: no puedes violar accidentalmente las dependencias porque Gradle simplemente no compilará.
El Problema que Nadie Quiere Admitir
Pero entonces llega el día a día del desarrollo, y la fricción se hace evidente.
Necesitas implementar una nueva funcionalidad: registrar un usuario. Ejecutas el comando de scaffolding para generar el caso de uso. La herramienta lo crea en domain/usecase/ en una estructura genérica. Ejecutas otro comando para generar el entry point REST. Se crea en infrastructure/entry-points/api-rest/ en otra ubicación genérica. Necesitas persistencia, ejecutas el comando para generar el adaptador JPA. Aparece en infrastructure/driven-adapters/jpa-repository/ en su propia ubicación técnica.
Cada componente vive exactamente donde debe vivir según su naturaleza técnica. El problema es que conceptualmente todos estos componentes están relacionados—todos son parte de "registrar un usuario"—pero físicamente están dispersos por toda la estructura del proyecto según su clasificación técnica.
El resultado es predecible: cinco pestañas abiertas en tu IDE, navegación constante entre módulos y carpetas, DTOs compartidos en ubicaciones centralizadas que sirven a múltiples propósitos, validadores reutilizables que intentan ser genéricos, y mappers comunes que traducen entre representaciones para varios casos de uso.
Y hay algo peor: seis meses después, cuando otro desarrollador necesita modificar esa funcionalidad de registro de usuarios, el proceso se repite. Buscar, navegar, intentar recordar dónde quedaron todas las piezas dispersas. El conocimiento está fragmentado, la comprensión es difícil, y cada modificación se siente como resolver un rompecabezas.
La pregunta natural surge: ¿por qué no simplemente abandonar esta estructura modular y volver a algo más simple donde todo esté junto? Porque entonces perdemos beneficios reales que los módulos independientes proporcionan: compilación incremental que solo recompila lo que cambió, gestión explícita de dependencias que previene acoplamiento accidental, y fronteras arquitectónicas forzadas que mantienen la integridad del diseño a largo plazo.
O podrías pensar: ¿por qué no compartir más componentes entre funcionalidades? Crear carpetas centralizadas de DTOs reutilizables, validadores comunes, mapeadores genéricos. Suena eficiente hasta que dos funcionalidades que comparten un validador divergen en sus necesidades. Entonces enfrentas la decisión imposible: ¿modificas el validador compartido arriesgando romper la otra funcionalidad, o duplicas el código que justamente intentabas evitar?
La Solución Está en la Dualidad
La respuesta no está en elegir entre estructura técnica o cohesión conceptual. La respuesta está en reconocer que ambas son valiosas pero en diferentes niveles.
Imagina mantener la estructura de módulos técnicos que Scaffolding of Clean Architecture genera—porque proporciona beneficios arquitectónicos reales—pero cambiar radicalmente cómo organizas el código dentro de cada módulo. En lugar de estructuras técnicas genéricas donde todos los componentes del mismo tipo conviven en carpetas planas, organizas por contextos de negocio donde cada funcionalidad tiene su propio espacio autocontenido.
El módulo de dominio sigue siendo un módulo de dominio, pero cuando lo abres, en lugar de encontrar una carpeta usecase/ con cincuenta casos de uso en una lista plana, encuentras algo diferente. Cada caso de uso vive en su propia carpeta de contexto: usecase/registrar-usuario/, usecase/procesar-pedido/, usecase/consultar-inventario/. Cada contexto agrupa todo lo que esa funcionalidad específica necesita.
Dentro de registrar-usuario/ no solo está el archivo del caso de uso. Está su carpeta dto/ con los DTOs de entrada y salida diseñados exactamente para lo que este caso de uso necesita—no DTOs genéricos compartidos que intentan servir múltiples propósitos. Está su carpeta mapper/ con traductores que mapean precisamente entre las representaciones que este caso de uso maneja. Está su carpeta validator/ con validadores que aplican las reglas específicas de negocio de registrar usuarios. Si necesita enriquecer datos desde otras fuentes, tiene su carpeta enricher/. Si requiere utilidades especializadas, tiene su carpeta util/.
Todo junto. Todo cohesivo. Todo autocontenido.
Lo mismo sucede en el módulo de entry points. En lugar de una carpeta genérica api-rest/ con todos los controladores mezclados, encuentras api-rest/registrar-usuario-api/ como su propio contexto. Dentro están los DTOs específicos de la API REST—diferentes de los DTOs del caso de uso porque representan el contrato externo, no el contrato de dominio. Están los mapeadores que traducen entre el mundo HTTP y el mundo del dominio. Están los validadores específicos de la capa de presentación que verifican formatos y restricciones del protocolo.
Y en el módulo de adaptadores, en lugar de entidades JPA genéricas en una carpeta común, encuentras jpa-repository/usuario-persistencia/ como contexto autocontenido con sus entidades JPA, sus repositorios Spring Data, su implementación del gateway del dominio, sus mapeadores entre entidades JPA y entidades de dominio, todo junto porque conceptualmente pertenece junto.
La estructura de módulos técnicos permanece intacta. El dominio sigue siendo independiente. Los adaptadores siguen implementando contratos del dominio. Los entry points siguen invocando casos de uso. Clean Architecture se mantiene en todo su esplendor. Pero dentro de cada módulo, la organización refleja el negocio, no solo la técnica.
Los Beneficios Tangibles que Cambian Todo
Esta dualidad—módulos técnicos afuera, contextos de negocio adentro—transforma radicalmente la experiencia de desarrollo.
Cuando necesitas modificar el registro de usuarios seis meses después de implementarlo, abres domain/usecase/registrar-usuario/ y todo está ahí. No hay búsquedas en carpetas compartidas. No hay intentos de recordar dónde quedó el validador o el mapper. La lógica del caso de uso, sus DTOs, sus validadores, sus enriquecedores, sus utilidades—todo en un solo lugar. Abres api-rest/registrar-usuario-api/ y encuentras todo lo relacionado con cómo esa funcionalidad se expone vía REST. Abres jpa-repository/usuario-persistencia/ y encuentras todo lo relacionado con cómo se persiste.
La velocidad de comprensión se dispara. Un desarrollador nuevo asignado a modificar una funcionalidad específica puede abrir su contexto y ver inmediatamente qué hace, cómo lo hace, y qué elementos utiliza. No necesita entender todo el sistema, solo el contexto específico con el que trabajará. El onboarding que solía tomar semanas ahora toma días porque el conocimiento no está disperso por todo el código base sino contenido en unidades comprensibles.
El mantenimiento se simplifica dramáticamente. Un bug en el procesamiento de pedidos significa ir a domain/usecase/procesar-pedido/. La mayoría de las veces, el problema y la solución están completamente contenidos en ese contexto. Haces el cambio, ejecutas los tests de ese contexto específico, y tienes alta confianza de que no rompiste nada más porque la independencia entre contextos minimiza los efectos colaterales.
La evolución del sistema se vuelve orgánica y natural. Una funcionalidad crítica del negocio crece en complejidad: agregas más validadores en su carpeta validator/, más enriquecedores en su carpeta enricher/, más utilidades en su carpeta util/. Otra funcionalidad permanece simple porque así lo requiere el negocio, con solo el caso de uso, un par de DTOs, y un mapper básico. No hay presión por mantener todo al mismo nivel de complejidad o estructura uniforme. Cada contexto crece según sus propias necesidades.
El trabajo en equipo fluye mejor sin fricción constante. Múltiples desarrolladores trabajan simultáneamente en diferentes contextos—uno en registrar usuarios, otro en procesar pedidos, un tercero en consultar inventario—sin colisionar porque el código está físicamente separado. Los conflictos de merge que solían ser diarios ahora son raros. Las revisiones de código son más efectivas porque los cambios están claramente contenidos: puedes ver exactamente qué se modificó dentro de un contexto específico y entender su alcance sin necesitar conocimiento exhaustivo de todo el sistema.
Y quizás lo más valioso: la confianza al hacer cambios. Cuando todo lo relacionado con una funcionalidad está junto y los contextos son genuinamente independientes, puedes modificar código con la confianza de que tus cambios no tendrán efectos colaterales sorpresa en funcionalidades no relacionadas. Los tests del contexto verifican que no rompiste esa funcionalidad específica, y la independencia entre contextos garantiza que no afectaste otras inadvertidamente.
El Principio de Duplicación Intencional
Pero hay un elefante en la habitación que necesitamos abordar directamente: verás código aparentemente duplicado. Y eso va a incomodarte.
Dos contextos tendrán validadores que lucen similares. Tres contextos tendrán mappers que parecen hacer traducciones parecidas. Varios contextos tendrán utilidades que se ven redundantes. Tu instinto—entrenado por años de escuchar "Don't Repeat Yourself"—gritará que esto está mal, que debes extraer, generalizar, compartir.
Necesitas resistir ese impulso porque está basado en una falsa equivalencia entre similitud y identidad.
Dos validadores que hoy lucen idénticos no son el mismo concepto. Uno valida emails en el contexto de registrar usuarios, donde quizás solo verificas el formato básico. Otro valida emails en el contexto de enviar campañas de marketing, donde quizás verificas que el dominio no esté en una lista de bloqueo, que el usuario haya dado consentimiento, que el email haya sido verificado previamente. Parecen el mismo código hoy, pero representan reglas de negocio de contextos diferentes que inevitablemente divergirán mañana.
Si hubieras compartido ese validador "para no duplicar código", cuando uno de los contextos necesite evolucionar—y lo necesitará—enfrentarás una decisión imposible. O modificas el validador compartido y arriesgas romper todos los contextos que lo usan, o agregas condicionales que verifican desde qué contexto se está llamando (acoplamiento horrible), o terminas duplicando el código de todas formas cuando la presión del deadline no te deja tiempo para refactorizaciones elegantes.
La duplicación intencional es el precio que pagas por la independencia. Y resulta ser un precio extraordinariamente bajo comparado con el costo del acoplamiento que crearías compartiendo componentes prematuramente.
Esto no significa nunca compartir nada. Significa compartir solo lo que tiene una razón de negocio genuina para ser compartido. Un modelo de dominio como Usuario que representa el mismo concepto fundamental a través de múltiples contextos merece vivir en domain/model/usuario/ como elemento transversal. Un servicio de dominio con lógica compleja de cálculo de precios que múltiples casos de uso invocan justifica su existencia en domain/service/calculo-precios/. Pero un validador que casualmente verifica el mismo formato en dos contextos diferentes no necesita ser compartido solo porque el código se ve similar.
La guía es simple: extrae como transversal solo cuando hay identidad conceptual de negocio, no cuando hay mera similitud técnica superficial. Y cuando dudes, prefiere duplicar. Es más fácil extraer código duplicado después cuando verdaderamente lo necesitas que desenredar dependencias compartidas cuando los contextos necesitan divergir.
Cómo Convive con las Herramientas de Scaffolding
La pregunta práctica que surge inmediatamente es: si Scaffolding of Clean Architecture genera código en ubicaciones genéricas basadas en naturaleza técnica, ¿cómo logras esta organización por contextos?
La respuesta es un flujo de trabajo disciplinado que combina generación automática con reorganización consciente.
Cuando necesitas crear un caso de uso, ejecutas el comando de scaffolding que lo genera en domain/usecase/ en una estructura base genérica. Inmediatamente después, antes de escribir una línea de lógica, creas manualmente la carpeta de contexto domain/usecase/nombre-funcionalidad/ y mueves el archivo generado ahí. Creas las subcarpetas que ese caso de uso específico necesitará: dto/, mapper/, validator/, etc.
Cuando generas un entry point REST, el scaffolding lo crea en infrastructure/entry-points/api-rest/ en ubicación genérica. De inmediato creas la carpeta de contexto api-rest/nombre-funcionalidad-api/ y reorganizas. Cuando generas un adaptador de persistencia, se crea en infrastructure/driven-adapters/jpa-repository/ genéricamente. Creas jpa-repository/contexto-persistencia/ y contextualizas.
El scaffolding proporciona el esqueleto técnico correcto en el módulo correcto con la estructura base apropiada. Tú proporcionas la organización conceptual que refleja el negocio. Es trabajo adicional, sí, pero es trabajo que pagas una vez y recuperas mil veces cada vez que necesitas encontrar, entender, o modificar código.
Esta reorganización no puede ser opcional ni algo que "haremos cuando tengamos tiempo". Debe ser parte no negociable del proceso de desarrollo desde el día uno. Cada componente generado se contextualiza inmediatamente antes de comenzar a escribir su lógica. Las revisiones de código verifican no solo que el código funciona sino que está correctamente organizado en su contexto apropiado.
La disciplina es crucial porque es fácil tomar atajos bajo presión. "Solo por esta vez dejaré el código donde el scaffolding lo generó, no tengo tiempo de reorganizar ahora." Pero esos atajos se acumulan. La estructura se vuelve inconsistente—algunos componentes contextualizados, otros dispersos genéricamente—y gradualmente pierdes todos los beneficios. Es como mantener limpia una cocina: si lavas los platos después de cada comida es fácil, si los dejas acumular se vuelve insoportable.
Maximizando los Beneficios: Desarrollo Outside-In
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Empiezas con el entry point. Si la funcionalidad se expondrá vía REST, generas el controlador con scaffolding, lo reorganizas en su contexto api-rest/crear-pedido-api/, creas sus DTOs de request y response, y haces que devuelva datos inventados pero con la estructura correcta. Levantas la aplicación. Haces una petición HTTP real. El endpoint responde en menos de treinta minutos desde que comenzaste. Son datos falsos, pero el contrato de la API está validado y tienes algo tangible que puedes mostrar.
Ahora creas el caso de uso. Generas con scaffolding, reorganizas en domain/usecase/crear-pedido/, creas sus DTOs—diferentes de los de la API—y lo haces devolver también datos simulados. Creas los mapeadores en api-rest/crear-pedido-api/mapper/ que traducen entre DTOs de API y DTOs de caso de uso. Inyectas el caso de uso en el controlador y conectas el flujo.
Levantas la aplicación nuevamente. Haces una petición. Los datos fluyen: API recibe → mapea a lenguaje de dominio → caso de uso procesa → mapea a lenguaje de API → responde. Todo funciona. Siguen siendo datos simulados, pero la arquitectura de comunicación entre capas está validada. Has probado que las abstracciones encajan correctamente.
Continúas capa por capa. Si el caso de uso necesita un modelo transversal que no existe, lo creas en domain/model/pedido/ con su gateway. Si necesita lógica reutilizable, creas el servicio en domain/service/calculo-descuentos/. Cada uno inicialmente con lógica simplificada o simulada.
Implementas el adaptador de persistencia. Generas con scaffolding, organizas en jpa-repository/pedido-persistencia/ con sus entidades JPA, repositorios, implementación del gateway, mapeadores. Lo pruebas de forma aislada con tests de integración contra base de datos de prueba. Solo cuando funciona correctamente lo conectas al caso de uso. Haces una petición end-to-end y por primera vez los datos realmente se persisten y recuperan.
Agregas validadores al caso de uso, uno a la vez, en domain/usecase/crear-pedido/validator/. Pruebas que rechazan correctamente datos inválidos. Agregas enriquecedores en enricher/ que complementan información. Implementas clientes para servicios externos, cada uno en su contexto en rest-consumer/servicio-inventario/. En cada paso tienes algo funcional que puedes probar.
Este flujo outside-in con retroalimentación temprana transforma el desarrollo. Nunca estás más de un paso alejado de algo que funciona. Los problemas de integración se descubren tempranamente cuando son fáciles de resolver. Siempre tienes una versión funcional—aunque incompleta—en lugar de un sistema completo que no funciona hasta el final. Y la presión psicológica desaparece porque constantemente ves progreso tangible.
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Con la estructura clara y el proceso de reorganización establecido, hay una práctica que potencia enormemente los beneficios de esta organización por contextos: el desarrollo outside-in. No es obligatorio seguir este enfoque, pero resulta extraordinariamente efectivo para avanzar con la seguridad de que cada paso está correctamente implementado antes de pasar al siguiente. Esta afirmación se entiende mejor al contrastarla con los inconvenientes de la forma tradicional de iniciar el desarrollo. En la forma tradicional normalmente se comienza desde el dominio y se trabaja hacia afuera. Esto implica que no puedes validar realmente que algo funciona hasta que todas las capas están implementadas. Pasas días o semanas escribiendo código sin poder ejecutar nada end-to-end, descubriendo problemas de integración solo al final cuando son más caros de resolver. El enfoque que mejor aprovecha esta estructura es el opuesto: comenzar desde el punto de entrada y avanzar hacia adentro, validando cada capa inmediatamente después de crearla.
Las Incomodidades Reales
Seamos honestos sobre los desafíos porque existen y necesitas conocerlos antes de adoptar esta aproximación. La disciplina de reorganización después de cada generación de scaffolding es real y constante. Bajo presión de deadlines, saltarse este paso es tentador. "Lo reorganizaré después" se convierte en "nunca". La solución no es intentar automatizar la reorganización—requiere juicio humano sobre qué constituye un contexto apropiado—sino hacer de la reorganización una parte no negociable del proceso. La definición de "done" incluye código correctamente contextualizado. Las revisiones de código lo verifican. Los desarrolladores nuevos son entrenados en esto desde el día uno. Las estructuras de carpetas serán más profundas. Más niveles de anidamiento que en estructuras tradicionales. Inicialmente esto se siente lento y confuso. Los IDEs modernos ayudan significativamente con búsquedas rápidas y navegación inteligente, pero aún así hay un período de adaptación de algunas semanas. Después, la mayoría de desarrolladores encuentra que localizar código es más rápido porque saben exactamente dónde buscar: todo lo relacionado con una funcionalidad está en su contexto. Decidir qué extraer como transversal y qué mantener en contextos requiere experiencia y juicio. No hay reglas absolutas que puedas seguir mecánicamente. Un modelo de dominio usado extensamente claramente debe ser transversal. Un servicio con lógica compleja reutilizable justifica extracción. Pero un mapper usado por un solo caso de uso debe permanecer en ese contexto. Esta decisión requiere práctica, y a veces te equivocarás y necesitarás refactorizar. Eso es normal y esperado. El tamaño del código base crecerá más que en aproximaciones tradicionales debido a la duplicación intencional. Esto puede parecer problemático especialmente en equipos acostumbrados a optimizar por menos líneas de código. Pero la métrica relevante no es el tamaño absoluto sino la mantenibilidad y comprensibilidad. Un código base más grande pero bien organizado, donde cada pieza tiene su lugar claro, es infinitamente más fácil de mantener que un código base más pequeño con componentes compartidos complejos y dependencias cruzadas que hacen imposible entender el impacto de los cambios.
Haciendo la Transición en Tu Equipo
Si esto resuena contigo y quieres adoptarlo, la transición requiere más que cambiar la estructura de carpetas. Comienza solo con código nuevo. Intentar refactorizar todo un código base existente de una vez es una receta para el fracasgo: demasiado costo, demasiado riesgo, demasiada resistencia del equipo. Aplica la filosofía a nuevas funcionalidades que implementes desde cero. Refactoriza código existente solo cuando ese código necesita modificaciones significativas de todas formas—entonces aprovechar para reorganizarlo en contextos apropiados es inversión que ya estás haciendo. Esta adopción gradual permite que el equipo aprenda sin el trauma de una reescritura masiva. Por algunos meses convivirán dos estilos: código viejo en estructura tradicional, código nuevo en contextos. Está bien. Eventualmente, a medida que el código viejo se modifica, se va reorganizando. En un año, la mayoría del código activo estará contextualizado. Invierte en documentación viva con ejemplos concretos de tu código base real. Abstracciones teóricas sobre "contextos autocontenidos" no funcionan tan bien como mostrar "mira, así organizamos el caso de uso de procesar pedidos, aquí están todos sus elementos, esta es la razón por la que cada uno está donde está". Cuando los desarrolladores pueden ver ejemplos reales del propio proyecto, la comprensión es inmediata. Las sesiones de pair programming donde desarrolladores experimentados en la filosofía trabajan con nuevos miembros aplicándola en práctica valen más que cualquier documento. Ver cómo alguien genera código con scaffolding y luego inmediatamente lo reorganiza, cómo decide qué subcarpetas crear, cómo identifica qué debe ser transversal versus específico del contexto—eso se aprende haciendo, no leyendo. Las revisiones de código son críticas para mantener integridad arquitectónica. Verifica que el código está en el contexto correcto, que sigue principios de autocontención, que no está creando acoplamiento innecesario. Esta verificación debe tener el mismo peso que verificar corrección funcional. Si el código funciona pero está mal organizado, solicitar cambios no es pedantería—es proteger la mantenibilidad a largo plazo del sistema. Y mantén flexibilidad dentro del marco. No todo contexto necesita la misma estructura. Un caso de uso simple no necesita todas las subcarpetas que uno complejo requiere. Lo importante son los principios—autocontención, cohesión conceptual, independencia—no seguir rígidamente una plantilla.
Performance: La Pregunta que Todos Hacen
Eventualmente alguien preguntará: "¿Toda esta separación y múltiples traducciones entre DTOs no tiene costo de performance prohibitivo?" La respuesta pragmática: en la vasta mayoría de aplicaciones empresariales, no. El costo de mapear entre DTOs de API, DTOs de caso de uso, entidades de dominio, y entidades JPA se mide en microsegundos. Las operaciones que realmente importan—queries a base de datos, llamadas HTTP a servicios externos, procesamiento de lógica de negocio compleja—se miden en milisegundos o más. Los mapeos son ruido estadístico en comparación. Cuando la performance es genuinamente crítica—procesamiento batch de millones de registros, sistemas de alta frecuencia, servicios con SLAs de latencia extremos—la arquitectura no lo prohíbe. Un caso de uso puede saltarse algunos mapeos, trabajando más directamente con representaciones de niveles inferiores si es necesario. La clave es que esto sea una decisión consciente, documentada, y justificada por mediciones reales de performance bajo carga real, no por optimización prematura basada en suposiciones. Las optimizaciones del compilador Java y la JVM también ayudan enormemente. El inlining de métodos pequeños significa que muchos mapeos que parecen caros en el código fuente son esencialmente gratuitos en el bytecode optimizado. La eliminación de código muerto elimina paths que nunca se ejecutan. El profile-guided optimization del JIT compiler optimiza los caminos que realmente se usan frecuentemente. La guía es clara: construye con la arquitectura limpia por defecto. Mide cuando tengas dudas reales. Optimiza solo donde las mediciones bajo carga real muestren necesidad. La claridad arquitectónica facilita la optimización cuando es necesaria porque es trivial identificar dónde está el cuello de botella—está en un contexto específico—y modificar solo esa parte sin afectar el resto.
El Impacto en la Cultura del Equipo
Más allá de la estructura de carpetas, esta filosofía cambia cómo los equipos trabajan y colaboran. El ownership del código se vuelve natural y claro. Cuando todo lo relacionado con una funcionalidad está en un contexto específico, es fácil asignar ownership de ese contexto a alguien. No significa que solo esa persona puede tocarlo—eso crearía silos de conocimiento—pero hay alguien responsable de su calidad, coherencia, y evolución. Cuando surge una pregunta sobre esa funcionalidad, hay un punto de contacto claro. Cuando necesita evolucionar, hay alguien que entiende su contexto completo. La planificación de sprints se simplifica porque las historias de usuario frecuentemente se mapean directamente a casos de uso, y los casos de uso son contextos autocontenidos. Estimar el esfuerzo se vuelve más predecible: implementar un caso de uso significa crear su contexto con los elementos que necesita. La variabilidad viene de cuántos y qué tipo de elementos específicos requiere—validadores complejos versus simples, múltiples enriquecedores versus ninguno—pero el patrón general es consistente. Las estimaciones mejoran porque hay menos incertidumbre sobre alcance y dependencias. La colaboración cambia de naturaleza. En lugar de conflictos constantes por múltiples personas modificando los mismos archivos compartidos, diferentes desarrolladores trabajan en diferentes contextos con mínima interferencia. Cuando necesitan coordinación, típicamente es a través de interfaces bien definidas—un caso de uso invocando un servicio de dominio, un entry point usando un caso de uso—no modificando los mismos archivos internos simultáneamente. El testing se vuelve más natural. Cada contexto puede probarse de forma aislada con sus dependencias mockeadas apropiadamente. Los tests unitarios se enfocan en lógica específica del contexto. Los tests de integración verifican que el contexto se comunica correctamente con sus dependencias reales. Los tests end-to-end verifican que el flujo completo funciona atravesando múltiples contextos. Esta separación hace que los tests sean más simples de escribir, más rápidos de ejecutar, y más fáciles de mantener porque el alcance de cada nivel de testing es claro. La rotación de personas—tanto salidas como nuevas incorporaciones—se maneja mejor. El conocimiento no está uniformemente distribuido por un código base monolítico donde entender cualquier parte requiere entender el todo. El conocimiento está organizado por contextos. Un desarrollador saliente puede documentar y traspasar los contextos de los que tenía ownership específico. Un desarrollador entrante puede comenzar tomando ownership de contextos particulares, aprendiendo el sistema incrementalmente en lugar de necesitar una descarga masiva de conocimiento de todo desde el día uno.
Evolución y Futuro
Esta filosofía híbrida no es un destino final sino un punto en la evolución continua de cómo organizamos código complejo. Las herramientas seguirán mejorando. Los IDEs se volverán más inteligentes en entender y navegar estructuras modulares complejas. Las herramientas de scaffolding podrían eventualmente aprender a generar código ya organizado por contextos, preguntando al desarrollador a qué contexto de negocio pertenece el componente antes de generarlo. La generación de código asistida por IA podría entender patrones arquitectónicos como esta filosofía de contextos y generar código que automáticamente se organiza correctamente, reduciendo la carga de disciplina manual. Las herramientas de análisis estático podrían detectar violaciones de la organización por contextos, identificando cuando un contexto accede directamente a detalles internos de otro o cuando la estructura se está volviendo inconsistente. A medida que más sistemas evolucionan hacia arquitecturas distribuidas, la clara separación de contextos se vuelve aún más valiosa. Los bounded contexts bien definidos facilitan decisiones sobre qué debe desplegarse junto y qué podría beneficiarse de despliegue independiente como microservicios. Los módulos Gradle proporcionan las fronteras naturales para estas decisiones, y la organización por contextos asegura que cada unidad desplegable sea cohesiva y completa. Pero más allá de las herramientas futuras, los principios permanecen: autocontención facilita comprensión, cohesión conceptual facilita mantenimiento, independencia entre contextos facilita evolución. Estos principios son atemporales incluso si los detalles de implementación evolucionan con nuevas tecnologías.
Casos Reales y Lecciones Aprendidas
En equipos que han adoptado esta aproximación, ciertos patrones emergen consistentemente. La transición inicial típicamente toma entre cuatro y ocho semanas. Las primeras dos semanas son de confusión y resistencia—"esto parece más complicado", "por qué estamos duplicando código", "no entiendo dónde poner las cosas". Las siguientes dos a cuatro semanas son de adaptación—el músculo de reorganizar después de scaffolding se desarrolla, las decisiones sobre qué contextualizar versus qué extraer se vuelven más naturales. Después de seis a ocho semanas, la mayoría de desarrolladores reporta que encontrar y modificar código se siente significativamente más fácil que antes. El momento "ajá" típicamente llega cuando un desarrollador necesita modificar una funcionalidad que implementó semanas antes. Abre el contexto esperando tener que buscar piezas dispersas por todo el proyecto, y descubre sorprendido que todo está ahí. "Oh, esto realmente funciona." Los equipos exitosos típicamente desarrollan sus propias convenciones específicas sobre nombrado de contextos, cuándo crear subcarpetas adicionales, cómo documentar decisiones de diseño dentro de contextos. Estas convenciones locales complementan los principios generales, adaptando la filosofía a las necesidades específicas del dominio y la cultura del equipo. Un error común es intentar que todos los contextos tengan exactamente la misma estructura. Un caso de uso complejo puede tener ocho subcarpetas diferentes. Uno simple puede tener solo tres. Ambos están bien. La estructura sirve a la funcionalidad, no al revés. Forzar uniformidad rígida crea carpetas vacías o artificialmente pobladas que no agregan valor. Otro error es ser demasiado conservador con la duplicación, intentando extraer cualquier similitud mínima. Esto recrea el problema original de componentes compartidos con dependencias complejas. La guía que funciona: cuando dudes si extraer, espera. Duplica inicialmente. Solo extrae cuando el tercer o cuarto contexto necesita exactamente lo mismo y tienes evidencia clara de que representa un concepto verdaderamente transversal del negocio, no solo similitud técnica superficial.
Relación con Otros Patrones
Esta filosofía no existe en vacío sino que complementa y se integra con otros patrones y prácticas establecidas. Domain-Driven Design proporciona el vocabulario para identificar y organizar contextos. Los bounded contexts de DDD se mapean naturalmente a agrupaciones de contextos en esta arquitectura. Las entidades, value objects, aggregates, y domain events de DDD encuentran su lugar en los contextos de modelo. Los servicios de dominio de DDD corresponden directamente a los servicios de dominio en esta estructura. CQRS puede aplicarse dentro de la organización por contextos. Los casos de uso que modifican estado (comandos) pueden organizarse claramente separados de los que solo leen (queries), permitiendo optimizaciones diferentes para cada tipo sin sacrificar claridad organizacional. Event Sourcing se integra naturalmente. Los domain events que las entidades generan pueden persistirse como event stream. Los adaptadores de persistencia implementan event stores. Los casos de uso publican eventos que otros contextos consumen, manteniendo independencia entre bounded contexts mientras permiten coordinación. La relación con Microservicios es interesante. Cada bounded context con sus casos de uso, servicios, y adaptadores podría potencialmente extraerse como microservicio independiente. Los módulos Gradle proporcionan fronteras naturales para esta extracción. Los gateways que actualmente se implementan con adaptadores locales podrían reemplazarse con adaptadores que hacen llamadas remotas. La organización por contextos facilita esta evolución porque las dependencias entre contextos son explícitas a través de gateways, haciendo visible el acoplamiento que necesitaría convertirse en comunicación remota.
El Verdadero Valor
Al final, todo esto se reduce a una verdad simple: la arquitectura de software existe para facilitar resolver problemas de negocio de manera efectiva y sostenible en el tiempo.
Una buena arquitectura es aquella que permite a los desarrolladores entender rápidamente qué hace el código, hacer cambios con confianza, y evolucionar el sistema según las necesidades del negocio cambian. No es la que se ve más elegante en un diagrama. No es la que usa las tecnologías más nuevas. No es la que tiene menos líneas de código. Es la que funciona para el equipo que la mantiene y el negocio que la necesita.
Esta filosofía híbrida de contextos dentro de módulos técnicos busca precisamente eso. No promete eliminar toda complejidad—la complejidad es inherente a sistemas empresariales que resuelven problemas complejos—pero promete organizarla de manera que sea manejable y comprensible.
Promete que cuando necesites modificar algo, sabrás dónde buscar porque todo lo relacionado está junto. Promete que tus cambios estarán contenidos y sus efectos predecibles porque los contextos son independientes. Promete que nuevos desarrolladores pueden comenzar a contribuir sin necesitar entender todo el sistema porque pueden tomar ownership de contextos específicos.
No es la única manera de organizar código, y no será la mejor para todos los proyectos y equipos. Pero para equipos que trabajan con herramientas de scaffolding que generan estructura modular, que construyen aplicaciones empresariales complejas donde el código vive y evoluciona durante años, y que valoran tanto la disciplina arquitectónica como la productividad práctica, ofrece un balance probado entre estructura y pragmatismo.
La adopción requiere más que cambiar carpetas. Requiere cambiar cómo piensas sobre organización de código. Requiere disposición a cuestionar dogmas como "nunca duplicar código" y reconocer que la duplicación intencional es frecuentemente mejor que el acoplamiento prematuro. Requiere disciplina para mantener integridad arquitectónica incluso bajo presión. Requiere inversión en documentación, entrenamiento, y procesos de revisión que refuercen los principios.
Pero para equipos dispuestos a hacer esa inversión, los retornos son reales y duraderos. Código que seis meses después todavía puedes entender rápidamente. Cambios que implementas con confianza sabiendo que no romperás cosas no relacionadas. Sistemas que crecen en funcionalidad sin colapsar bajo su propio peso. En un mundo donde el software exitoso inevitablemente crece en complejidad, eso no es poca cosa.
Así que la próxima vez que abras un proyecto y necesites modificar cómo se procesa un pedido, no pasarás treinta minutos buscando piezas dispersas por toda la estructura. Abrirás domain/usecase/procesar-pedido/ y todo estará ahí. Esa es la promesa. Esa es la diferencia.
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD
- Mauricio ECR
- Arquitectura
- 01 Mar, 2026
Hay una tensión que todo equipo de desarrollo enfrenta tarde o temprano: la necesidad de saber qué está pasando dentro del sistema sin que esa necesidad contamine el código que lo hace funcionar. Los
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD
- Mauricio ECR
- Arquitectura
- 01 Mar, 2026
Hay una tensión que todo equipo de desarrollo enfrenta tarde o temprano: la necesidad de saber qué está pasando dentro del sistema sin que esa necesidad contamine el código que lo hace funcionar. Los logs son la herramienta más inmediata para satisfacer esa necesidad, pero también son, cuando no se gestionan con criterio, una de las fuentes más frecuentes de deuda técnica, acoplamiento silencioso y dolores de cabeza en producción.
Lo que se propone en este artículo es un modelo de observabilidad para sistemas construidos con Java 21, Spring Boot 3.5.x, Gradle y arquitectura DDD. El objetivo no es solo definir dónde va cada logger.info(), sino construir un esquema en el que la observabilidad sea una preocupación transversal completamente separada de la lógica de negocio, implementada mediante Programación Orientada a Aspectos (AOP) y sostenida por convenciones que cualquier miembro del equipo pueda seguir sin ambigüedad.
El problema que queremos resolver
Antes de hablar de la solución vale la pena entender con precisión el problema. En la mayoría de los proyectos, los logs nacen de forma orgánica: el desarrollador que escribe un caso de uso añade un par de líneas de debug para entender qué está pasando durante el desarrollo, y esas líneas se quedan ahí. Llega otro desarrollador, añade las suyas, y así sucesivamente. El resultado, algunos meses después, es un codebase donde la lógica de negocio está entrelazada con instrucciones de log que nadie revisa, que no siguen ningún formato consistente, que en algunos métodos son excesivas y en otros brillan por su ausencia, y que en más de una ocasión exponen datos sensibles de los usuarios en texto plano.
El problema no es que los desarrolladores sean descuidados. El problema es estructural: cuando la responsabilidad de loguear está distribuida en cada clase del sistema, es inevitable que el resultado sea inconsistente. La única forma de garantizar consistencia es centralizar esa responsabilidad en un mecanismo que opere de forma transversal, sin depender de que cada desarrollador recuerde seguir una convención.
Eso es, en esencia, lo que ofrece la Programación Orientada a Aspectos.
AOP: observar sin intervenir
La idea central de AOP es simple aunque su implementación puede ser sofisticada: existen preocupaciones en un sistema, como la seguridad, las transacciones o el logging, que no pertenecen a ningún módulo en particular pero que afectan a todos. En lugar de dispersar el código que gestiona esas preocupaciones por todo el sistema, AOP permite encapsularlo en un componente separado llamado aspecto, que el framework inyecta de forma transparente en los puntos de ejecución que se le indiquen.
En el contexto de Spring, esto funciona a través de proxies. Cuando el contenedor de inversión de control crea un bean, puede envolverlo en un proxy que intercepta las llamadas a sus métodos. Ese proxy ejecuta el aspecto antes, después o alrededor de la llamada real. El método original no sabe que está siendo observado; simplemente hace su trabajo.
Para que este mecanismo funcione hay una condición que no siempre es obvia: los objetos deben ser beans de Spring. Si una clase no está gestionada por el contenedor, Spring no puede envolverla en un proxy y el aspecto no puede interceptarla. Este detalle tiene una implicación directa en cómo se diseña la observabilidad en una arquitectura DDD, y es precisamente el punto de partida para resolver uno de los dilemas más frecuentes en este tipo de proyectos.
La capa de dominio y el dilema del logging
En una arquitectura DDD estricta, la capa de dominio es la más interna y la más pura. No debe tener dependencias de infraestructura, no debe saber si está siendo ejecutada en una API REST o en un job batch, y definitivamente no debería importar librerías de logging. Esta pureza es lo que la hace testeable, portable y mantenible.
Pero esa misma pureza genera una pregunta legítima: ¿qué pasa con los servicios de dominio? Un servicio que valida si un cliente tiene crédito suficiente, que aplica reglas de descuento, que verifica el stock disponible, ¿no merece ser observado? Si algo falla en esa lógica, ¿cómo sabremos qué ocurrió?
La respuesta convencional suele ser una de dos: o se acepta contaminar el dominio con un logger, o se ignora completamente ese nivel de detalle y se espera que las excepciones cuenten la historia. Ninguna de las dos opciones es satisfactoria.
La salida está en un detalle de implementación que a veces pasa desapercibido: cuando los servicios de dominio y los casos de uso se registran como beans en el contenedor de Spring a través de la capa de aplicación, aunque el dominio no sabe nada de Spring, el contenedor sí los gestiona. Y si el contenedor los gestiona, AOP puede interceptarlos. El dominio sigue siendo puro porque no tiene ninguna dependencia en infraestructura. El aspecto lo observa desde afuera, a través del proxy, sin que el servicio de dominio sea consciente de ello.
Este es uno de esos casos donde las restricciones de una arquitectura, entendidas a fondo, abren posibilidades que no eran evidentes a primera vista.
Una sola responsabilidad por capa
Con ese fundamento claro, los puntos de observabilidad se organizan siguiendo la misma lógica que organiza la arquitectura: cada capa tiene su propio contrato de logging.
Los entry points, que son los controladores REST o cualquier otro mecanismo de entrada al sistema, son el primer y último punto que el aspecto intercepta en el flujo de una solicitud. Aquí se registra el request entrante con los datos de entrada saneados y, cuando el flujo termina, el response saliente con el tiempo total que tomó la operación. Es la vista más amplia del sistema: saber qué llegó y qué salió.
Los casos de uso aportan el siguiente nivel de granularidad. El aspecto registra el inicio y el fin de la orquestación, con el DTO de entrada ya mapeado y el resultado antes de que sea transformado para la respuesta. Esto permite correlacionar exactamente qué datos entran al corazón del sistema y qué produce como resultado.
Los servicios de dominio representan el nivel más detallado. Aquí el aspecto registra el resultado de cada validación, cada regla de negocio, cada decisión que toma el dominio. Este nivel de detalle, sin embargo, no necesita estar activo permanentemente en producción. Se emite en nivel DEBUG, lo que significa que en un ambiente productivo es invisible pero puede activarse dinámicamente en cuestión de segundos si se necesita diagnosticar un problema sin reiniciar la aplicación.
Finalmente, los driven adapters, que son las implementaciones de los puertos hacia el mundo exterior, tienen un requerimiento adicional que los diferencia de todas las demás capas: la latencia. No basta con saber que se hizo una llamada a un servicio externo o que se ejecutó una consulta a la base de datos; hay que saber cuánto tardó. Esa información es la que permite distinguir entre un problema de lógica interna y un problema de dependencia externa, una diferencia que en producción puede significar horas de diagnóstico incorrecto.
El tiempo como dato de primera clase
Medir el tiempo solo en las llamadas externas es un primer paso, pero insuficiente. Para entender verdaderamente el comportamiento de un sistema bajo carga es necesario conocer cuánto tarda cada etapa del flujo. Un total de 800 milisegundos en una solicitud puede ser perfectamente aceptable o completamente inaceptable dependiendo de dónde se origina ese tiempo.
Por eso cada punto de observabilidad debe registrar dos métricas de tiempo: durationMs, que mide cuánto tardó esa etapa específica, y elapsedMs, que mide el tiempo acumulado desde que llegó la solicitud hasta ese punto. Con ambas métricas en cada registro, reconstruir la línea de tiempo de una transacción en una herramienta de observabilidad es trivial.
A esto se suma un campo stage en cada registro, que identifica la capa que lo generó: ENTRY_POINT, USE_CASE, DOMAIN_SERVICE, EXTERNAL_CALL o REPOSITORY. Este campo convierte los logs de texto plano en datos estructurados sobre los que se pueden construir dashboards, alertas y análisis de performance sin necesidad de parsear mensajes de texto.
El valor de este diseño se hace evidente con un ejemplo concreto. Imaginemos una solicitud de creación de orden de compra que tarda 800 milisegundos en total. Sin el campo stage y sin durationMs por etapa, la única conclusión disponible es que la solicitud fue lenta. Con esos campos, el análisis revela en segundos que 600 de esos 800 milisegundos los consumió la API externa de cobertura logística, mientras que la lógica de dominio tomó menos de 15 milisegundos. La optimización correcta es evidente: no hay que tocar el dominio, hay que atacar la dependencia externa.
Privacidad por diseño, no por convención
Uno de los aspectos más delicados del logging es la privacidad. Cada vez que un sistema registra información existe el riesgo de que datos sensibles terminen en un archivo de log, en una herramienta de indexación o en el radar de una auditoría de seguridad. La respuesta habitual a este riesgo es la convención: "no logueen datos personales". El problema con las convenciones es que dependen de que cada desarrollador las recuerde y las aplique correctamente en cada caso.
Un enfoque más robusto es que la privacidad se declare en el modelo de datos, no en el código que loguea.
Para materializar esto se definen dos anotaciones que se aplican directamente sobre los campos del modelo de dominio. La primera, @NoLog, indica que un campo nunca debe aparecer en ningún log bajo ninguna circunstancia: omisión total. Se usa para campos como imágenes en Base64, documentos adjuntos, o cualquier objeto cuyo tamaño o naturaleza lo hace inadecuado para un registro. La segunda, @Confidential, indica que el campo contiene datos personales y que su valor debe enmascararse antes de escribirse. El aspecto aplica una función de máscara según el tipo configurado: una dirección de correo como [email protected] se convierte en j***@mail.com, un número de identificación se convierte en ***, un teléfono muestra solo los últimos cuatro dígitos.
Lo elegante de este diseño es que las reglas de privacidad viven donde tienen sentido: en el modelo de dominio, junto a la definición del dato. Cuando un desarrollador crea un campo en un modelo y lo anota con @Confidential, esa anotación se respeta automáticamente en todos los logs del sistema, sin necesidad de recordar actualizar ningún otro componente. La privacidad deja de ser una convención y se convierte en una propiedad del dato.
Más allá de las anotaciones, hay categorías de información que nunca deben aparecer en logs independientemente de si están anotadas: credenciales, tokens de autenticación, datos completos de tarjetas de crédito, cookies de sesión, datos biométricos. La regla práctica que sintetiza todos estos casos es directa: si el dato permite suplantar la identidad de un usuario o acceder a un sistema, no va en el log.
Trazabilidad: el hilo que conecta todo
Un log aislado tiene valor limitado. El valor real emerge cuando se pueden correlacionar todos los eventos de una transacción, desde que llega la solicitud hasta que sale la respuesta, incluyendo cada llamada externa y cada decisión de dominio que ocurrió en el camino.
El mecanismo que hace posible esta correlación es el message-id, un identificador único que se asigna a cada solicitud en el momento en que entra al sistema. Si el cliente lo envía en el header X-Message-Id, se reutiliza; si no viene, el sistema genera uno automáticamente. Este identificador se almacena en el MDC de SLF4J, que es un mapa de contexto asociado al hilo de ejecución. Todos los logs emitidos durante esa solicitud lo incluyen automáticamente.
El resultado es que en cualquier herramienta de observabilidad, filtrar por message-id produce exactamente la secuencia completa de eventos de una transacción, ordenada por tiempo, con cada etapa identificada por su stage y con sus métricas de duración. Lo que antes requería correlacionar manualmente decenas de líneas de log dispersas ahora es una consulta de una sola condición.
Este mecanismo presenta un desafío particular en operaciones asíncronas. Cuando Spring lanza un hilo para ejecutar una tarea marcada con @Async, ese hilo nuevo no hereda el MDC del hilo padre. El message-id y el tiempo de inicio de la solicitud se pierden, y los logs del hilo asíncrono quedan huérfanos sin correlación. La solución es un decorador de tareas que captura el MDC completo del hilo padre en el momento en que se lanza la tarea y lo restaura en el hilo hijo antes de ejecutarla. Este decorador se configura una sola vez en el executor del pool de hilos y aplica a todas las operaciones asíncronas del sistema sin ningún esfuerzo adicional por parte del desarrollador.
El mismo principio se extiende a arquitecturas de microservicios. Cuando el sistema hace una llamada HTTP a otro servicio, el message-id debe viajar en el header de la petición saliente. El microservicio receptor lo extrae, lo almacena en su propio MDC, y todos sus logs quedan correlacionados con la misma transacción origen. Esto se configura una sola vez en el cliente HTTP como un interceptor, y a partir de ahí todas las llamadas salientes propagan el identificador automáticamente. En una plataforma de observabilidad centralizada, una sola búsqueda por message-id puede reconstruir el árbol completo de llamadas entre servicios.
El flujo completo bajo la lupa
Para ilustrar cómo se manifiesta todo esto en la práctica, vale la pena recorrer un flujo real. Tomemos la creación de una orden de compra como caso de uso: el cliente envía los datos de la orden con sus productos, dirección de entrega e información personal; el sistema valida que el cliente esté activo, verifica el stock, homologa los códigos externos de los productos a los códigos internos del catálogo, consulta una API externa para validar la cobertura logística en la dirección indicada, persiste la orden en base de datos y devuelve el número de orden generado.
Sin escribir una sola línea de log en ninguno de esos componentes, el aspecto genera automáticamente doce registros a lo largo del flujo. El primero captura el request entrante con los datos saneados: el correo del cliente enmascarado, el número de identificación reemplazado por asteriscos, los documentos adjuntos simplemente omitidos. El segundo marca el inicio del caso de uso. Los registros tres, cuatro y cinco corresponden a los servicios de dominio: la validación del cliente, la validación de stock y la homologación de productos; estos se emiten en DEBUG y son invisibles en producción a menos que se activen dinámicamente. Los registros siete y ocho capturan la llamada a la API de cobertura logística con su latencia exacta. Los registros nueve y diez hacen lo mismo con la operación de base de datos. El registro once cierra el caso de uso con el tiempo total de orquestación. El doce emite el response saliente con el tiempo total de la solicitud de punta a punta.
El análisis de esos doce registros revela de inmediato la distribución del tiempo: cuatro milisegundos de overhead en los mappers de entrada, diez milisegundos en validaciones de dominio, doscientos diez milisegundos en la API de cobertura logística, cuarenta y cinco milisegundos en base de datos. Sin ningún profiler, sin instrumentación adicional, el sistema cuenta su propia historia con precisión quirúrgica.
El mismo flujo en un escenario de error muestra otra dimensión del diseño. Si el stock es insuficiente, el servicio de dominio lanza una excepción de negocio controlada. El aspecto la captura a nivel DEBUG en el servicio de dominio y la deja subir. El @ControllerAdvice la intercepta y emite un registro en nivel WARN, no ERROR, porque una validación fallida es una condición esperada del negocio, no un fallo del sistema. Sin stacktrace completo, solo el mensaje de negocio y el message-id. En cambio, si la API externa de cobertura logística devuelve un timeout, el adapter emite un registro en nivel ERROR con stacktrace completo y la latencia exacta que revela los cinco segundos de espera antes del fallo.
Esta distinción entre WARN y ERROR no es cosmética. En los dashboards de monitoreo permite separar el ruido normal del negocio de los fallos reales que requieren atención inmediata. Un equipo de operaciones que recibe alertas solo para registros ERROR puede confiar en que cada alerta representa un problema genuino del sistema, no una validación fallida que el usuario debe corregir.
Producción sin sorpresas
Hay un escenario que todo sistema productivo enfrenta eventualmente: un comportamiento anómalo que no se reproduce en desarrollo y que requiere ver el detalle de la lógica interna para diagnosticarse. En el modelo tradicional, la respuesta a este escenario era subir el nivel de log a DEBUG, redesplegar, esperar, bajar el nivel, redesplegar de nuevo. Un proceso lento, arriesgado y que en sistemas con tráfico real puede generar un volumen de logs suficiente para saturar la infraestructura de observabilidad.
Dos mecanismos complementarios evitan ese ciclo. El primero es la jerarquía de niveles ya descrita: los logs de DOMAIN_SERVICE se emiten en DEBUG, así que en producción con nivel INFO son completamente invisibles y no generan ningún costo operativo. El segundo es el cambio dinámico de nivel a través de un endpoint interno que delega en la API de loggers de Spring Boot Actuator. Activar DEBUG para un paquete específico, observar el comportamiento, y volver a INFO es una operación de segundos sin ningún redespliegue.
Este diseño refleja una filosofía más amplia: las herramientas de observabilidad deben poder adaptarse al momento sin modificar el sistema observado.
Lo que también importa, aunque no se vea en el código
Hay una dimensión del logging que no suele documentarse pero que es igualmente crítica: saber qué no loguear. Las anotaciones @NoLog y @Confidential cubren los datos que el modelo declara explícitamente como sensibles, pero hay categorías de información que nunca deben aparecer en logs independientemente de cualquier anotación.
Los tokens de autenticación son el ejemplo más obvio. Un JWT completo, una API key o un refresh token en un log es esencialmente una credencial expuesta que puede ser extraída por cualquiera con acceso a la plataforma de observabilidad, que en muchas organizaciones incluye a un número considerable de personas. Lo mismo aplica para contraseñas, PINs, datos completos de tarjetas de crédito, cookies de sesión y datos biométricos.
La lista no es exhaustiva ni puede serlo, porque los datos sensibles dependen del contexto de cada sistema. Lo que sí puede establecerse como hábito es hacerse la pregunta antes de que un dato llegue a un registro. Y en la duda, la respuesta correcta es siempre la omisión.
De los logs a la inteligencia operacional
Un sistema de logs bien diseñado no es solo un mecanismo de diagnóstico reactivo. Es la materia prima de la inteligencia operacional. Los campos estructurados que este esquema produce, stage, durationMs, elapsedMs, event, adapter, permiten derivar métricas sin instrumentación adicional en el código.
La latencia promedio por adapter externo permite monitorear el SLA de cada dependencia. La tasa de registros con event=BUSINESS_EXCEPTION agrupada por tipo de excepción permite entender qué reglas de negocio fallan con más frecuencia y orientar decisiones de producto. El tiempo total por endpoint permite construir alertas que disparen cuando la latencia supera el percentil 99 histórico. La correlación entre EXTERNAL_CALL_START sin su correspondiente EXTERNAL_CALL_END permite detectar llamadas que nunca respondieron.
Y quizás el beneficio más silencioso de todos: cada nueva funcionalidad que se añada al sistema hereda automáticamente la observabilidad con el mismo nivel de detalle y el mismo formato estructurado, simplemente por seguir la arquitectura. No hay nada que recordar, nada que configurar, nada que pueda olvidarse.
Mirando hacia adelante
Lo descrito en este artículo establece una base sólida, pero no es un punto de llegada. Hay líneas de evolución naturales que vale la pena tener en el horizonte.
La integración con sistemas de tracing distribuido como OpenTelemetry lleva la correlación entre microservicios un paso más allá, al construir árboles de spans que representan visualmente la jerarquía de llamadas, con tiempos y metadatos, en una interfaz diseñada específicamente para ese propósito. Los logs estructurados que produce este esquema son compatibles con ese modelo y pueden complementarlo sin contradicción.
La generación automática de métricas de aplicación a través de Micrometer desde los mismos puntos de intercepción del AOP es otra extensión natural, ya que evita la duplicación entre el sistema de logs y el sistema de métricas, manteniendo una única fuente de verdad para ambos tipos de datos.
El mismo patrón de aspectos transversales también puede extenderse a otros dominios de preocupación: auditoría de cambios de estado, registro de accesos a datos sensibles para cumplimiento regulatorio, o validación automática de contratos entre capas.
Lo que todo esto ilustra, más allá de los detalles técnicos, es que la observabilidad no tiene por qué ser un ciudadano de segunda clase en la arquitectura de un sistema. Cuando se diseña con la misma intención que se diseña la lógica de negocio, cuando se le aplican los mismos principios de separación de responsabilidades y consistencia, se convierte en una ventaja operacional genuina: el equipo gana la capacidad de entender qué está pasando en producción en cualquier momento, con el nivel de detalle que necesita, sin adivinar y sin contaminar el código que hace que el sistema funcione.
La implementación concreta de este diseño, con el código de cada artefacto, los aspectos completos, el sanitizador de datos y el ejemplo funcional del flujo de orden de compra, se documenta en detalle en la segunda parte de este artículo.
Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
PARTE I: PRINCIPIOS Y NORMATIVA 1. Fundamentos de Consistencia Eventual Debido a la naturaleza distribuida del sistema, se abandona el modelo ACID tradicional (Atomicidad inmediata con bloque
Estándar de Arquitectura: Transacciones Distribuidas (Patrón Saga)
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
PARTE I: PRINCIPIOS Y NORMATIVA
1. Fundamentos de Consistencia Eventual
Debido a la naturaleza distribuida del sistema, se abandona el modelo ACID tradicional (Atomicidad inmediata con bloqueos) en favor del modelo BASE (Basically Available, Soft state, Eventually consistent).
Implicaciones arquitectónicas:
- Los datos pueden estar temporalmente inconsistentes entre servicios
- La consistencia se alcanza mediante propagación de eventos y compensaciones
- Cada servicio mantiene su propia fuente de verdad (base de datos)
- No existen transacciones atómicas que abarquen múltiples servicios
Garantías del sistema:
- Disponibilidad: Los servicios responden incluso si otros están caídos
- Durabilidad: Los eventos se persisten antes de considerarse publicados
- Convergencia: El sistema eventualmente alcanzará un estado consistente
2. Patrones Permitidos
2.1 Patrón Primario: Saga basada en Coreografía
Los servicios participantes reaccionan a eventos de dominio de manera autónoma sin conocer el flujo completo de la transacción distribuida. Cada servicio:
- Escucha eventos relevantes de su dominio
- Ejecuta su lógica de negocio local
- Publica eventos de resultado
- No tiene conocimiento de qué otros servicios participan en el proceso
Ventajas: Bajo acoplamiento, alta escalabilidad, sin punto único de fallo.
Desventajas: Flujo implícito, difícil depuración, complejidad en el rastreo.
2.2 Patrón Complementario: Orquestación Ligera de Estado
Se permite que UN servicio actúe como Coordinador de Estado (no como orquestador tradicional) con las siguientes restricciones:
Permitido:
- Mantener una tabla de estado que rastree el progreso de la saga
- Escuchar todos los eventos relevantes del flujo
- Tomar decisiones de cancelación basadas en eventos recibidos
- Emitir comandos de compensación cuando sea necesario
- Proveer endpoints de consulta del estado de la transacción
Prohibido:
- Invocar directamente (HTTP/gRPC) a otros servicios para ejecutar pasos
- Mantener lógica de negocio que corresponde a otros dominios
- Actuar como proxy o gateway entre servicios
Clarificación: El coordinador observa y reacciona, no comanda y espera. La comunicación sigue siendo asíncrona mediante el bus de eventos.
2.3 Prohibiciones Absolutas
- Two-Phase Commit (2PC): No se permite debido a bloqueos prolongados y baja disponibilidad
- XA Transactions: Prohibido extender transacciones de base de datos entre servicios
- Distributed Locks: No se permiten bloqueos compartidos entre servicios (excepto Semantic Locks de negocio, ver Parte III)
- Llamadas Síncronas en Flujo Crítico: HTTP/REST/gRPC solo para consultas (queries), nunca para comandos (writes) en sagas
3. Comunicación y Mensajería
3.1 Intermediario Obligatorio
Toda comunicación entre pasos de una saga debe realizarse a través de un Message Broker con las siguientes características:
Requisitos mínimos del broker:
- Persistencia en disco (durabilidad de mensajes)
- Garantía de entrega "al menos una vez" (at-least-once delivery)
- Capacidad de reintento automático
- Soporte para Dead Letter Queues (DLQ)
- Ordenamiento por partición (opcional pero recomendado)
Brokers aprobados: Kafka, RabbitMQ, Amazon SQS/SNS, Azure Service Bus, Google Pub/Sub.
3.2 Topología de Mensajería
Para eventos de dominio:
- Utilizar patrón Publish-Subscribe (pub/sub)
- Múltiples consumidores pueden suscribirse al mismo evento
- Los productores no conocen a los consumidores
Para comandos directos (casos excepcionales):
- Utilizar colas punto-a-punto
- Un solo consumidor procesa el mensaje
- Incluir timeout de procesamiento
3.3 Restricciones de Comunicación Síncrona
Prohibido para:
- Ejecutar el siguiente paso de una transacción crítica
- Confirmar operaciones de escritura entre servicios
- Propagar cambios de estado en flujos transaccionales
Permitido para:
- Consultas de solo lectura (queries)
- Validaciones previas no bloqueantes
- Obtención de datos de referencia
- Healthchecks y monitoreo
4. Transactional Outbox Pattern (Obligatorio)
4.1 Definición del Problema
El Dual Write Problem ocurre cuando un servicio intenta:
- Actualizar su base de datos local
- Publicar un evento en el broker
Si la publicación falla después del commit de BD, el sistema queda inconsistente. Si falla antes, se pierde el evento.
4.2 Solución Mandatoria
Regla de Oro: Un servicio nunca debe publicar directamente en el broker dentro de su código de negocio.
Implementación del patrón:
Primera fase - Transacción Atómica Local:
- Iniciar transacción de base de datos
- Ejecutar operación de negocio (INSERT/UPDATE/DELETE en tablas de dominio)
- Insertar el evento a publicar en una tabla especial llamada OUTBOX
- Confirmar transacción completa (COMMIT atómico)
Segunda fase - Publicación Asíncrona: 5. Un proceso independiente (Relay/Publisher) lee continuamente la tabla OUTBOX 6. Publica los eventos pendientes en el broker 7. Marca los eventos como publicados o los elimina
Tabla OUTBOX - Estructura requerida:
Campos obligatorios:
- Identificador único del mensaje (UUID)
- Tipo de evento (nombre del evento de dominio)
- Cuerpo del evento (payload serializado)
- Timestamp de creación
- Estado de publicación (pendiente, publicado, fallido)
- Número de intentos de publicación
- Agregado raíz asociado (para ordenamiento)
- Versión del esquema del evento
4.3 Estrategias de Relay
Opción A - Polling:
- Proceso que consulta periódicamente la tabla OUTBOX
- Publica eventos pendientes ordenados por timestamp
- Marca como publicados tras confirmación del broker
- Intervalo recomendado: 100-500 milisegundos
Opción B - Change Data Capture (CDC):
- Herramienta que lee el transaction log de la base de datos
- Detecta inserts en OUTBOX en tiempo real
- Publica automáticamente en el broker
- Ejemplos: Debezium, Maxwell, AWS DMS
Opción C - Database Triggers:
- Trigger que se activa al insertar en OUTBOX
- Invoca procedimiento que publica en broker
- No recomendado por acoplamiento y menor resiliencia
5. Resiliencia e Idempotencia
5.1 Principio de Idempotencia
Dado que los brokers garantizan entrega "al menos una vez", es inevitable que algunos mensajes se entreguen duplicados. Todo consumidor de eventos DEBE ser idempotente.
Definición: Una operación es idempotente si ejecutarla múltiples veces produce el mismo resultado que ejecutarla una sola vez.
Verificación obligatoria: Antes de procesar un evento, el consumidor debe verificar si el identificador del mensaje ya fue procesado previamente.
5.2 Implementación de Deduplicación
Tabla de Registro de Mensajes Procesados:
Cada servicio debe mantener una tabla dedicada con:
- Identificador del mensaje (clave primaria)
- Tipo de evento procesado
- Timestamp de procesamiento
- Estado final (éxito/fallo)
- Índice en timestamp para limpieza periódica
Flujo de procesamiento idempotente:
- Recibir mensaje del broker
- Iniciar transacción de base de datos local
- Intentar insertar el identificador del mensaje en la tabla de registro
- Si la inserción falla por duplicado: hacer rollback y retornar éxito (ya fue procesado)
- Si la inserción es exitosa: ejecutar lógica de negocio
- Insertar evento resultante en tabla OUTBOX (si aplica)
- Confirmar transacción completa
- Enviar ACK al broker
Política de limpieza: Eliminar registros con más de siete días de antigüedad mediante proceso nocturno.
5.3 Estrategias de Compensación
Para toda operación de escritura que modifique estado de negocio, el servicio debe implementar una Transacción Compensatoria.
Definición: Acción lógicamente inversa que deshace (o mitiga) el efecto de una operación previamente confirmada.
Ejemplos de compensación:
Operación original → Compensación:
- CrearPedido → AnularPedido
- ReservarInventario → LiberarReserva
- CobrarPago → ReembolsarPago
- EnviarNotificacion → EnviarNotificacionCorreccion
- AsignarRecurso → DesasignarRecurso
Características de compensaciones:
- Debe ser idempotente (puede ejecutarse múltiples veces)
- Puede ser semántica (no necesariamente restaura estado exacto)
- Debe registrarse en logs de auditoría
- Debe emitir eventos de compensación para trazabilidad
Tipos de compensación:
- Compensación perfecta: Restaura el estado exacto anterior (ej: cancelar reserva)
- Compensación aproximada: Restaura un estado equivalente (ej: reembolso en créditos en vez de dinero)
- Compensación simbólica: Registra el intento de reversión cuando la compensación real es imposible (ej: no se puede "des-enviar" un email, pero se envía corrección)
5.4 Manejo de Errores y Reintentos
Clasificación de fallos:
Fallos Transitorios: Errores temporales que pueden resolverse reintentando
- Pérdida de conexión de red
- Timeouts de base de datos por carga
- Servicio dependiente temporalmente no disponible
- Límites de rate limiting
Fallos Permanentes: Errores que no se resolverán reintentando
- Validaciones de negocio fallidas
- Datos malformados o incompletos
- Violaciones de reglas de dominio
- Permisos insuficientes
- Recursos no encontrados
Estrategia de Reintentos - Exponential Backoff:
Para fallos transitorios se debe implementar:
- Espera inicial entre primer y segundo intento: 500 milisegundos
- Multiplicador exponencial: factor de 2
- Espera máxima entre intentos (techo): 60 segundos
- Número máximo de intentos: 5
- Jitter aleatorio: añadir variación del 10-25% para evitar thundering herd
Progresión ejemplo: 500ms → 1s → 2s → 4s → 8s → DLQ
Dead Letter Queue (DLQ):
Después de agotar los reintentos, el mensaje debe enviarse a una cola especial para:
- Análisis manual posterior
- Alertas al equipo de operaciones
- Posible reprocesamiento manual tras corrección
- Auditoría de fallos recurrentes
Propiedades requeridas en mensajes de DLQ:
- Mensaje original completo
- Número de intentos realizados
- Timestamps de cada intento
- Detalles de cada error ocurrido
- Trace completo del último error
6. Versionado y Evolución de Contratos
6.1 Esquemas Explícitos Obligatorios
Todo evento de dominio publicado en el bus debe tener un esquema formal que defina:
- Nombre y tipo de cada campo
- Campos obligatorios vs opcionales
- Tipos de datos permitidos
- Restricciones de validación
- Descripción semántica de cada campo
Formatos aprobados: Avro, Protocol Buffers (Protobuf), JSON Schema.
Prohibido: Publicar eventos con estructura ad-hoc sin definición formal.
6.2 Versionado Semántico de Eventos
Todo evento debe incluir un campo de metadatos que indique su versión siguiendo el formato semántico: MAJOR.MINOR.PATCH
Ejemplo: "schema_version": "1.2.0"
Interpretación de versiones:
MAJOR: Cambios incompatibles que requieren actualización del consumidor
- Eliminar campos
- Cambiar tipo de dato existente
- Cambiar semántica del campo
- Renombrar campos
MINOR: Cambios retrocompatibles que agregan funcionalidad
- Agregar nuevos campos opcionales
- Agregar nuevos valores a enumeraciones
- Deprecar campos (sin eliminarlos)
PATCH: Correcciones menores sin impacto funcional
- Corregir descripciones
- Mejorar documentación
- Correcciones de typos en nombres
6.3 Estrategias de Evolución
Para cambios ADITIVOS (Minor/Patch):
- Agregar solo campos opcionales con valores por defecto
- Los consumidores antiguos ignoran campos nuevos
- Los productores nuevos deben tolerar consumidores antiguos
- No requiere coordinación de despliegue
Para cambios BREAKING (Major):
- Crear un nuevo tipo de evento con sufijo de versión
- Ejemplo: "OrdenSolicitada_v2"
- Mantener publicación dual por período de transición
- El productor emite tanto evento v1 como v2
- Los consumidores migran gradualmente a la nueva versión
- Período mínimo de convivencia: 90 días calendario
- Después del período, deprecar y eliminar versión antigua
Política de deprecación:
- Anunciar deprecación con 90 días de anticipación
- Añadir warnings en logs cuando se use versión antigua
- Publicar métricas de uso de versiones obsoletas
- Coordinar migración con todos los equipos consumidores
- Eliminar soporte solo cuando uso sea cero por 30 días
6.4 Registro Centralizado de Esquemas
Obligatorio: Mantener un Schema Registry centralizado que:
- Almacena todas las versiones de esquemas de eventos
- Valida compatibilidad antes de registrar nuevas versiones
- Provee APIs para consulta programática de esquemas
- Genera documentación automática de contratos
- Permite validación en tiempo de runtime
Herramientas recomendadas: Confluent Schema Registry, AWS Glue Schema Registry, Apicurio Registry.
7. Observabilidad y Rastreabilidad
7.1 Rastreo Distribuido
Generación de Identificadores:
El servicio que inicia una saga debe generar los siguientes identificadores únicos:
Saga ID: Identificador global único (UUID versión 4) que representa toda la transacción distribuida. Se genera una sola vez al inicio y se propaga sin cambios.
Span ID: Identificador único para cada paso o evento individual dentro de la saga. Cada servicio que procesa genera su propio Span ID.
Parent Span ID: Referencia al Span ID del paso anterior, creando una jerarquía de trazas.
Propagación de Contexto:
Estos identificadores deben incluirse como headers/metadatos en todos los mensajes:
- Nombre del header de Saga ID: "X-Saga-ID"
- Nombre del header de Span ID: "X-Span-ID"
- Nombre del header de Parent Span: "X-Parent-Span-ID"
Los servicios intermedios deben:
- Preservar el Saga ID sin modificarlo
- Generar su propio Span ID
- Copiar el Span ID recibido como su Parent Span ID
- Propagar estos tres valores en todos los eventos que emitan
7.2 Logging Estructurado
Formato Obligatorio:
Cada entrada de log relacionada con procesamiento de eventos de saga debe ser estructurada (no texto plano) e incluir los siguientes campos:
Campos mandatorios de contexto:
- Identificador de saga (copiado del mensaje)
- Tipo de evento procesado
- Versión del esquema del evento
- Nombre del servicio que genera el log
- Timestamp en formato ISO-8601 con zona horaria UTC
- Identificador del span actual
- Identificador del span padre
Campos mandatorios de resultado:
- Estado del procesamiento: RECEIVED, PROCESSING, SUCCESS, FAILED, COMPENSATING, COMPENSATED
- Duración en milisegundos de la operación
- Número de intento (para reintentos)
Campos opcionales pero recomendados:
- Identificador de correlación de negocio (ej: número de orden)
- Identificador del usuario o entidad que inició la transacción
- Datos relevantes del payload (sin información sensible)
- Detalles del error (en caso de fallo)
- Nombre del nodo/instancia que procesó
Niveles de Log:
- INFO: Inicio y fin exitoso de procesamiento de evento
- WARN: Reintentos por fallos transitorios
- ERROR: Fallos permanentes, envío a DLQ
- DEBUG: Detalles de validaciones y decisiones de negocio
Ejemplo descriptivo de entrada de log:
Un log estructurado indicando que el servicio de Inventario procesó exitosamente un evento PagoExitoso en 150 milisegundos, este fue el primer intento, pertenece a la saga con ID alfa-123, el span actual es beta-456 hijo del span gamma-789, ocurrió el 7 de febrero de 2026 a las 10:30:00 UTC, procesó la versión 1.0 del evento, y resultó en éxito.
7.3 Métricas Requeridas
Todos los servicios participantes en sagas deben exponer las siguientes métricas en formato compatible con sistemas de monitoreo:
Métricas de duración:
- Nombre: saga_duration_seconds
- Tipo: Histogram
- Etiquetas: tipo_de_saga, estado_final (success, failed, compensated)
- Descripción: Tiempo total desde inicio hasta conclusión de la saga
Métricas de errores por paso:
- Nombre: saga_step_errors_total
- Tipo: Counter (contador acumulativo)
- Etiquetas: nombre_servicio, tipo_evento, tipo_error
- Descripción: Cantidad total de fallos al procesar eventos
Métricas de compensaciones:
- Nombre: saga_compensations_total
- Tipo: Counter
- Etiquetas: nombre_servicio, razón_compensación
- Descripción: Cantidad de transacciones compensatorias ejecutadas
Métricas de mensajes pendientes:
- Nombre: outbox_pending_messages
- Tipo: Gauge (valor instantáneo)
- Etiquetas: nombre_servicio
- Descripción: Cantidad de eventos en tabla OUTBOX pendientes de publicar
Métricas de mensajes en DLQ:
- Nombre: dlq_messages_total
- Tipo: Gauge
- Etiquetas: nombre_servicio, tipo_evento
- Descripción: Cantidad de mensajes en Dead Letter Queue por servicio
Formato de exportación: Prometheus, OpenMetrics, o CloudWatch.
7.4 Trazabilidad de Auditoría
Para procesos críticos de negocio se debe mantener:
- Tabla de auditoría de saga con todos los cambios de estado
- Timestamp de cada transición de estado
- Razón del cambio (evento que lo provocó)
- Usuario o sistema responsable del inicio
- Datos relevantes de negocio (sin información sensible duplicada)
- Retención mínima: según políticas regulatorias (típicamente 7 años)
8. Límites Operacionales y Timeouts
8.1 Parámetros Configurables Mandatorios
Todo servicio participante en sagas debe exponer y documentar los siguientes parámetros de configuración:
Reintentos:
Número máximo de intentos antes de enviar a DLQ
- Valor mínimo permitido: 3 intentos
- Valor recomendado: 5 intentos
Espera inicial entre primer y segundo intento (backoff inicial)
- Valor mínimo permitido: 100 milisegundos
- Valor recomendado: 500 milisegundos
Espera máxima entre intentos (techo de backoff)
- Valor mínimo permitido: 30 segundos
- Valor recomendado: 60 segundos
Timeouts de saga completa:
- Tiempo máximo total para completar toda la saga
- Valor mínimo permitido: 1 hora
- Valor recomendado: 24 horas
- Nota: Ajustar según naturaleza del proceso de negocio
Timeouts por paso individual:
- Tiempo máximo de espera por respuesta de un paso
- Valor mínimo permitido: 30 segundos
- Valor recomendado: 5 minutos (300 segundos)
Estos valores deben ser configurables sin recompilar código (variables de entorno, archivos de configuración, configuration server).
8.2 Política de Timeouts
Para timeouts de paso individual:
Si un evento esperado no llega dentro del plazo configurado:
- El servicio coordinador (si existe) debe emitir un evento de timeout
- Nombre del evento: "StepTimedOut" o similar
- Incluir en el payload: saga_id, paso esperado, tiempo transcurrido
- Iniciar proceso de compensación
- Registrar en logs con nivel ERROR
- Incrementar métrica de timeouts
Para timeouts de saga completa:
Si la saga no se completa dentro del plazo total configurado:
- Ejecutar compensación automática de todos los pasos confirmados
- Marcar la saga con estado TIMED_OUT
- Emitir evento de saga expirada para auditoría
- Notificar al usuario/sistema iniciador del fallo
- Generar alerta para equipo de operaciones
- No eliminar datos de auditoría (mantener para análisis)
8.3 Monitoreo de Umbrales
Alertas obligatorias:
- Si el porcentaje de sagas fallidas supera 5% en ventana de 15 minutos
- Si el tiempo promedio de saga supera el doble del baseline histórico
- Si la cantidad de mensajes en DLQ supera 10 por servicio
- Si hay mensajes en OUTBOX pendientes por más de 10 minutos
- Si una saga individual supera el 80% del timeout configurado
Niveles de severidad:
- CRITICAL: Afecta flujos de negocio críticos (pagos, pedidos)
- HIGH: Afecta funcionalidad importante pero no crítica
- MEDIUM: Degradación de rendimiento sin pérdida de funcionalidad
- LOW: Anomalías detectadas pero sin impacto inmediato
PARTE II: IMPLEMENTACIÓN DE REFERENCIA
1. Caso de Uso: Procesamiento de Órdenes con Validación Preventiva
Dominio de negocio: Sistema de comercio electrónico
Objetivo: Procesar una orden de compra asegurando disponibilidad de inventario antes de ejecutar el cobro, minimizando reembolsos por falta de stock.
Estrategia elegida: Check-Then-Act (Verificación antes de Acción Financiera)
Justificación: Reducir costos de transacciones bancarias fallidas y mejorar experiencia del cliente evitando cobros seguidos de reembolsos.
2. Definición del Flujo Transaccional
El proceso se divide en cuatro fases secuenciales:
Fase 1 - Intención:
- Acción: Registro inicial de la orden en el sistema
- Estado resultante: PENDIENTE_VALIDACION
- Evento emitido: OrdenSolicitada
Fase 2 - Validación:
- Acción: Consulta de disponibilidad de inventario sin reserva
- Tipo de operación: Lectura (SELECT) sin bloqueos
- Eventos posibles: StockVerificado o StockNoDisponible
Fase 3 - Cobro Condicional:
- Acción: Ejecución de transacción financiera
- Precondición: Solo si Fase 2 fue exitosa
- Eventos posibles: PagoExitoso o PagoRechazado
Fase 4 - Asignación con Bloqueo:
- Acción: Descuento definitivo de inventario
- Tipo de operación: Escritura (UPDATE) con bloqueo pesimista
- Eventos posibles: StockAsignado o FalloAsignacion
3. Servicios Participantes y Responsabilidades
3.1 Servicio: Gestor de Pedidos
Rol: Coordinador de estado (Orquestación Ligera)
Responsabilidades:
- Recibir la solicitud inicial del cliente
- Crear el registro de orden con estado inicial
- Generar el Saga ID único
- Emitir el evento OrdenSolicitada vía patrón Outbox
- Escuchar eventos de progreso del resto de participantes
- Mantener máquina de estados de la orden
- Actualizar estado según eventos recibidos
- Notificar al cliente sobre el resultado final
Transiciones de estado:
Estado PENDIENTE_VALIDACION al recibir:
- StockVerificado → PENDIENTE_PAGO
- StockNoDisponible → CANCELADA_SIN_STOCK (flujo termina)
- Timeout de validación → CANCELADA_TIMEOUT
Estado PENDIENTE_PAGO al recibir:
- PagoExitoso → PENDIENTE_ASIGNACION
- PagoRechazado → CANCELADA_PAGO_RECHAZADO
Estado PENDIENTE_ASIGNACION al recibir:
- StockAsignado → COMPLETADA (flujo exitoso)
- FalloAsignacion → CANCELADA_CON_REEMBOLSO (requiere compensación)
Eventos que escucha:
- StockVerificado
- StockNoDisponible
- PagoExitoso
- PagoRechazado
- StockAsignado
- FalloAsignacion
- ReembolsoEjecutado
Eventos que emite:
- OrdenSolicitada (inicio del flujo)
- OrdenCompletada (conclusión exitosa)
- OrdenCancelada (conclusión con fallo)
3.2 Servicio: Gestor de Inventario
Rol: Validador y Ejecutor (participa en dos momentos diferentes)
Responsabilidades:
Momento 1 - Validación (Fase 2):
- Escuchar evento OrdenSolicitada
- Consultar disponibilidad actual en base de datos
- Validar si existe stock suficiente para los ítems solicitados
- NO realizar ninguna reserva ni modificación de datos
- Emitir resultado de validación
Momento 2 - Asignación (Fase 4):
- Escuchar evento PagoExitoso
- Iniciar transacción de base de datos con bloqueo pesimista
- Re-verificar disponibilidad actual (puede haber cambiado desde Fase 2)
- Descontar las unidades si aún hay stock disponible
- Confirmar transacción o hacer rollback según resultado
- Emitir resultado de asignación
Lógica de validación (Momento 1):
Para cada ítem en la orden:
- Consultar tabla de productos con el SKU solicitado
- Leer campo de cantidad disponible actual
- Comparar cantidad solicitada vs cantidad disponible
- Si para TODOS los ítems hay stock suficiente: emitir StockVerificado
- Si para ALGÚN ítem no hay stock suficiente: emitir StockNoDisponible con detalles
Lógica de asignación (Momento 2):
- Iniciar transacción con nivel de aislamiento REPEATABLE_READ o superior
- Aplicar bloqueo pesimista (SELECT FOR UPDATE) sobre los productos afectados
- Re-leer cantidad disponible actual
- Validar nuevamente que hay stock suficiente
- Si validación exitosa:
- Ejecutar UPDATE restando las unidades
- Insertar evento StockAsignado en tabla OUTBOX
- COMMIT de transacción
- Si validación falla (race condition, stock consumido por otra transacción):
- Insertar evento FalloAsignacion en tabla OUTBOX
- COMMIT de transacción (el evento de fallo debe publicarse)
Eventos que escucha:
- OrdenSolicitada (trigger de validación)
- PagoExitoso (trigger de asignación)
Eventos que emite:
- StockVerificado
- StockNoDisponible
- StockAsignado
- FalloAsignacion
Compensación: Si recibe evento de compensación (por fallo en paso posterior):
- Restaurar las unidades de inventario sumando la cantidad original
- Emitir evento StockLiberado
3.3 Servicio: Procesador de Pagos
Rol: Intermediario financiero condicional
Responsabilidades:
Operación Normal:
- Escuchar evento StockVerificado (NO OrdenSolicitada, para evitar cobros sin stock)
- Extraer información de pago del payload del evento
- Invocar API de pasarela de pagos externa (Stripe, PayPal, etc.)
- Manejar respuesta de la pasarela
- Emitir resultado de la operación financiera
Operación de Compensación:
- Escuchar evento FalloAsignacion
- Identificar la transacción financiera original asociada
- Ejecutar reembolso o reversa en la pasarela de pagos
- Emitir evento de confirmación de compensación
Lógica de cobro:
- Recibir evento StockVerificado
- Validar que evento no fue procesado previamente (idempotencia)
- Extraer datos: monto, método de pago, token de tarjeta, etc.
- Llamar API de pasarela con timeout de 30 segundos
- Si respuesta es exitosa:
- Almacenar ID de transacción de la pasarela
- Insertar evento PagoExitoso en OUTBOX con referencia de transacción
- COMMIT
- Si respuesta es rechazo (fondos insuficientes, tarjeta inválida):
- Insertar evento PagoRechazado en OUTBOX con código de error
- COMMIT
- Si hay timeout o error de red:
- Aplicar política de reintentos con exponential backoff
- Tras agotar intentos: enviar a DLQ para revisión manual
Lógica de compensación:
- Recibir evento FalloAsignacion
- Extraer Saga ID para identificar la transacción financiera original
- Buscar en base de datos local el ID de transacción de la pasarela
- Llamar API de reembolso de la pasarela con el ID original
- Si reembolso exitoso:
- Insertar evento ReembolsoEjecutado en OUTBOX
- COMMIT
- Si reembolso falla:
- Reintentar con backoff exponencial
- Tras 5 intentos fallidos: enviar alerta crítica a equipo de finanzas
- Marcar para reembolso manual
Eventos que escucha:
- StockVerificado (trigger de cobro)
- FalloAsignacion (trigger de compensación)
Eventos que emite:
- PagoExitoso
- PagoRechazado
- ReembolsoEjecutado
- FalloReembolso (para casos críticos)
4. Escenarios de Ejecución
4.1 Escenario A: Flujo Exitoso (Happy Path)
Contexto inicial:
- Cliente solicita 1 unidad del producto SKU-123
- Inventario actual: 10 unidades disponibles
- No hay operaciones concurrentes
Secuencia de eventos:
Cliente envía solicitud HTTP POST al endpoint de Pedidos
Pedidos crea registro de orden con estado PENDIENTE_VALIDACION
Pedidos genera Saga ID: "550e8400-e29b-41d4-a716-446655440000"
Pedidos inserta en OUTBOX el evento OrdenSolicitada
Relay de Pedidos publica evento en topic "order-events"
Inventario consume evento OrdenSolicitada
Inventario consulta: SELECT cantidad FROM productos WHERE sku = 'SKU-123'
Inventario verifica: 10 unidades disponibles, pedido requiere 1, verificación OK
Inventario inserta en OUTBOX el evento StockVerificado
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockVerificado y actualiza estado a PENDIENTE_PAGO
Pagos consume StockVerificado
Pagos invoca API de pasarela: "Cobrar 50.00 USD a tarjeta terminada en 4242"
Pasarela responde: "Aprobado, transaction_id: txn_abc123"
Pagos inserta en OUTBOX el evento PagoExitoso con referencia txn_abc123
Relay de Pagos publica evento en topic "payment-events"
Pedidos consume PagoExitoso y actualiza estado a PENDIENTE_ASIGNACION
Inventario consume PagoExitoso
Inventario inicia transacción con: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-123' FOR UPDATE
Inventario lee cantidad bloqueada: 10 unidades
Inventario valida: 10 >= 1, OK
Inventario ejecuta: UPDATE productos SET cantidad = cantidad - 1 WHERE sku = 'SKU-123'
Inventario inserta en OUTBOX el evento StockAsignado
Inventario confirma: COMMIT
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockAsignado y actualiza estado a COMPLETADA
Pedidos emite evento OrdenCompletada
Pedidos envía notificación al cliente: "Tu orden ha sido confirmada"
Resultado final:
- Orden completada exitosamente
- Inventario reducido a 9 unidades
- Cliente cobrado y notificado
- Duración total: aproximadamente 2-5 segundos
4.2 Escenario B: Fallo Temprano (Sin Stock Disponible)
Contexto inicial:
- Cliente solicita 5 unidades del producto SKU-456
- Inventario actual: 0 unidades disponibles
- Optimización: evitar cobro innecesario
Secuencia de eventos:
Cliente envía solicitud HTTP POST al endpoint de Pedidos
Pedidos crea registro de orden con estado PENDIENTE_VALIDACION
Pedidos genera Saga ID: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
Pedidos inserta en OUTBOX el evento OrdenSolicitada
Relay de Pedidos publica evento en topic "order-events"
Inventario consume evento OrdenSolicitada
Inventario consulta: SELECT cantidad FROM productos WHERE sku = 'SKU-456'
Inventario verifica: 0 unidades disponibles, pedido requiere 5, verificación FALLA
Inventario inserta en OUTBOX el evento StockNoDisponible con detalles
Relay de Inventario publica evento en topic "inventory-events"
Pedidos consume StockNoDisponible
Pedidos actualiza estado a CANCELADA_SIN_STOCK
Pedidos emite evento OrdenCancelada con razón "stock insuficiente"
Pedidos envía notificación al cliente: "Lo sentimos, el producto está agotado"
Comportamiento de Pagos:
- El servicio de Pagos NO escucha el evento StockNoDisponible
- Por tanto, NO se ejecuta ninguna operación financiera
- No hay cargos ni reembolsos
Resultado final:
- Orden cancelada rápidamente (en menos de 1 segundo)
- Cliente no fue cobrado
- Costo financiero: cero
- Experiencia de cliente: transparente y honesta
Beneficio del patrón: Esta arquitectura evita aproximadamente el 95% de reembolsos comparado con estrategias de "cobrar primero, verificar después".
4.3 Escenario C: Fallo Tardío (Race Condition)
Contexto inicial:
- Cliente A solicita 1 unidad del producto SKU-789
- Inventario actual: 1 unidad disponible
- Evento concurrente: Cliente B también solicita 1 unidad del mismo producto
Secuencia de eventos para Cliente A:
Pedidos A crea orden con Saga ID: "saga-aaa"
Pedidos A emite OrdenSolicitada
Inventario verifica disponibilidad para saga-aaa
Inventario consulta: 1 unidad disponible
Inventario emite StockVerificado para saga-aaa
Pagos procesa cobro para saga-aaa
Pasarela aprueba transacción: txn_xyz789
Pagos emite PagoExitoso para saga-aaa
Evento concurrente (Cliente B):
Mientras el flujo de Cliente A está entre Fase 3 y Fase 4:
- Pedidos B emite OrdenSolicitada para saga-bbb
- Inventario verifica: aún hay 1 unidad, emite StockVerificado para saga-bbb
- Pagos cobra a Cliente B: txn_def456
- Pagos emite PagoExitoso para saga-bbb
Continuación para Cliente A (intenta asignar primero):
Inventario consume PagoExitoso para saga-aaa
Inventario inicia: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-789' FOR UPDATE
Inventario lee cantidad: 1 unidad
Inventario valida: 1 >= 1, OK
Inventario ejecuta: UPDATE productos SET cantidad = 0
Inventario inserta evento StockAsignado para saga-aaa
Inventario confirma: COMMIT
Pedidos A recibe StockAsignado
Orden A finaliza con estado COMPLETADA
Continuación para Cliente B (llega segundo):
Inventario consume PagoExitoso para saga-bbb
Inventario inicia: BEGIN; SELECT cantidad FROM productos WHERE sku = 'SKU-789' FOR UPDATE
Inventario lee cantidad: 0 unidades (ya fue consumida por Cliente A)
Inventario valida: 0 >= 1, FALLA
Inventario NO ejecuta UPDATE
Inventario inserta evento FalloAsignacion para saga-bbb con razón "race condition"
Inventario confirma: COMMIT (importante: confirmar para que evento se publique)
Pagos consume FalloAsignacion para saga-bbb
Pagos busca transacción original: txn_def456
Pagos invoca API de pasarela: "Reembolsar txn_def456"
Pasarela confirma: "Reembolso procesado, refund_id: rfnd_xyz"
Pagos inserta evento ReembolsoEjecutado para saga-bbb
Relay de Pagos publica evento
Pedidos B consume ReembolsoEjecutado
Pedidos B actualiza estado a CANCELADA_CON_REEMBOLSO
Pedidos B envía notificación al Cliente B: "Tu pago ha sido reembolsado, el producto se agotó durante el proceso"
Resultado final:
- Cliente A: orden completada, inventario asignado
- Cliente B: orden cancelada, dinero reembolsado automáticamente
- Sistema mantuvo consistencia a pesar de concurrencia
- No hubo sobreventa (overselling)
Observación crítica: Este escenario demuestra por qué la verificación en Fase 2 es "optimista" (sin bloqueo) y la asignación en Fase 4 es "pesimista" (con bloqueo). El bloqueo temprano causaría alta contención. El bloqueo tardío minimiza ventana crítica.
5. Consideraciones de Implementación
5.1 Ordenamiento de Eventos
Problema: Los brokers garantizan orden dentro de una partición, no globalmente.
Solución para este caso de uso:
- Particionar eventos por Saga ID
- Todos los eventos de una misma saga van a la misma partición
- Configurar clave de partición: Saga ID
- Garantiza que eventos de UNA orden se procesan en orden
- No importa el orden entre órdenes diferentes
5.2 Manejo de Duplicados
Ejemplo de deduplicación en Inventario:
Cuando llega evento PagoExitoso:
- Extraer Message ID del header: "msg-12345"
- Iniciar transacción
- Intentar: INSERT INTO processed_messages (message_id, event_type) VALUES ('msg-12345', 'PagoExitoso')
- Si falla por clave duplicada:
- Significa que este mensaje ya fue procesado
- Hacer ROLLBACK
- Retornar ACK al broker (no reintentar)
- Registrar en log: "Evento duplicado ignorado"
- Si inserción exitosa:
- Proceder con lógica de asignación de stock
- COMMIT incluye tanto la nueva fila en processed_messages como el UPDATE de inventario
- Retornar ACK al broker
5.3 Consistencia de OUTBOX
Garantía crítica: El evento en OUTBOX y el cambio de estado de negocio deben confirmarse en la misma transacción atómica de base de datos.
Ejemplo en Pedidos al recibir StockVerificado:
Transacción única:
- BEGIN
- UPDATE orders SET status = 'PENDIENTE_PAGO' WHERE saga_id = '550e8400...'
- INSERT INTO outbox (event_type, payload, saga_id) VALUES ('OrdenActualizada', '{...}', '550e8400...')
- COMMIT
Si falla el COMMIT, ningún cambio se persiste. Si se confirma, ambos cambios quedan guardados atómicamente.
5.4 Configuración de Timeouts por Servicio
Pedidos:
- Timeout de validación de stock: 30 segundos
- Timeout de pago: 60 segundos (APIs bancarias pueden ser lentas)
- Timeout de asignación: 15 segundos
- Timeout total de saga: 2 horas
Inventario:
- Timeout de consulta de BD: 5 segundos
- Timeout de bloqueo pesimista: 10 segundos
Pagos:
- Timeout de llamada a pasarela: 30 segundos
- Reintentos en pasarela: 3 intentos con backoff de 2-8-18 segundos
- Timeout de reembolso: 60 segundos
PARTE III: PATRONES AVANZADOS (OPCIONAL)
1. Sagas de Larga Duración
Definición: Procesos que requieren más de una hora para completarse, típicamente por intervención humana, validaciones externas, o pasos asíncronos lentos.
Ejemplos:
- Proceso de aprobación de crédito (requiere revisión manual)
- Workflow de incorporación de empleado (múltiples pasos en días)
- Proceso de compra B2B con aprobaciones corporativas
- Integración con sistemas legacy batch que procesan nocturnamente
1.1 Desafíos Específicos
Problema 1: Estado en Memoria No es viable mantener el estado de la saga en memoria de un servicio durante horas o días. El servicio puede reiniciarse.
Problema 2: Escalabilidad Miles de sagas activas simultáneas durante días consumen recursos si no se gestionan apropiadamente.
Problema 3: Visibilidad Usuarios y operadores necesitan consultar el estado de procesos que toman días.
1.2 Estrategia de Implementación
Persistencia de Estado Explícita:
Crear una tabla dedicada para rastrear sagas activas:
Campos requeridos:
- Identificador único de saga (PK)
- Tipo de saga (ej: "AprobacionCredito")
- Estado actual (ej: "ESPERANDO_REVISION_MANUAL")
- Timestamp de inicio
- Timestamp de última actualización
- Paso actual en el flujo
- Contexto de negocio (datos necesarios para reanudar)
- Usuario o entidad propietaria
- Fecha de expiración o timeout
Timers Persistentes:
En lugar de mantener timers en memoria, usar:
- Scheduled jobs que consultan tabla de sagas periódicamente
- Buscar sagas en estado de espera cuyo timeout ha expirado
- Emitir eventos de timeout para reanudar o compensar
- Ejemplo: Job cada 5 minutos revisa sagas con "expected_event_by" < NOW()
Endpoints de Consulta:
Exponer APIs REST para que usuarios consulten estado:
- GET /sagas/saga-id/status → Retorna estado actual y progreso
- GET /sagas/saga-id/history → Retorna todos los eventos y transiciones
- POST /sagas/saga-id/cancel → Permite cancelación manual (ejecuta compensación)
1.3 Patrón de Reanudación
Caso de uso: Proceso pausado esperando aprobación humana.
Flujo:
- Saga llega a paso que requiere aprobación
- Servicio emite evento "AprobacionSolicitada"
- Servicio actualiza tabla de sagas: estado = "ESPERANDO_APROBACION"
- Se envía notificación a aprobador (email, dashboard, etc.)
- Servicio NO mantiene nada en memoria, libera recursos
- Horas o días después, aprobador toma decisión
- Sistema de UI/backoffice emite evento "AprobacionOtorgada" o "AprobacionRechazada"
- Servicio escucha evento, consulta estado de saga en tabla
- Servicio carga contexto de negocio desde tabla
- Servicio reanuda flujo desde el paso siguiente
- Servicio actualiza estado en tabla
Beneficio: El servicio es stateless, puede reiniciarse sin perder progreso.
2. Sub-Sagas Anidadas
Definición: Una saga que, como parte de uno de sus pasos, inicia otra saga completa e independiente.
Ejemplo:
- Saga principal: "ProcesarCompraEmpresarial"
- Paso 3 de la saga requiere: "ValidarCreditoProveedor"
- ValidarCreditoProveedor es en sí una saga con múltiples pasos (consultar bureaus, validar referencias, aprobar monto)
2.1 Reglas de Anidación
Máximo permitido: 2 niveles de profundidad
- Saga Nivel 0 (raíz)
- Saga Nivel 1 (hija directa)
- Prohibido: Saga Nivel 2 (nieta)
Razón: Complejidad exponencial de compensación. Si una saga de nivel 2 falla, hay que compensar nivel 2, luego nivel 1, luego nivel 0. El rastreo se vuelve intratable.
2.2 Responsabilidad de Compensación
Principio: La saga padre es responsable de compensar sagas hijas si el flujo general falla.
Ejemplo:
Saga Padre: ProcesarCompra
- Paso 1: CrearOrden → OK
- Paso 2: ValidarCredito (invoca sub-saga) → OK
- Paso 3: EnviarMercancia → FALLA
Compensación:
- Saga Padre emite evento de compensación para Paso 3 (no aplica, nunca ocurrió)
- Saga Padre emite evento "CancelarValidacionCredito" para sub-saga
- Sub-saga ejecuta su propia compensación interna (liberar límite de crédito reservado)
- Saga Padre compensa Paso 1 (CancelarOrden)
Implementación:
La saga padre debe:
- Mantener registro de todas las sub-sagas iniciadas (almacenar Sub-Saga IDs)
- Al compensar, emitir eventos de compensación dirigidos a cada sub-saga
- Esperar confirmación de compensación de sub-sagas antes de completar su propia compensación
- Implementar timeout: si sub-saga no confirma compensación en X tiempo, alertar para intervención manual
2.3 Propagación de Contexto
Identificadores requeridos:
- Saga ID del padre (Root Saga ID)
- Saga ID de la hija (Child Saga ID)
- Nivel de anidación (0 = raíz, 1 = hija)
Headers en eventos de sub-saga:
- X-Root-Saga-ID: ID de la saga raíz
- X-Parent-Saga-ID: ID de la saga que inició esta
- X-Saga-Level: Nivel numérico de anidación
Uso en observabilidad: Permite visualizar jerarquía completa en herramientas de tracing:
- Root Saga: ProcesarCompra-123
- Child Saga: ValidarCredito-456
- Event: ConsultarBureau
- Event: ValidarReferencias
- Event: EnviarMercancia
- Child Saga: ValidarCredito-456
3. Semantic Lock (Bloqueo Semántico de Negocio)
Problema: Prevenir race conditions en recursos críticos sin recurrir a bloqueos pesimistas de base de datos que reducen throughput.
Ejemplo del problema: Dos usuarios intentan reservar el mismo asiento de avión simultáneamente. Sin bloqueo, ambos podrían ver "asiento disponible" y ambos intentar comprarlo.
3.1 Concepto de Reserva Soft
En lugar de modificar inmediatamente el estado del recurso, se marca como "reservado temporalmente" con:
- Identificador de quién reservó (Saga ID)
- Timestamp de expiración (TTL - Time To Live)
Diferencia con bloqueo tradicional:
- Bloqueo tradicional (SELECT FOR UPDATE): Mantiene lock de base de datos hasta commit/rollback
- Semantic Lock: Marca lógica en el dato que otros respetan, lock se libera automáticamente por TTL
3.2 Flujo de Tres Fases
Fase 1 - Reserva Soft (Check):
- Servicio verifica disponibilidad del recurso
- Si está disponible, marca como "reservado" con Saga ID y TTL de 5 minutos
- Emite evento "RecursoReservado"
- NO compromete definitivamente el recurso
Fase 2 - Operación Crítica (Execute):
- Se ejecuta la operación costosa (ej: cobro de pago)
- Si falla, la reserva expira automáticamente por TTL
- Si tiene éxito, emite evento para confirmar
Fase 3 - Confirmación Hard (Commit):
- Servicio escucha evento de éxito de operación crítica
- Convierte reserva soft en asignación definitiva
- Cambia estado de "reservado" a "vendido"
- Limpia TTL
Fase Alternativa - Liberación Automática:
- Si saga falla o expira, el TTL llega a cero
- Job periódico (cada minuto) busca reservas expiradas
- Cambia estado de "reservado" a "disponible"
- Recurso queda libre para otros
3.3 Ejemplo Completo: Reserva de Asiento
Tabla de asientos:
Campos:
- id_asiento (PK)
- numero_asiento
- estado: DISPONIBLE, RESERVADO, VENDIDO
- reservado_por_saga_id (nullable)
- reservado_hasta_timestamp (nullable)
Paso 1 - Validación y Reserva:
Servicio de Asientos escucha OrdenDeVueloSolicitada:
- Consultar: SELECT estado, reservado_hasta FROM asientos WHERE numero = '12A'
- Si estado = VENDIDO: emitir AsientoNoDisponible
- Si estado = RESERVADO AND reservado_hasta > NOW: emitir AsientoNoDisponible
- Si estado = DISPONIBLE OR (estado = RESERVADO AND reservado_hasta <= NOW):
- UPDATE asientos SET estado = 'RESERVADO', reservado_por_saga_id = 'saga-123', reservado_hasta = NOW + 5 minutos
- Emitir AsientoReservado
- COMMIT
Paso 2 - Pago:
Servicio de Pagos procesa cobro (toma 30 segundos):
- Si éxito: emite PagoExitoso
- Si fallo: NO emite nada, saga expira
Paso 3 - Confirmación:
Servicio de Asientos escucha PagoExitoso:
- Verificar que saga_id coincide: SELECT reservado_por_saga_id FROM asientos WHERE numero = '12A'
- UPDATE asientos SET estado = 'VENDIDO', reservado_por_saga_id = NULL, reservado_hasta = NULL
- Emitir AsientoConfirmado
- COMMIT
Job de Limpieza (cada 1 minuto):
- SELECT numero FROM asientos WHERE estado = 'RESERVADO' AND reservado_hasta <= NOW
- Para cada asiento encontrado:
- UPDATE asientos SET estado = 'DISPONIBLE', reservado_por_saga_id = NULL, reservado_hasta = NULL
- Registrar en log: "Reserva expirada para asiento X de saga Y"
Ventajas de este patrón:
- Alta concurrencia: No bloquea filas durante el pago
- Auto-recuperación: Fallos liberan recursos automáticamente
- Fairness: Primer solicitante obtiene reserva temporal
- Sin deadlocks: No hay bloqueos de base de datos
Desventajas:
- Complejidad adicional en lógica de negocio
- Requiere job de limpieza confiable
- Ventana de race condition muy pequeña (pero existe) en actualización de reserva
PARTE IV: GUÍAS DE IMPLEMENTACIÓN
1. Checklist de Desarrollo
Todo servicio que participe en una saga debe cumplir los siguientes requisitos antes de pasar a producción:
1.1 Persistencia y Mensajería
Verificar que:
- Existe tabla OUTBOX con todos los campos mandatorios
- Existe tabla de mensajes procesados para deduplicación
- Existe proceso Relay que publica eventos desde OUTBOX al broker
- El Relay se ejecuta con intervalo no mayor a 1 segundo
- Todos los eventos incluyen campo schema_version
- Todos los eventos tienen esquemas registrados en Schema Registry
1.2 Idempotencia
Verificar que:
- Todo handler de evento verifica Message ID antes de procesar
- La verificación y el procesamiento están en la misma transacción
- Existe test automatizado que envía mismo mensaje 3 veces y verifica resultado único
- Existe limpieza automática de tabla de mensajes procesados
1.3 Compensación
Verificar que:
- Para cada operación de escritura existe handler de compensación
- La compensación es idempotente (puede ejecutarse N veces)
- Existen tests que verifican que compensación revierte el estado
- La compensación emite evento de confirmación
- La compensación se registra en logs de auditoría
1.4 Configuración
Verificar que:
- Todos los parámetros de timeout son configurables externamente
- Valores por defecto cumplen con mínimos del estándar
- Configuración se carga al inicio y se valida
- Cambios de configuración no requieren recompilación
1.5 Observabilidad
Verificar que:
- Todos los logs de saga son estructurados (no texto plano)
- Saga ID se propaga en todos los eventos emitidos
- Todas las métricas mandatorias están implementadas
- Existe dashboard de monitoreo con visualización de métricas
- Existen alertas configuradas para casos anómalos
1.6 Testing
Verificar que:
- Existen tests de happy path completo
- Existen tests de cada escenario de compensación
- Existen tests de race conditions simuladas
- Existen tests de chaos engineering (matar servicio en medio de saga)
- Existe test de duplicación de mensaje
2. Templates de Eventos Estándar
Todos los eventos deben seguir una estructura consistente para facilitar consumo y rastreo.
2.1 Estructura Base de Evento
Todo evento publicado debe incluir tres secciones:
Sección 1 - Metadatos de Rastreo:
- Identificador único del mensaje (UUID)
- Identificador de la saga (UUID)
- Identificador del span actual (UUID)
- Identificador del span padre (UUID o null si es raíz)
- Versión del esquema del evento (formato semántico)
- Timestamp de creación en UTC ISO-8601
- Nombre del servicio que emitió el evento
- Tipo de evento (nombre descriptivo)
Sección 2 - Datos de Negocio (Payload):
- Información específica del dominio
- Solo datos necesarios para los consumidores
- Sin información sensible sin encriptar
- Con tipos de datos explícitos
Sección 3 - Metadatos Adicionales (Opcional):
- Identificador de correlación de negocio
- Identificador del usuario que inició el flujo
- Información de contexto relevante
- Tags o labels para filtrado
2.2 Ejemplo Descriptivo: Evento OrdenSolicitada
Metadatos de rastreo:
- message_id contiene un UUID único generado al crear el evento
- saga_id contiene el identificador de la saga completa de procesamiento de orden
- span_id contiene un UUID único para este evento específico
- parent_span_id contiene null porque este es el evento inicial
- schema_version contiene el string "1.0.0"
- timestamp contiene la fecha y hora de creación en formato ISO-8601 UTC
- source_service contiene el string "pedidos"
- event_type contiene el string "OrdenSolicitada"
Payload de negocio:
- order_id contiene el identificador único de la orden en el sistema de pedidos
- customer_id contiene el identificador del cliente que hizo la orden
- items es una lista de objetos, cada uno contiene:
- sku: código del producto
- quantity: cantidad solicitada (número entero)
- price: precio unitario (número decimal con dos decimales)
- total_amount contiene el monto total de la orden (número decimal)
- shipping_address es un objeto que contiene:
- street: calle
- city: ciudad
- country: país
- postal_code: código postal
- payment_method es un objeto que contiene:
- type: tipo de pago (string: "credit_card", "paypal", etc.)
- token: token de pago tokenizado (NO el número de tarjeta real)
Metadatos adicionales:
- correlation_id contiene un identificador de correlación de negocio (ej: número de orden visible al cliente)
- user_id contiene el identificador del usuario autenticado
- channel contiene el canal de origen: "web", "mobile", "api"
3. Estrategias de Testing
3.1 Tests de Unidad para Idempotencia
Objetivo: Verificar que procesar el mismo evento múltiples veces produce el mismo resultado.
Escenario de test:
- Preparar estado inicial de base de datos
- Crear un evento de prueba con Message ID específico
- Ejecutar handler del evento por primera vez
- Capturar estado resultante de base de datos
- Ejecutar handler del mismo evento (mismo Message ID) por segunda vez
- Verificar que estado de base de datos es idéntico al del paso 4
- Ejecutar handler por tercera vez
- Verificar nuevamente estado idéntico
- Verificar que tabla de mensajes procesados tiene solo UNA entrada para ese Message ID
Resultado esperado:
- Operación de negocio se ejecutó solo una vez
- Llamadas subsecuentes fueron ignoradas
- No hubo duplicación de datos
- No hubo errores
3.2 Tests de Compensación
Objetivo: Verificar que la transacción compensatoria revierte el efecto de la operación original.
Escenario de test para Inventario:
- Estado inicial: Producto SKU-999 tiene 100 unidades
- Publicar evento PagoExitoso para orden de 5 unidades de SKU-999
- Esperar procesamiento
- Verificar: Producto SKU-999 tiene 95 unidades
- Publicar evento FalloAsignacion (trigger de compensación)
- Esperar procesamiento de compensación
- Verificar: Producto SKU-999 tiene 100 unidades nuevamente
- Verificar en logs: Existe entrada de compensación ejecutada
- Verificar: Se emitió evento StockLiberado
Casos adicionales a probar:
- Compensar cuando la operación original nunca ocurrió (compensación debe ser no-op)
- Compensar dos veces (debe ser idempotente)
- Compensar cuando el recurso ya no existe (debe manejarse gracefully)
3.3 Tests de Chaos Engineering
Objetivo: Verificar resiliencia ante fallos de infraestructura.
Escenario 1 - Matar Servicio Después de COMMIT:
- Iniciar saga de prueba
- Instrumentar código para matar proceso inmediatamente después de COMMIT en base de datos pero ANTES de enviar ACK al broker
- Iniciar servicio
- Esperar que saga procese
- Servicio muere
- Verificar: Cambio en BD se persistió
- Verificar: Evento en OUTBOX se persistió
- Reiniciar servicio
- Verificar: Relay publica evento pendiente
- Verificar: Saga continúa normalmente
Escenario 2 - Desconectar Broker Durante Saga:
- Iniciar saga
- Cuando llegue al paso 2, simular desconexión de red al broker
- Verificar: Servicio reintenta con exponential backoff
- Verificar: Eventos quedan pendientes en OUTBOX
- Restaurar conexión después de 2 minutos
- Verificar: Relay publica eventos pendientes
- Verificar: Saga completa exitosamente
Escenario 3 - Latencia Extrema de Base de Datos:
- Simular latencia de 10 segundos en todas las queries de BD
- Iniciar saga
- Verificar: Timeouts se activan correctamente
- Verificar: Mensajes van a DLQ después de reintentos
- Verificar: Se emiten alertas apropiadas
- Verificar: No hay deadlocks ni procesos zombies
3.4 Tests End-to-End de Saga Completa
Objetivo: Verificar flujo completo en ambiente similar a producción.
Infraestructura de test:
- Broker real (Kafka o RabbitMQ dockerizado)
- Bases de datos reales por servicio (PostgreSQL dockerizado)
- Los tres servicios corriendo (Pedidos, Inventario, Pagos)
- Mock de pasarela de pagos externa
Test de Happy Path:
- Insertar datos de prueba en BD de Inventario: SKU-TEST con 50 unidades
- Enviar request HTTP POST a servicio de Pedidos: crear orden de 10 unidades de SKU-TEST
- Esperar máximo 10 segundos
- Verificar en BD de Pedidos: orden existe con estado COMPLETADA
- Verificar en BD de Inventario: SKU-TEST tiene 40 unidades
- Verificar en BD de Pagos: existe registro de transacción aprobada
- Verificar en logs: todos los eventos se emitieron en orden correcto
- Verificar en métricas: saga_duration_seconds registró tiempo total
Test de Fallo y Compensación:
- Configurar mock de pasarela para rechazar pagos
- Insertar datos: SKU-TEST2 con 20 unidades
- Enviar request: crear orden de 5 unidades de SKU-TEST2
- Esperar procesamiento
- Verificar: Orden en estado CANCELADA_PAGO_RECHAZADO
- Verificar: Inventario NO se modificó (sigue en 20 unidades)
- Verificar: NO existe registro de transacción en BD de Pagos
- Verificar en logs: evento PagoRechazado fue emitido
Test de Race Condition:
- Insertar datos: SKU-TEST3 con 1 unidad
- Lanzar DOS requests simultáneos: ambos piden 1 unidad de SKU-TEST3
- Esperar procesamiento
- Verificar: UNA orden en estado COMPLETADA
- Verificar: OTRA orden en estado CANCELADA_CON_REEMBOLSO
- Verificar: Inventario en 0 unidades (solo una asignación exitosa)
- Verificar en logs: evento FalloAsignacion emitido para segunda saga
- Verificar: Mock de pasarela recibió llamada de reembolso
ANEXO A: Architecture Decision Records (ADR)
ADR-001: Prohibición de Two-Phase Commit y XA Transactions
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
En arquitecturas de microservicios distribuidos, la coordinación de transacciones entre múltiples servicios requiere decisiones sobre consistencia vs disponibilidad. El protocolo Two-Phase Commit (2PC) y las transacciones XA ofrecen atomicidad estricta pero con costos significativos.
Decisión:
Se prohíbe el uso de transacciones distribuidas bloqueantes (2PC, XA Transactions) en todo el ecosistema de microservicios. En su lugar, se adopta el patrón Saga con consistencia eventual.
Razones:
Disponibilidad: 2PC requiere que todos los participantes estén disponibles simultáneamente. Si un servicio está caído, toda la transacción se bloquea. En sistemas distribuidos con múltiples servicios, la probabilidad de que algún componente esté temporalmente no disponible es alta.
Latencia: El protocolo requiere múltiples roundtrips de red (prepare, vote, commit). Esto incrementa significativamente la latencia percibida por usuarios finales.
Bloqueos: Los recursos (filas de base de datos) quedan bloqueados durante todo el protocolo. En sistemas de alta concurrencia, esto reduce dramáticamente el throughput.
Complejidad Operacional: Requiere coordinador transaccional centralizado (Transaction Manager) que se convierte en punto único de fallo. La recuperación de fallos en 2PC es compleja y propensa a estados inconsistentes.
Escalabilidad Limitada: No escala horizontalmente bien porque el coordinador se convierte en bottleneck.
Consecuencias:
Positivas:
- Mayor disponibilidad del sistema (cada servicio puede operar independientemente)
- Mejor throughput en operaciones concurrentes (sin bloqueos prolongados)
- Escalabilidad horizontal sin límites de coordinador central
- Resiliencia ante fallos parciales (saga puede progresar aunque un servicio esté caído temporalmente)
Negativas:
- Complejidad lógica incrementada (implementación de compensaciones)
- Ventanas de inconsistencia temporal (datos pueden estar desincronizados por segundos o minutos)
- Mayor complejidad en testing (necesidad de probar escenarios de compensación)
- Debugging más complejo (flujo implícito, necesidad de rastreo distribuido)
Mitigaciones de Consecuencias Negativas:
- Implementar Transactional Outbox Pattern para garantizar publicación de eventos
- Establecer observabilidad robusta con rastreo distribuido
- Documentar claramente lógica de compensación
- Implementar idempotencia estricta en todos los consumidores
- Definir límites de timeout para evitar sagas infinitas
ADR-002: Validación de Inventario Antes de Pago
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
En sistemas de comercio electrónico, existen dos estrategias principales para manejar inventario en el flujo de compra:
- Estrategia A (Pay-First): Cobrar primero, luego verificar/asignar inventario. Si no hay stock, reembolsar.
- Estrategia B (Check-Then-Pay): Verificar inventario primero, luego cobrar solo si hay disponibilidad.
Decisión:
Se adopta la estrategia Check-Then-Pay: validar disponibilidad de inventario ANTES de ejecutar el cobro financiero.
Razones:
Costos Financieros: Cada transacción con pasarela de pagos tiene un costo (típicamente 2.9% + 0.30 USD). Los reembolsos también incurren en costos. La estrategia Pay-First genera costos innecesarios cuando el pedido finalmente se cancela por falta de stock.
Experiencia de Usuario: Los clientes perciben negativamente ser cobrados y luego reembolsados días después. Genera desconfianza y fricción. La estrategia Check-Then-Pay ofrece feedback inmediato sobre disponibilidad.
Complejidad Contable: Los reembolsos complican la contabilidad y reconciliación bancaria. Requieren procesos adicionales de seguimiento.
Carga en Soporte: Clientes cobrados y luego reembolsados generan tickets de soporte preguntando por el cargo temporal.
Trade-offs Considerados:
Ventana de Race Condition: Entre la validación (Fase 2) y la asignación (Fase 4), el inventario puede ser consumido por otra transacción concurrente. Esto resulta en que algunos pagos exitosos requieran reembolso de todos modos.
Latencia Adicional: Agregar paso de validación aumenta latencia total del flujo en aproximadamente 200-500ms.
Mitigación del Race Condition:
- Implementar re-verificación con bloqueo pesimista en Fase 4
- Implementar compensación automática de pago (reembolso) cuando ocurra race condition
- Monitorear tasa de race conditions y ajustar inventario buffer si es muy alta
Consecuencias:
Positivas:
- Reducción del 95% en costos de transacciones fallidas (según estudios de caso de e-commerce)
- Mejor experiencia de usuario (feedback inmediato de falta de stock)
- Menor carga operacional (menos reembolsos manuales)
- Contabilidad más limpia
Negativas:
- Posibilidad de race condition (aproximadamente 5% de casos en alta concurrencia)
- Latencia adicional de 200-500ms por validación previa
- Complejidad de implementar lógica de re-verificación con bloqueo
Métricas de Éxito:
- Tasa de reembolsos por falta de stock < 5%
- Latencia total de checkout < 3 segundos en percentil 95
- Tasa de quejas de clientes por cobros incorrectos < 0.1%
ADR-003: Coreografía con Coordinador de Estado vs Coreografía Pura
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
Las sagas pueden implementarse mediante dos patrones principales:
Coreografía Pura: Cada servicio escucha eventos relevantes y emite eventos de resultado sin conocer el flujo completo. No existe ningún componente central.
Orquestación: Un servicio centralizado (orquestador) coordina toda la saga invocando directamente a participantes y esperando respuestas.
Híbrido (Coreografía con Coordinador de Estado): Servicios se comunican vía eventos (coreografía) pero uno de ellos mantiene estado de la saga para visibilidad y decisiones de alto nivel.
Decisión:
Se adopta el patrón híbrido: Coreografía con Coordinador de Estado opcional. Se prohíbe orquestación tradicional con llamadas síncronas.
Razones a Favor de Coreografía:
- Bajo acoplamiento entre servicios
- Alta escalabilidad y resiliencia
- Facilita evolución independiente de servicios
- No hay punto único de fallo
Razones Contra Coreografía Pura:
- Difícil rastrear estado completo de una saga
- Complejo identificar en qué punto está una transacción de negocio
- No hay lugar obvio para implementar timeouts de saga completa
- Testing end-to-end más complejo
Razones Contra Orquestación Tradicional:
- Orquestador se convierte en bottleneck
- Alto acoplamiento (orquestador conoce todos los servicios)
- Punto único de fallo
- Dificulta escalabilidad horizontal
Solución Híbrida:
Permitir que el servicio iniciador (ej: Pedidos) actúe como Coordinador de Estado con restricciones:
- Puede mantener tabla de estado de saga
- Puede escuchar eventos de progreso
- Puede tomar decisiones de compensación
- NO puede invocar directamente a otros servicios
- NO puede ejecutar lógica de negocio de otros dominios
Consecuencias:
Positivas:
- Mantiene beneficios de coreografía (bajo acoplamiento, escalabilidad)
- Provee visibilidad centralizada de estado de saga
- Facilita implementación de timeouts
- Simplifica queries de estado para usuarios
- Facilita testing y debugging
Negativas:
- Incremento leve de complejidad en servicio coordinador
- Riesgo de que coordinador acumule lógica que debería estar en otros servicios (debe vigilarse en code reviews)
Reglas de Implementación:
- El coordinador solo mantiene estado, no ejecuta lógica de negocio
- Toda comunicación sigue siendo asíncrona vía eventos
- El coordinador es stateless (estado persiste en BD, no en memoria)
- Debe ser posible eliminar el coordinador sin romper el flujo (otros servicios siguen funcionando)
ADR-004: Outbox Pattern como Estándar Obligatorio
Fecha: 2026-02-01
Estado: Aceptado
Contexto:
Al trabajar con microservicios y messaging, existe el problema de Dual Writes: un servicio necesita actualizar su base de datos local Y publicar un evento en el broker. No existe transacción distribuida que abarque ambos sistemas.
Escenarios problemáticos sin Outbox:
- Escenario 1: Servicio actualiza BD, luego intenta publicar en broker, pero broker está caído → Cambio persiste pero evento nunca se publica → Inconsistencia
- Escenario 2: Servicio publica en broker exitosamente, luego intenta commit en BD, pero falla → Evento publicado pero cambio no persiste → Inconsistencia
- Escenario 3: Servicio hace commit en BD, luego proceso muere antes de publicar → Evento perdido → Inconsistencia
Decisión:
Hacer obligatorio el uso de Transactional Outbox Pattern en todos los servicios que participen en sagas.
Razones:
Atomicidad Garantizada: La tabla OUTBOX está en la misma base de datos que las tablas de negocio. Un commit atómico garantiza que ambos (cambio de negocio + evento) se persistan juntos o ninguno se persista.
Resiliencia ante Fallos: Si el proceso muere después del commit pero antes de publicar, el Relay independiente eventualmente publicará el evento pendiente.
Simplicidad Conceptual: La lógica de negocio se simplifica: solo se preocupa por persistir en BD local. La publicación en broker es responsabilidad del Relay.
Debugging Facilitado: Todos los eventos a publicar quedan registrados en una tabla. Se puede auditar qué eventos se publicaron, cuándo, cuántos intentos tomó, etc.
Implementaciones Consideradas:
Opción A - Polling: Proceso que consulta tabla OUTBOX periódicamente
- Pros: Simple de implementar, funciona con cualquier BD
- Contras: Latencia adicional (según intervalo de polling)
Opción B - Change Data Capture (CDC): Herramienta lee transaction log de BD
- Pros: Latencia mínima (casi real-time), no impacta rendimiento de BD
- Contras: Requiere herramienta adicional (Debezium), complejidad operacional
Opción C - Triggers de BD: Trigger que publica al insertar en OUTBOX
- Pros: Latencia cero
- Contras: Acopla BD con broker, dificulta testing, problemas de resiliencia
Decisión de Implementación:
- Opción A (Polling) es mandatoria para todos los servicios
- Opción B (CDC) es recomendada para servicios críticos de alto volumen
- Opción C (Triggers) está prohibida por acoplamiento
Consecuencias:
Positivas:
- Cero pérdida de eventos
- Garantía de atomicidad entre cambio de negocio y publicación
- Resiliencia ante fallos de broker o red
- Auditoría completa de eventos
Negativas:
- Latencia adicional (100-500ms típicamente con polling)
- Necesidad de proceso Relay adicional
- Tabla OUTBOX crece y requiere limpieza
- Complejidad adicional en infraestructura
Mitigaciones:
- Optimizar intervalo de polling (100-500ms es aceptable)
- Implementar limpieza automática de eventos publicados después de 7 días
- Usar índices apropiados en tabla OUTBOX para queries eficientes
- Monitorear tamaño de OUTBOX y alertar si crece anormalmente
ANEXO B: Glosario de Términos
BASE: Modelo de consistencia para sistemas distribuidos. Acrónimo de Basically Available (Básicamente Disponible), Soft state (Estado Suave), Eventually consistent (Eventualmente Consistente). Contrasta con ACID.
Broker de Mensajes: Sistema intermediario que facilita comunicación asíncrona entre servicios mediante colas y topics. Ejemplos: Kafka, RabbitMQ.
Change Data Capture (CDC): Técnica para detectar y capturar cambios en base de datos mediante lectura del transaction log. Usado para publicar eventos sin Dual Write Problem.
Compensación: Transacción lógicamente inversa que deshace o mitiga el efecto de una operación previamente confirmada. Ejemplo: Si se cargó una tarjeta, la compensación es reembolsar.
Consistencia Eventual: Propiedad de sistemas distribuidos donde, en ausencia de nuevas actualizaciones, eventualmente todas las réplicas convergerán al mismo estado. No garantiza cuándo ocurrirá.
Coreografía: Patrón de saga donde cada servicio escucha eventos relevantes y reacciona autónomamente sin coordinación central. Comparable a bailarines que siguen música sin director.
Correlation ID: Identificador único que se propaga a través de múltiples servicios y operaciones para rastrear una transacción de negocio completa en logs y métricas distribuidos.
Dead Letter Queue (DLQ): Cola especial donde se envían mensajes que fallaron repetidamente después de múltiples intentos de procesamiento. Permite análisis y reprocesamiento manual.
Dual Write Problem: Problema de consistencia que ocurre al intentar escribir en dos sistemas diferentes (ej: base de datos + message broker) sin transacción atómica que abarque ambos.
Exponential Backoff: Estrategia de reintentos donde el tiempo de espera entre intentos crece exponencialmente. Ejemplo: 1s, 2s, 4s, 8s, 16s. Previene sobrecarga durante fallos.
Idempotencia: Propiedad de una operación que puede ejecutarse múltiples veces sin cambiar el resultado más allá de la primera ejecución. Ejemplo: "Establecer X = 5" es idempotente, "Incrementar X" no lo es.
Message Broker: Ver Broker de Mensajes.
Orquestación: Patrón de saga donde un componente central (orquestador) coordina explícitamente todos los pasos invocando a participantes y esperando respuestas.
Outbox Pattern: Ver Transactional Outbox Pattern.
Race Condition: Situación donde el resultado de una operación depende del tiempo relativo de eventos concurrentes. Ejemplo: dos transacciones leyendo el mismo inventario antes de que cualquiera lo actualice.
Relay: Proceso que lee eventos de la tabla OUTBOX y los publica en el message broker. Puede ser implementado via polling o CDC.
Saga: Patrón de diseño para manejar transacciones distribuidas mediante secuencia de transacciones locales coordinadas por eventos, con compensaciones para revertir en caso de fallo.
Schema Registry: Servicio centralizado que almacena y versiona esquemas de eventos/mensajes. Permite validación de compatibilidad y generación de documentación.
Semantic Lock: Bloqueo lógico a nivel de negocio (no de base de datos) que marca un recurso como "reservado temporalmente" con TTL, permitiendo alta concurrencia.
Span: En rastreo distribuido, representa una unidad de trabajo individual. Una saga completa contiene múltiples spans (uno por cada paso/evento).
Timeout: Límite de tiempo máximo para esperar una respuesta o completar una operación. Previene esperas infinitas ante fallos.
Transactional Outbox Pattern: Patrón que resuelve Dual Write Problem insertando eventos en tabla local (OUTBOX) dentro de la misma transacción de negocio, para luego publicarlos asíncronamente.
TTL (Time To Live): Tiempo de vida de un recurso o dato después del cual expira automáticamente. Usado en Semantic Locks para liberar reservas no confirmadas.
Two-Phase Commit (2PC): Protocolo de transacciones distribuidas bloqueantes que garantiza atomicidad mediante fase de preparación y fase de commit. Prohibido en este estándar.
ANEXO C: Referencias y Recursos
Documentación Técnica Recomendada
Libros:
- "Designing Data-Intensive Applications" por Martin Kleppmann - Capítulos 7-9 sobre transacciones distribuidas y consistencia
- "Microservices Patterns" por Chris Richardson - Capítulo 4 completo sobre Sagas
- "Building Microservices" por Sam Newman - Segunda edición, capítulo sobre workflows y consistencia
Papers Académicos:
- "Sagas" por Hector Garcia-Molina y Kenneth Salem (1987) - Paper original que define el patrón
- "Life beyond Distributed Transactions: an Apostate's Opinion" por Pat Helland - Argumentos contra transacciones distribuidas
- "Building on Quicksand" por Pat Helland y Dave Campbell - Fundamentos de consistencia eventual
Recursos Online:
- Microservices.io - Patrón Saga: https://microservices.io/patterns/data/saga.html
- Documentación de Debezium para CDC: https://debezium.io
- Confluent Schema Registry documentation: https://docs.confluent.io/platform/current/schema-registry/
Bibliotecas y Frameworks Recomendados
Para Java/JVM:
- Eventuate Tram Saga Framework - Framework especializado en sagas con outbox pattern
- Axon Framework - CQRS y Event Sourcing con soporte para sagas
- Apache Camel - Integración con múltiples brokers y patrones de mensajería
Para .NET:
- MassTransit - Framework de messaging con soporte nativo para sagas
- NServiceBus - Bus de servicios con soporte para sagas de larga duración
- Rebus - Bus de mensajes ligero con soporte para sagas
Para Node.js:
- Moleculer - Framework de microservicios con soporte para sagas
- NestJS con Bull - Soporte para colas y workflows complejos
Para Python:
- Nameko - Framework de microservicios con soporte para eventos
- Celery - Sistema de colas distribuidas con soporte para workflows
Herramientas de Observabilidad
Rastreo Distribuido:
- Jaeger - Sistema open-source de rastreo distribuido
- Zipkin - Rastreo distribuido con múltiples integraciones
- AWS X-Ray - Servicio administrado de AWS para rastreo
- Google Cloud Trace - Servicio de rastreo para GCP
Logging:
- ELK Stack (Elasticsearch, Logstash, Kibana) - Stack completo de logging
- Grafana Loki - Sistema de agregación de logs
- Splunk - Plataforma enterprise de análisis de logs
Métricas:
- Prometheus - Sistema de métricas con modelo pull
- Grafana - Visualización de métricas
- Datadog - Plataforma SaaS completa de observabilidad
Comunidades y Foros
- CNCF Slack - Canal #microservices
- Stack Overflow - Tag "saga-pattern" y "distributed-transactions"
- Reddit r/microservices
- DDD/CQRS Google Group
FIN DEL DOCUMENTO
Última Actualización: Febrero 2026
Próxima Revisión: Agosto 2026
Responsable: Equipo de Arquitectura Empresarial
Contacto: [email protected]
Arquitectura distribuida y el abandono consciente de ACID
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
En el mundo de los sistemas distribuidos, hay una verdad incómoda que enfrentamos tarde o temprano: no podemos tenerlo todo. La promesa de las transacciones ACID tradicionales —esa garantía tranquiliz
Arquitectura distribuida y el abandono consciente de ACID
- Mauricio ECR
- Arquitectura
- 14 Feb, 2026
En el mundo de los sistemas distribuidos, hay una verdad incómoda que enfrentamos tarde o temprano: no podemos tenerlo todo. La promesa de las transacciones ACID tradicionales —esa garantía tranquilizadora de que nuestros datos siempre estarán perfectamente sincronizados— se desvanece en el momento en que decidimos distribuir nuestra aplicación monolítica en múltiples servicios independientes. Es un momento de madurez arquitectónica que muchos equipos experimentan con cierta resistencia, similar a cuando un adolescente comprende que el mundo no es tan simple como parecía en la infancia.
Esta transformación no es meramente técnica; representa un cambio filosófico en cómo concebimos la consistencia de datos. Abandonamos el confort de las transacciones atómicas inmediatas, donde todo sucede o nada sucede en un instante perfecto, y abrazamos algo más orgánico, más real: la consistencia eventual. Los datos pueden estar temporalmente desalineados entre servicios, como músicos de una orqestra que momentáneamente pierden el compás para luego reconectarse con la melodía principal. La belleza de este modelo —conocido como BASE (Basically Available, Soft state, Eventually consistent)— radica en su pragmatismo: el sistema responde incluso cuando algunos servicios están caídos, los eventos se persisten antes de considerarse publicados, y eventualmente, con la paciencia de un jardinero que espera la floración, el sistema converge hacia un estado consistente.
El Arte de la Coreografía Distribuida
Imagina un ballet donde los bailarines no siguen a un director de orquesta central, sino que responden a las acciones de sus compañeros de manera autónoma. Este es el corazón del patrón Saga basado en coreografía. Cada servicio actúa como un participante independiente que escucha eventos relevantes de su dominio, ejecuta su lógica de negocio local, publica eventos de resultado y —esto es crucial— no tiene conocimiento completo de qué otros servicios participan en el proceso general.
Esta autonomía trae consigo ventajas significativas: bajo acoplamiento entre servicios, alta escalabilidad y la ausencia de un punto único de fallo. Pero también presenta desafíos genuinos. El flujo completo de la transacción se vuelve implícito, emergente de las interacciones locales, lo que puede hacer que la depuración se asemeje a seguir las huellas de un animal esquivo en el bosque. La complejidad en el rastreo es real, y no debemos minimizarla.
Para contextos donde necesitamos mayor visibilidad del flujo completo, existe una alternativa complementaria: la orquestación ligera de estado. Aquí, un servicio actúa como coordinador de estado —no como un tirano centralizado que comanda cada movimiento, sino como un observador atento que rastrea el progreso, mantiene una tabla de estado de la saga, escucha todos los eventos relevantes y puede tomar decisiones de cancelación cuando sea necesario. La distinción es sutil pero fundamental: el coordinador observa y reacciona, no comanda y espera. La comunicación sigue siendo asíncrona mediante el bus de eventos, preservando así los beneficios de la arquitectura desacoplada.
Hay, por supuesto, caminos que debemos evitar absolutamente. El Two-Phase Commit (2PC) y las transacciones XA, que extienden transacciones de base de datos entre servicios, están prohibidos debido a los bloqueos prolongados que generan y la baja disponibilidad resultante. Los bloqueos distribuidos compartidos entre servicios son igualmente problemáticos, con la excepción de los bloqueos semánticos de negocio que discutiremos más adelante. Las llamadas síncronas en flujos críticos —HTTP, REST, gRPC— deben reservarse exclusivamente para consultas de solo lectura, nunca para comandos que modifican estado en el contexto de una saga.
El Problema Dual Write y su Solución Elegante
Uno de los desafíos más insidiosos en sistemas distribuidos es el "Dual Write Problem". El escenario es simple pero traicionero: un servicio necesita actualizar su base de datos local y publicar un evento en el broker de mensajería. Si la publicación falla después del commit de la base de datos, el sistema queda inconsistente. Si falla antes, perdemos el evento y el flujo se interrumpe sin que nadie lo sepa.
La solución a este dilema es el patrón Transactional Outbox, una técnica elegante que convierte un problema de coordinación distribuida en dos problemas locales secuenciales. La regla de oro es simple: un servicio nunca debe publicar directamente en el broker dentro de su código de negocio. En su lugar, la operación se divide en dos fases distintas.
La primera fase es una transacción atómica local donde todo sucede dentro de los límites seguros de una sola base de datos. Iniciamos la transacción, ejecutamos nuestra operación de negocio —quizás un INSERT, UPDATE o DELETE en las tablas de dominio— e inmediatamente después insertamos el evento que queremos publicar en una tabla especial llamada OUTBOX. Finalmente, confirmamos la transacción completa. El punto crítico aquí es que ambas escrituras están protegidas por el mismo commit atómico: o ambas suceden, o ninguna sucede.
La segunda fase es completamente asíncrona y resiliente. Un proceso independiente —típicamente llamado Relay o Publisher— lee continuamente la tabla OUTBOX, encuentra eventos pendientes, los publica en el broker y los marca como publicados o los elimina. Este proceso puede fallar, reintentar, detenerse y reiniciarse sin comprometer la integridad de nuestros datos, porque la fuente de verdad —la tabla OUTBOX— está segura en nuestra base de datos.
La estructura de la tabla OUTBOX debe incluir campos obligatorios que garanticen su funcionamiento correcto: un identificador único del mensaje (típicamente un UUID), el tipo de evento de dominio, el cuerpo del evento serializado, timestamp de creación, estado de publicación (pendiente, publicado, fallido), número de intentos, el agregado raíz asociado para ordenamiento, y la versión del esquema del evento.
Para implementar el proceso de relay tenemos tres opciones principales. El polling tradicional es simple y confiable: un proceso consulta periódicamente la tabla OUTBOX —recomendamos intervalos de 100 a 500 milisegundos— publica eventos pendientes ordenados por timestamp y los marca como publicados tras confirmación del broker. Change Data Capture (CDC) es más sofisticado: herramientas como Debezium, Maxwell o AWS DMS leen el transaction log de la base de datos, detectan inserts en OUTBOX en tiempo real y publican automáticamente en el broker. Los triggers de base de datos son la opción menos recomendada debido al acoplamiento que generan y su menor resiliencia, aunque técnicamente funcional: un trigger se activa al insertar en OUTBOX e invoca un procedimiento que publica en el broker.
Idempotencia: El Escudo Contra la Duplicación
Aquí nos encontramos con otra verdad incómoda de los sistemas distribuidos: los brokers de mensajería garantizan entrega "al menos una vez", lo que significa que inevitablemente algunos mensajes llegarán duplicados. No es un error del broker, es una característica inherente a sistemas que priorizan la disponibilidad sobre la consistencia perfecta. Por tanto, todo consumidor de eventos debe ser idempotente.
La definición es directa pero profunda: una operación es idempotente si ejecutarla múltiples veces produce el mismo resultado que ejecutarla una sola vez. Es como presionar el botón del elevador repetidamente —el elevador viene una sola vez, sin importar cuántas veces presionamos el botón. Cada servicio debe mantener una tabla dedicada de mensajes procesados con el identificador del mensaje como clave primaria, el tipo de evento procesado, timestamp de procesamiento, estado final (éxito o fallo) y un índice en timestamp para limpieza periódica.
El flujo de procesamiento idempotente se convierte en un ritual casi ceremonial. Al recibir un mensaje del broker, iniciamos una transacción de base de datos local e intentamos insertar el identificador del mensaje en la tabla de registro. Si la inserción falla por clave duplicada, sabemos que este mensaje ya fue procesado anteriormente: hacemos rollback y retornamos éxito al broker sin ejecutar nada. Si la inserción es exitosa, procedemos con la lógica de negocio, potencialmente insertamos un evento resultante en nuestra tabla OUTBOX, confirmamos la transacción completa y enviamos ACK al broker. Como medida de higiene, eliminamos registros con más de siete días de antigüedad mediante un proceso nocturno.
La Danza de la Compensación
Cuando las cosas van mal en un sistema distribuido —y eventualmente lo harán— necesitamos una estrategia para deshacer operaciones que ya fueron confirmadas. Aquí entra el concepto de transacción compensatoria: una acción lógicamente inversa que deshace o mitiga el efecto de una operación previamente confirmada.
Los ejemplos son intuitivos una vez que comprendemos el patrón. CrearPedido se compensa con AnularPedido. ReservarInventario con LiberarReserva. CobrarPago con ReembolsarPago. EnviarNotificacion con EnviarNotificacionCorreccion. AsignarRecurso con DesasignarRecurso. Pero la compensación no siempre es perfecta en el sentido matemático de restaurar el estado exacto anterior.
Existen tres tipos de compensación, cada uno con su propia filosofía. La compensación perfecta restaura el estado exacto anterior —como cancelar una reserva que aún no ha sido utilizada. La compensación aproximada restaura un estado equivalente pero no idéntico —como ofrecer un reembolso en créditos de la tienda en lugar de dinero, cuando ambos tienen valor similar para el cliente. La compensación simbólica es la más interesante: registra el intento de reversión cuando la compensación real es físicamente imposible. No podemos "des-enviar" un email una vez que salió, pero podemos enviar un email de corrección o aclaración.
Las compensaciones deben ser idempotentes (pueden ejecutarse múltiples veces sin efectos adversos), deben registrarse en logs de auditoría para trazabilidad, y deben emitir eventos de compensación para que otros servicios puedan reaccionar apropiadamente.
El Arte del Reintento Inteligente
No todos los errores son creados iguales. Esta distinción es fundamental para implementar una estrategia de reintentos efectiva. Los fallos transitorios son errores temporales que pueden resolverse reintentando: pérdida de conexión de red, timeouts de base de datos por carga momentánea, un servicio dependiente temporalmente no disponible, o límites de rate limiting alcanzados. Los fallos permanentes no se resolverán sin intervención humana o cambios en el código: validaciones de negocio fallidas, datos malformados o incompletos, violaciones de reglas de dominio, permisos insuficientes, o recursos no encontrados.
Para fallos transitorios implementamos exponential backoff con jitter. La progresión es elegante: empezamos con una espera inicial de 500 milisegundos entre el primer y segundo intento, luego multiplicamos por un factor de 2 en cada intento subsecuente, con un techo máximo de 60 segundos entre intentos y un límite de 5 intentos totales. Añadimos jitter aleatorio —una variación del 10-25%— para evitar el fenómeno de "thundering herd" donde múltiples procesos reintentan simultáneamente, creando picos de carga que agravan el problema original. La progresión típica sería: 500ms → 1s → 2s → 4s → 8s → Dead Letter Queue.
La Dead Letter Queue (DLQ) es nuestro hospital para mensajes enfermos. Después de agotar los reintentos automáticos, el mensaje se envía a esta cola especial donde espera análisis manual posterior, genera alertas al equipo de operaciones, puede ser reprocesado manualmente tras corrección del problema subyacente, y sirve para auditoría de fallos recurrentes. Los mensajes en DLQ deben preservar información forense completa: el mensaje original completo, número de intentos realizados, timestamps de cada intento, detalles de cada error ocurrido, y el trace completo del último error.
Evolución sin Ruptura: El Versionado de Contratos
Los sistemas vivos evolucionan, y los contratos entre servicios deben evolucionar con ellos sin romper el ecosistema existente. Todo evento de dominio publicado debe tener un esquema formal —Avro, Protocol Buffers o JSON Schema— que defina nombre y tipo de cada campo, campos obligatorios versus opcionales, tipos de datos permitidos, restricciones de validación, y descripción semántica de cada campo.
El versionado semántico se convierte en nuestra brújula. Cada evento incluye un campo "schema_version" con formato MAJOR.MINOR.PATCH. Un cambio MAJOR indica incompatibilidad que requiere actualización del consumidor: eliminar campos, cambiar tipos de datos existentes, cambiar la semántica de un campo, o renombrar campos. Un cambio MINOR representa adiciones retrocompatibles: agregar nuevos campos opcionales, agregar nuevos valores a enumeraciones, o deprecar campos sin eliminarlos. Un PATCH es para correcciones menores sin impacto funcional: corregir descripciones, mejorar documentación, o corregir typos en nombres.
Para cambios aditivos (Minor o Patch), agregamos solo campos opcionales con valores por defecto. Los consumidores antiguos ignoran campos nuevos que no conocen, los productores nuevos toleran consumidores antiguos, y no se requiere coordinación de despliegue. Es evolución pacífica y gradual.
Para cambios breaking (Major), necesitamos una estrategia más cuidadosa. Creamos un nuevo tipo de evento con sufijo de versión —por ejemplo, "OrdenSolicitada_v2"— y mantenemos publicación dual por un período de transición. El productor emite tanto el evento v1 como el v2, permitiendo que los consumidores migren gradualmente. El período mínimo de convivencia es 90 días calendario. Solo después de este período podemos deprecar y eventualmente eliminar la versión antigua.
La política de deprecación es deliberadamente conservadora: anunciamos la deprecación con 90 días de anticipación, añadimos warnings en logs cuando se use la versión antigua, publicamos métricas de uso de versiones obsoletas, coordinamos la migración con todos los equipos consumidores, y eliminamos soporte solo cuando el uso sea cero por 30 días consecutivos. Es un proceso tedioso, sí, pero necesario para la salud del ecosistema.
Observabilidad: Iluminando la Caja Negra
Un sistema distribuido sin observabilidad es como navegar un barco en niebla densa sin instrumentos. El rastreo distribuido nos permite seguir el viaje completo de una transacción a través de múltiples servicios. El servicio que inicia una saga genera un Saga ID —un identificador global único, típicamente UUID versión 4— que representa toda la transacción distribuida. Este ID se genera una sola vez al inicio y se propaga sin cambios a través de todos los pasos subsecuentes.
Adicionalmente, cada servicio que procesa genera su propio Span ID —un identificador único para ese paso específico— y mantiene referencia al Parent Span ID del paso anterior, creando así una jerarquía de trazas como un árbol genealógico de operaciones. Estos identificadores viajan como headers en todos los mensajes: "X-Saga-ID", "X-Span-ID" y "X-Parent-Span-ID". Los servicios intermedios preservan el Saga ID sin modificarlo, generan su propio Span ID, copian el Span ID recibido como su Parent Span ID, y propagan estos tres valores en todos los eventos que emitan.
El logging estructurado es nuestra memoria colectiva. Cada entrada de log relacionada con procesamiento de eventos debe incluir campos mandatorios de contexto —identificador de saga, tipo de evento, versión del esquema, nombre del servicio, timestamp en ISO-8601 con zona horaria UTC, identificador del span actual y padre— junto con campos mandatorios de resultado: estado del procesamiento (RECEIVED, PROCESSING, SUCCESS, FAILED, COMPENSATING, COMPENSATED), duración en milisegundos de la operación, y número de intento para reintentos.
Las métricas son nuestros sensores vitales. saga_duration_seconds es un histograma que mide el tiempo total desde inicio hasta conclusión, etiquetado por tipo de saga y estado final. saga_step_errors_total es un contador acumulativo de fallos al procesar eventos, etiquetado por nombre de servicio, tipo de evento y tipo de error. saga_compensations_total cuenta las transacciones compensatorias ejecutadas. outbox_pending_messages es un gauge que muestra cuántos eventos están pendientes de publicar en cada servicio. dlq_messages_total indica la cantidad de mensajes problemáticos en Dead Letter Queue.
Para procesos críticos de negocio mantenemos tablas de auditoría completas con todos los cambios de estado de la saga, timestamp de cada transición, razón del cambio (evento que lo provocó), usuario o sistema responsable del inicio, y datos relevantes de negocio. La retención mínima sigue políticas regulatorias, típicamente siete años para sectores financieros.
Límites Temporales y la Paciencia del Sistema
Todo proceso distribuido debe tener límites temporales claramente definidos. No podemos esperar indefinidamente. Los parámetros configurables son nuestra red de seguridad: número máximo de intentos antes de enviar a DLQ (recomendamos 5 intentos con mínimo de 3), espera inicial entre primer y segundo intento (recomendamos 500 milisegundos con mínimo de 100), espera máxima entre intentos (recomendamos 60 segundos con mínimo de 30), timeout de saga completa (recomendamos 24 horas con mínimo de 1 hora, ajustable según naturaleza del proceso), y timeout por paso individual (recomendamos 5 minutos con mínimo de 30 segundos).
La filosofía de timeouts tiene dos niveles. Para timeouts de paso individual, si un evento esperado no llega dentro del plazo configurado, el coordinador emite un evento de timeout, inicia proceso de compensación, registra en logs con nivel ERROR e incrementa métricas de timeouts. Para timeouts de saga completa, si la saga no se completa dentro del plazo total, ejecutamos compensación automática de todos los pasos confirmados, marcamos la saga con estado TIMED_OUT, emitimos evento de saga expirada para auditoría, notificamos al usuario o sistema iniciador del fallo, y generamos alerta para el equipo de operaciones. Crucialmente, no eliminamos datos de auditoría —los mantenemos para análisis post-mortem.
Las alertas obligatorias actúan como sistema de alerta temprana: si el porcentaje de sagas fallidas supera 5% en ventana de 15 minutos, si el tiempo promedio supera el doble del baseline histórico, si la cantidad de mensajes en DLQ supera 10 por servicio, si hay mensajes en OUTBOX pendientes por más de 10 minutos, o si una saga individual supera el 80% del timeout configurado. Cada alerta tiene su nivel de severidad: CRITICAL para flujos de negocio críticos como pagos y pedidos, HIGH para funcionalidad importante pero no crítica, MEDIUM para degradación de rendimiento sin pérdida de funcionalidad, y LOW para anomalías sin impacto inmediato.
Del Concepto a la Realidad: Un Caso Práctico
La teoría cobra vida cuando la aplicamos a un caso concreto. Consideremos un sistema de comercio electrónico donde necesitamos procesar una orden de compra asegurando disponibilidad de inventario antes de ejecutar el cobro. El objetivo es claro: minimizar reembolsos por falta de stock, reducir costos de transacciones bancarias fallidas, y mejorar la experiencia del cliente evitando cobros seguidos de reembolsos inmediatos.
Nuestra estrategia es Check-Then-Act: verificación antes de acción financiera. El proceso se divide en cuatro fases secuenciales, cada una con su propósito específico. En la fase de intención, registramos la orden inicial con estado PENDIENTE_VALIDACION y emitimos el evento OrdenSolicitada. En la fase de validación, consultamos disponibilidad de inventario sin realizar ninguna reserva —es una operación de lectura pura, sin bloqueos— que resulta en StockVerificado o StockNoDisponible. En la fase de cobro condicional, ejecutamos la transacción financiera solo si la fase anterior fue exitosa, resultando en PagoExitoso o PagoRechazado. Finalmente, en la fase de asignación con bloqueo, realizamos el descuento definitivo de inventario con un UPDATE que usa bloqueo pesimista, resultando en StockAsignado o FalloAsignacion.
Tres servicios orquestan este ballet: el Gestor de Pedidos actúa como coordinador de estado, el Gestor de Inventario participa en dos momentos diferentes (validación y asignación), y el Procesador de Pagos actúa como intermediario financiero condicional.
El Gestor de Pedidos mantiene la máquina de estados de la orden. Cuando está en estado PENDIENTE_VALIDACION y recibe StockVerificado, transiciona a PENDIENTE_PAGO. Si recibe StockNoDisponible, va directamente a CANCELADA_SIN_STOCK y el flujo termina. Desde PENDIENTE_PAGO, al recibir PagoExitoso transiciona a PENDIENTE_ASIGNACION, pero si recibe PagoRechazado va a CANCELADA_PAGO_RECHAZADO. Finalmente, desde PENDIENTE_ASIGNACION, StockAsignado lleva a COMPLETADA (el flujo exitoso), mientras que FalloAsignacion resulta en CANCELADA_CON_REEMBOLSO y requiere compensación.
El Gestor de Inventario tiene una doble vida fascinante. En el momento de validación, al escuchar OrdenSolicitada, simplemente consulta disponibilidad actual sin tocar nada. Es como asomarse a la despensa para ver si hay suficiente harina sin tomar nada todavía. Si hay stock suficiente para todos los ítems, emite StockVerificado. Si algún ítem no tiene stock suficiente, emite StockNoDisponible con detalles. En el momento de asignación, al escuchar PagoExitoso, la cosa se pone seria: inicia una transacción con bloqueo pesimista (SELECT FOR UPDATE), re-verifica disponibilidad actual —que puede haber cambiado desde la validación inicial— descuenta las unidades si aún hay stock, y confirma transacción o hace rollback según el resultado.
Esta re-verificación en la fase de asignación es crítica. Durante el tiempo transcurrido entre la validación optimista (fase 2) y la asignación pesimista (fase 4), otra transacción concurrente podría haber consumido ese stock. El bloqueo pesimista en la fase 4 garantiza que, una vez que obtenemos el lock, nadie más puede modificar esos registros hasta que terminemos.
El Procesador de Pagos es deliberadamente ciego a OrdenSolicitada. Solo reacciona a StockVerificado, lo que previene cobros innecesarios cuando no hay stock disponible. Al recibir StockVerificado, valida que el evento no fue procesado previamente (idempotencia), extrae datos de pago, llama a la API de la pasarela con timeout de 30 segundos, y maneja la respuesta apropiadamente. Si la pasarela aprueba, almacena el ID de transacción e inserta PagoExitoso en OUTBOX. Si hay rechazo por fondos insuficientes o tarjeta inválida, inserta PagoRechazado. Si hay timeout o error de red, aplica la política de reintentos con exponential backoff, y tras agotar intentos envía a DLQ para revisión manual.
La compensación en Pagos es igualmente importante. Al recibir FalloAsignacion, extrae el Saga ID para identificar la transacción financiera original, busca en su base de datos local el ID de transacción de la pasarela, llama a la API de reembolso, y si es exitoso inserta ReembolsoEjecutado en OUTBOX. Si el reembolso falla, reintenta con backoff exponencial, y tras 5 intentos fallidos envía alerta crítica al equipo de finanzas y marca para reembolso manual.
Tres Historias, Tres Destinos
El flujo exitoso —el happy path que todos queremos ver— es casi poético en su simplicidad. Un cliente solicita 1 unidad del producto SKU-123. El inventario actual tiene 10 unidades disponibles. No hay operaciones concurrentes. El cliente envía su solicitud HTTP POST, Pedidos crea el registro con estado PENDIENTE_VALIDACION, genera un Saga ID único, inserta OrdenSolicitada en OUTBOX. El relay publica el evento. Inventario lo consume, consulta la base de datos, verifica que hay 10 unidades y el pedido requiere solo 1, inserta StockVerificado en OUTBOX. Pagos consume StockVerificado, invoca la API de la pasarela que aprueba el cobro, inserta PagoExitoso. Inventario consume PagoExitoso, inicia transacción con bloqueo pesimista, re-verifica que aún hay stock, ejecuta el UPDATE restando 1 unidad, inserta StockAsignado, confirma la transacción. Pedidos consume StockAsignado, actualiza estado a COMPLETADA, emite OrdenCompletada, y notifica al cliente. Todo el proceso toma aproximadamente 2-5 segundos. Elegante, eficiente, exitoso.
El fallo temprano es igualmente instructivo. Un cliente solicita 5 unidades del producto SKU-456. El inventario actual tiene 0 unidades. Pedidos crea la orden y emite OrdenSolicitada. Inventario consulta, verifica que hay 0 unidades pero el pedido requiere 5, inmediatamente inserta StockNoDisponible en OUTBOX. Pedidos consume este evento, actualiza estado a CANCELADA_SIN_STOCK, emite OrdenCancelada, y notifica al cliente que el producto está agotado. Lo crucial aquí es que el servicio de Pagos nunca se entera de nada —no escucha StockNoDisponible— por tanto no se ejecuta ninguna operación financiera. No hay cargos, no hay reembolsos. La orden se cancela en menos de 1 segundo. El costo financiero es cero. Esta arquitectura evita aproximadamente el 95% de reembolsos comparado con estrategias de "cobrar primero, verificar después".
El fallo tardío —la race condition— es donde el diseño realmente brilla. Cliente A solicita 1 unidad del producto SKU-789. Inventario actual: 1 unidad disponible. Cliente B también solicita 1 unidad del mismo producto simultáneamente. Ambos pasan la validación optimista porque en ese momento había 1 unidad disponible. Ambos son cobrados exitosamente por la pasarela de pagos. Pero en la fase de asignación, solo uno puede ganar.
Digamos que Cliente A llega primero a la fase de asignación. Inventario inicia transacción con SELECT FOR UPDATE, obtiene el lock, lee 1 unidad disponible, valida que 1 >= 1, ejecuta UPDATE restando la unidad, deja el inventario en 0, inserta StockAsignado, confirma. Cliente A recibe su orden completa. Segundos después, Cliente B llega a la fase de asignación. Inventario inicia otra transacción con SELECT FOR UPDATE, obtiene el lock (Cliente A ya liberó el lock al hacer COMMIT), pero ahora lee 0 unidades disponibles, valida que 0 < 1, la validación falla, no ejecuta el UPDATE, pero —esto es crucial— sí confirma la transacción para que el evento FalloAsignacion se publique correctamente.
Pagos consume FalloAsignacion para Cliente B, busca la transacción original que había aprobado, invoca la API de reembolso de la pasarela, recibe confirmación, inserta ReembolsoEjecutado. Pedidos consume este evento, actualiza estado a CANCELADA_CON_REEMBOLSO, y notifica a Cliente B que su pago ha sido reembolsado porque el producto se agotó durante el proceso. Cliente A tiene su orden completada, Cliente B tiene su dinero de vuelta automáticamente, el sistema mantuvo consistencia a pesar de la concurrencia, y crucialmente, no hubo sobreventa (overselling).
Este escenario demuestra por qué la verificación en fase 2 es optimista (sin bloqueo) y la asignación en fase 4 es pesimista (con bloqueo). Si bloqueáramos en la fase de validación, tendríamos alta contención —cada consulta de disponibilidad bloquearía las filas, forzando a otras transacciones a esperar. El bloqueo tardío minimiza la ventana crítica a solo el momento de la asignación definitiva.
Consideraciones Finales de Implementación
El ordenamiento de eventos merece atención especial. Los brokers garantizan orden dentro de una partición, no globalmente. La solución es particionar eventos por Saga ID, asegurando que todos los eventos de una misma saga vayan a la misma partición. Configuramos la clave de partición como el Saga ID. Esto garantiza que eventos de una orden específica se procesen en orden correcto, aunque no importa el orden entre órdenes diferentes —cada orden es independiente.
El manejo de duplicados es un ritual bien definido. Cuando llega un evento PagoExitoso al servicio de Inventario, extraemos el Message ID del header, iniciamos una transacción, e intentamos insertar ese ID en la tabla processed_messages. Si la inserción falla por clave duplicada, sabemos que este mensaje ya fue procesado: hacemos rollback, retornamos ACK al broker sin hacer nada más, y registramos en logs "Evento duplicado ignorado". Si la inserción es exitosa, procedemos con la lógica de asignación de stock, y el COMMIT incluye tanto la nueva fila en processed_messages como el UPDATE de inventario. Ambos cambios o ninguno —atomicidad local garantizada.
La consistencia de OUTBOX es una garantía crítica inviolable: el evento en OUTBOX y el cambio de estado de negocio deben confirmarse en la misma transacción atómica de base de datos. Por ejemplo, cuando Pedidos recibe StockVerificado, en una sola transacción ejecuta UPDATE de la orden cambiando estado a PENDIENTE_PAGO e INSERT en OUTBOX del evento OrdenActualizada, seguido de COMMIT. Si el COMMIT falla, ningún cambio se persiste. Si se confirma, ambos cambios quedan guardados atómicamente. Esta es la base de toda la confiabilidad del sistema.
Los timeouts deben configurarse pensando en las características reales de cada operación. Para Pedidos: timeout de validación de stock de 30 segundos (consultas de base de datos son rápidas), timeout de pago de 60 segundos (APIs bancarias pueden ser lentas), timeout de asignación de 15 segundos (es un UPDATE simple), y timeout total de saga de 2 horas (permitiendo delays en procesamiento de eventos). Para Inventario: timeout de consulta de base de datos de 5 segundos, timeout de bloqueo pesimista de 10 segundos. Para Pagos: timeout de llamada a pasarela de 30 segundos, 3 reintentos con backoff de 2-8-18 segundos, y timeout de reembolso de 60 segundos.
Reflexiones sobre la Consistencia Eventual
Implementar el patrón Saga es, en esencia, aceptar la naturaleza distribuida de la realidad. No estamos simulando un sistema monolítico con trucos de coordinación; estamos abrazando honestamente que nuestros servicios son entidades autónomas que colaboran a través de eventos. La consistencia eventual no es una limitación que debemos lamentar, sino una propiedad emergente que podemos diseñar deliberadamente.
Los desafíos son reales: flujos implícitos, depuración compleja, compensaciones que requieren pensamiento cuidadoso, race conditions que debemos anticipar, y la necesidad constante de idempotencia. Pero las recompensas también son sustanciales: servicios verdaderamente desacoplados que pueden evolucionar independientemente, escalabilidad horizontal sin límites artificiales, resiliencia ante fallos parciales, y la capacidad de razonar sobre procesos de negocio complejos mediante eventos de dominio.
La clave está en la disciplina. El patrón Transactional Outbox elimina el dual write problem. La deduplicación sistemática maneja mensajes duplicados. Las compensaciones bien diseñadas permiten deshacer operaciones. Los reintentos inteligentes distinguen entre fallos transitorios y permanentes. El versionado cuidadoso permite evolución sin ruptura. La observabilidad exhaustiva ilumina lo que de otra manera sería opaco.
Cada uno de estos elementos es un pilar que sostiene el edificio completo. Eliminar cualquiera de ellos compromete la integridad estructural. Pero implementados en conjunto, con la atención al detalle que merece cada uno, nos permiten construir sistemas distribuidos que no solo funcionan sino que son comprensibles, mantenibles y confiables a largo plazo.
Al final, el patrón Saga nos enseña una lección más amplia sobre la arquitectura de software: los sistemas complejos emergen de la composición de partes simples que interactúan mediante protocolos bien definidos. No necesitamos coordinación central omnisciente. No necesitamos transacciones globales mágicas. Necesitamos servicios que comprendan sus responsabilidades, eventos que comuniquen intenciones claras, y mecanismos de compensación que permitan corrección de errores. Con estos ingredientes, la consistencia eventual emerge naturalmente, como un patrón que se forma en la arena cuando las olas retroceden.
Esta es la belleza del diseño distribuido: aceptamos las limitaciones fundamentales de la física —la información viaja a velocidad finita, los sistemas fallan parcialmente, el tiempo no es absoluto— y construimos abstracciones que funcionan dentro de estas limitaciones en lugar de pretender superarlas. El patrón Saga es nuestra forma de bailar con la entropía en lugar de luchar contra ella.
Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)
- Mauricio ECR
- Arquitectura
- 01 Feb, 2026
Cuando se habla de seguridad en el contexto de una aplicación, la conversación casi siempre gira en torno a las capas visibles: el cifrado en tránsito, las políticas de contraseñas, los tokens de aute
Arquitectura de Base de Datos para Identidad, Autenticación y Autorización (IAM)
- Mauricio ECR
- Arquitectura
- 01 Feb, 2026
Cuando se habla de seguridad en el contexto de una aplicación, la conversación casi siempre gira en torno a las capas visibles: el cifrado en tránsito, las políticas de contraseñas, los tokens de autenticación. Son piezas visibles e importantes, pero debajo de todas ellas existe una estructura que determina si un sistema IAM puede sostenerse en producción o si eventualmente colapsará bajo su propia complejidad. Esa estructura es el modelo de datos.
Diseñar una base de datos para gestionar identidad, autenticación y autorización no es simplemente crear una tabla de usuarios con nombre, email y contraseña. Es resolver, desde el nivel más fundamental, preguntas como: ¿quién es una entidad dentro del sistema? ¿cómo demuestra que es quien dice ser? ¿qué puede hacer? ¿a qué organización pertenece? Y hacerlo de una forma que no requiera rediseños costosos cuando la aplicación crezca de diez usuarios a diez millones.
Este artículo explora un modelo de datos completo para IAM, desde sus principios arquitectónicos más fundamentales hasta las decisiones de implementación que determinan si el sistema puede escalar, cumplir requisitos de seguridad en producción y adaptarse a entornos enterprise sin romperse en el proceso.
La separación entre identidad y autenticación
El punto de partida de todo el modelo es una decisión que parece obvia pero que la mayoría de las implementaciones no respetan: la identidad de una entidad y el mecanismo por el cual se autentica son dos cosas completamente distintas.
En la práctica, esto se traduce en separar el concepto de actor del concepto de cuenta de acceso. Un actor es cualquier entidad que existe en el sistema: una persona, una empresa cliente, un bot interno, un proveedor externo. Una cuenta de acceso es el mecanismo que permite a ese actor demostrarlo cuando lo necesita. Y la clave es que no todos los actores necesitan una cuenta.
Piense en el escenario de una plataforma SaaS donde un usuario registra una empresa como cliente. En ese momento la empresa existe como entidad en el sistema, tiene datos de contacto y puede ser referenciada desde otros registros. Pero quizás nadie en esa empresa necesita ingresar al sistema aún. Si el modelo obligara a crear una cuenta de autenticación cada vez que se crea una entidad, ese escenario sería imposible sin truismos como usuarios ficticios o campos nulos que van acumulando deuda técnica.
La tabla actor en el modelo es deliberadamente mínima: un identificador único, un tipo de entidad y un nombre. Todo lo demás se construye a partir de ahí.
CREATE TABLE actor (
actor_id UUIDv7 PRIMARY KEY,
tipo_actor UUIDv7 NOT NULL REFERENCES actor_tipo(tipo_id),
nombre VARCHAR(256) NOT NULL,
eliminado_en TIMESTAMP NULL
);
El campo eliminado_en merece una mención especial porque representa otra decisión fundamental: estas entidades nunca se eliminan físicamente en producción. En lugar de borrar un registro, se marca con una fecha lógica de eliminación. La razón es pragmática: la tabla de auditoría va a referenciar este actor durante años. Si el registro desapareciera, esa referencia estaría rota, y con ella cualquier intento de reconstruir qué sucedió y cuándo. Además, regulaciones como el GDPR exigen retención de datos por períodos definidos, lo cual es imposible si ya no existen.
Los datos específicos de cada tipo de actor se almacenan por separado, en tablas que se relacionan al actor mediante una clave foránea que es simultáneamente clave primaria. Así, la información de una persona natural —nombre, apellidos— vive en actor_persona, los documentos de identidad en actor_documento, y los contactos en tablas propias para correos, teléfonos y direcciones. Cada uno de estos, además, soporta múltiples valores con un campo de contexto que distingue entre un correo personal, uno laboral o uno de facturación, sin que la aplicación tenga que adivinar cuál usar según la circunstancia.
Por qué UUIDv7 y no otro identificador
Una decisión que atraviesa todo el modelo es el uso de UUIDv7 como identificador primario en todas las tablas principales. Es una elección que tiene consecuencias concretas en rendimiento y operación, no solo en diseño.
El problema con usar valores como el email o el número de documento como clave es simple: cambian. Un usuario puede cambiar su correo electrónico mañana. Si ese email era la clave que todos los otros registros usaban para referirlo, cada uno de ellos necesitaría actualizarse en cascada, con el riesgo de inconsistencias y la costosa operación de actualizar claves foráneas en decenas de tablas.
UUIDv4, el estándar más común, resuelve eso: genera identificadores únicos que no cambian. Pero UUIDv7 va un paso más allá. Sus primeros 48 bits contienen un timestamp del momento de creación. Eso tiene dos efectos inmediatos en la base de datos.
El primero es de rendimiento. Los índices B-tree, que son la estructura por defecto en la gran mayoría de bases de datos relacional, se mantienen ordenados por el valor de la clave. Con UUIDv7, ese orden es automáticamente cronológico. En una tabla como la de auditoría, que puede crecer a millones de filas por día, las consultas que buscan los eventos de las últimas 24 horas no necesitan un sort adicional: los datos ya están en ese orden físicamente.
El segundo es de operación diaria. Cuando un desarrollador ve un ID en un log de producción a las 3 de la mañana investigando un incidente, con UUIDv7 puede derivar aproximadamente cuándo fue creado ese registro sin tener que ir a la base de datos. Es un detalle pequeño, pero en las situaciones de mayor presión esos detalles marcan la diferencia entre resolver un problema en minutos o en horas.
La cuenta y sus credenciales
La cuenta de acceso (cuenta_acceso) es el puente entre un actor y el sistema de autenticación. Se crea únicamente cuando el actor necesita autenticarse, y contiene los campos que el sistema necesita para controlar el acceso en tiempo real: el estado de la cuenta, un contador de intentos fallidos consecutivos y una fecha de bloqueo automático.
CREATE TABLE cuenta_acceso (
cuenta_id UUIDv7 PRIMARY KEY,
actor_id UUIDv7 NOT NULL REFERENCES actor(actor_id),
estado VARCHAR(20) NOT NULL DEFAULT 'activa',
intentos_fallidos_consecutivos INT NOT NULL DEFAULT 0,
bloqueada_hasta TIMESTAMP NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
eliminado_en TIMESTAMP NULL
);
El diseño de los campos de lockout en esta tabla no es accidental. El contador intentos_fallidos_consecutivos se incrementa con cada fallo y se reinicia al cero en cada login exitoso. Cuando supera el umbral configurado por la organización —por defecto cinco intentos— el sistema calcula una fecha de bloqueo y la escribe en bloqueada_hasta. Desde ese momento, cualquier intento de login contra esa cuenta falla automáticamente hasta que la fecha pase. Este mecanismo es necesario para defender contra ataques de fuerza bruta, pero el umbral y la duración del bloqueo no están hardcoded: cada organización puede configurarlos de forma independiente según sus requisitos de seguridad.
Las credenciales reales viven en una tabla separada, cuenta_credencial, y aquí el modelo hace otra cosa interesante. Una sola cuenta puede tener varias credenciales simultáneamente. Un usuario puede entrar con contraseña local, conectar su cuenta de Google y usar SAML corporativo, todo bajo la misma cuenta. Cada credencial tiene un campo proveedor que indica de dónde viene —LOCAL, GOOGLE, SAML— y el sistema sabe cómo procesar cada una según ese valor.
CREATE TABLE cuenta_credencial (
credencial_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
proveedor VARCHAR(40) NOT NULL,
identificador VARCHAR(320) NOT NULL,
secreto_hash VARCHAR(256) NULL,
algoritmo_hash VARCHAR(40) NULL,
parametros_hash JSONB NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (cuenta_id, proveedor, identificador)
);
Para las credenciales locales, la contraseña se almacena como hash —nunca en texto plano— y junto a ella se guardan dos campos que habitualmente se olvidan: algoritmo_hash y parametros_hash. La razón de su existencia tiene que ver con un problema real que aparece cuando una aplicación madura. En el momento en que el sistema decide migrar de bcrypt a Argon2id —algo que eventualmente todo sistema serio debe hacer— no es posible invalidar todas las contraseñas de la base de datos de una vez. La solución es lo que se conoce como migración lazy: cuando un usuario hace login, si su hash fue generado con un algoritmo antiguo, el sistema lo regenera con el algoritmo actual sin pedir que el usuario cambie su contraseña. Para que eso funcione, el sistema necesita saber exactamente qué algoritmo y qué parámetros usó para generar cada hash. De ahí vienen esos dos campos.
Sesiones, tokens y el problema de la revocación
Una vez que un usuario se autentica exitosamente, el sistema crea una sesión. La sesión es el registro que representa una instancia activa de uso desde un dispositivo específico, y es el lugar donde vive una de las decisiones más importantes del modelo desde el punto de vista de seguridad.
CREATE TABLE cuenta_sesion (
sesion_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
version BIGINT NOT NULL DEFAULT 1,
dispositivo_id UUIDv7 NULL REFERENCES cuenta_dispositivo(dispositivo_id),
creada_en TIMESTAMP NOT NULL DEFAULT NOW(),
expira_en TIMESTAMP NOT NULL,
ip INET NOT NULL,
user_agent TEXT NOT NULL
);
El campo version es el que hace la diferencia. En un sistema típico que usa JWT —JSON Web Tokens— como mecanismo de autenticación por token, cada access token tiene una duración fija, por ejemplo quince minutos. Si un token es robado, el sistema no puede invalidarlo antes de que llegue a expirar: el token es autosuficiente por diseño. Durante esos quince minutos, el token robado es completamente válido. En una cuenta que ha sido comprometida, quince minutos pueden ser más que suficientes para causas daños significativos.
La solución que implementa este modelo es elegante en su simplicidad. Cada access token emitido incluye la versión actual de la sesión en su payload. Cuando el usuario realiza una acción que debe invalidar sus tokens —cambiar contraseña, habilitar MFA, cerrar sesión remota— el sistema simplemente incrementa la versión en la base de datos. El middleware de autenticación compara la versión del token con la versión almacenada. Si no coinciden, el token es rechazado inmediatamente, en milisegundos, sin esperar a que expiré.
Los refresh tokens operan en un esquema complementario. Son de duración larga —hasta treinta días— y su propósito es permitir obtener nuevos access tokens sin que el usuario vuelva a ingresar sus credenciales. La tabla cuenta_refresh_token incluye un campo que vale la pena destacar: reemplazado_por. Cuando un refresh token se rota —lo cual debe ocurrir cada vez que se genera un nuevo access token— el antiguo apunta al nuevo mediante ese campo. Esto crea una cadena auditable, pero más importante, permite detectar ataques. Si alguien roba un refresh token y lo usa después de que ya fue rotado, el sistema detecta que el token de la cadena ya fue reemplazado y puede revocar toda la sesión.
Protección contra ataques y el arte de no revelar demasiado
El control de intentos de login es donde la seguridad se enfrenta directamente con la experiencia del usuario, y donde las decisiones de diseño en la base de datos tienen consecuencias de seguridad reales.
CREATE TABLE cuenta_intento_login (
intento_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NULL REFERENCES cuenta_acceso(cuenta_id),
identificador VARCHAR(320) NOT NULL,
exito BOOLEAN NOT NULL,
ip INET NOT NULL,
user_agent TEXT NOT NULL,
motivo_fallo VARCHAR(64) NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
El detalle más sutil de esta tabla es que cuenta_id es nulable. Cuando alguien intenta hacer login con un email que no existe en el sistema, el registro se crea con cuenta_id = NULL. La razón tiene que ver con un ataque conocido como enumeración de usuarios: si el sistema respondiera de forma diferente ante un email no registrado versus una contraseña incorrecta, un atacante podría ir probando emails hasta confirmar cuáles están registrados. Al registrar todos los intentos de forma uniforme, independientemente de si la cuenta existe o no, y al no distinguir entre tipos de error en la respuesta al cliente, el sistema cierra esa puerta.
El campo motivo_fallo almacena la información detallada del fallo, pero esta información es para uso interno exclusivamente. El sistema nunca la retorna al cliente: desde afuera, un fallo es simplemente un fallo, sin distingos. Es un patrón que aparece repetido en varios lugares del modelo, esta idea de que hay información que el sistema necesita almacenar para su propia operación pero que no debe revelar hacia afuera.
El lockout funciona en dos niveles independientes. El primero es por cuenta: cuando los intentos fallidos consecutivos supera el umbral, la cuenta se bloquea por un período configurable. El segundo es por IP: si una misma dirección IP genera demasiados intentos fallidos contra cuentas diferentes en un período corto, se activa rate limiting a nivel de red. Ese segundo nivel es el que detecta credential stuffing, uno de los ataques más comunes hoy.
Políticas de contraseña como modelo de datos
Las políticas de contraseñas en la mayoría de las aplicaciones se implementan como constantes en el código: mínimo ocho caracteres, debe tener mayúscula, debe tener número. El problema con ese enfoque es que es imposible auditorlo, imposible que cada organización tenga requisitos diferentes, y requiere un deploy cada vez que cambie una regla.
En este modelo las políticas son una tabla en sí misma, con un campo tenant_id nulable que determina su alcance. Si es NULL, es la política global que aplica a todos; si tiene valor, es específica de esa organización y tiene prioridad.
CREATE TABLE politica_password (
politica_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
min_longitud INT NOT NULL DEFAULT 12,
max_longitud INT NOT NULL DEFAULT 128,
requiere_mayuscula BOOLEAN NOT NULL DEFAULT TRUE,
requiere_minuscula BOOLEAN NOT NULL DEFAULT TRUE,
requiere_numeros BOOLEAN NOT NULL DEFAULT TRUE,
requiere_especiales BOOLEAN NOT NULL DEFAULT TRUE,
max_edad_dias INT NULL,
historial_prohibido INT NOT NULL DEFAULT 5,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
El campo historial_prohibido merece explicación porque implica la existencia de otra tabla: cuenta_historial_password. Cuando un usuario cambia su contraseña, el sistema necesita verificar que la nueva no coincida con las últimas N que tuvo. Para poder hacer esa comparación, los hashes de las contraseñas anteriores deben estar almacenados en algún lugar. En esa tabla, junto con cada hash antiguo se guardan también el algoritmo y los parámetros que fueron usados para generarlo, por la misma razón de compatibilidad que ya mencionamos: el algoritmo puede haber cambiado entre cuando se creó ese hash y el momento en que se necesita compararlo.
El límite máximo de longitud, que a primera vista parece arbitrario, tiene una razón de seguridad: una contraseña extremadamente larga puede usarse para un ataque de denegación de servicio, ya que calcular el hash de una cadena de miles de caracteres consume recursos significativos.
Multi-tenancy: aislamiento sin multiplicar la base de datos
Cuando una aplicación necesita servir a múltiples organizaciones independientes, la tentación es crear una base de datos separada por cada una. Es la solución más aislada, pero también la más costosa en operación: N bases de datos significan N planes de backup, N procesos de monitoreo, N niveles de mantenimiento.
El modelo propone la alternativa estándar en la industria: una sola base de datos compartida donde cada organización —llamada tenant— tiene sus datos lógicamente aislados mediante una clave tenant_id que atraviesa todas las tablas relevantes.
CREATE TABLE tenant (
tenant_id UUIDv7 PRIMARY KEY,
nombre VARCHAR(256) NOT NULL,
estado VARCHAR(20) NOT NULL DEFAULT 'activo',
plan VARCHAR(64) NULL,
configuracion JSONB NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
eliminado_en TIMESTAMP NULL
);
Un actor puede ser miembro de múltiples tenants simultáneamente, lo cual refleja la realidad de consultores o profesionales que trabajan con varias empresas. La membresía se gestiona en tenant_miembro, y las invitaciones en tenant_invitacion, donde tanto el email del invitado como el token de validación se almacenan como hash. Esto evita que alguien con acceso directo a la base de datos pueda enumerar qué emails tienen invitaciones pendientes en cada organización, un vector de ataque que suena teórico hasta que alguien lo explota.
Cada organización puede además tener sus propios requisitos de seguridad: cuántos intentos fallidos se permiten antes del lockout, si el MFA es obligatorio, qué métodos de MFA están permitidos, cuál es la política de contraseñas. Todas estas configuraciones se almacenan en tablas de configuración por tenant, lo cual significa que una organización enterprise puede exigir FIDO2 como único método de MFA mientras otra más pequeña se conforma con TOTP, sin que eso afecte al resto del sistema.
Autorización por roles y grupos
El módulo de autorización implementa RBAC, Role-Based Access Control, con una extensión importante: soporte para grupos organizacionales. La estructura básica es la clásica: roles que contienen permisos, y usuarios que tienen roles asignados. Pero la realidad de las organizaciones grandes no funciona así de simple.
En una empresa real, los permisos no se asignan uno por uno a cada persona. Se asignan por departamento, por unidad organizacional. Cuando un nuevo empleado entra al área de finanzas, debería recibir automáticamente los permisos que corresponden a esa función, sin que un administrador los configure manualmente uno por uno.
Para resolver eso, el modelo incluye una capa de grupos dentro de cada tenant. Un grupo como "Finanzas" tiene asignado el rol CONTADOR. Cuando alguien se agrega al grupo, hereda automáticamente todos los permisos de ese rol. Si además necesita algo extra —un permiso que no aplica a todo el grupo— se le asigna un rol adicional a nivel individual mediante la tabla miembro_rol. Las dos vías coexisten sin conflicto.
CREATE TABLE tenant_grupo (
grupo_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NOT NULL REFERENCES tenant(tenant_id),
nombre VARCHAR(128) NOT NULL,
descripcion VARCHAR(256) NULL,
UNIQUE (tenant_id, nombre)
);
La restricción UNIQUE (tenant_id, nombre) es un detalle que evita un error de diseño frecuente: que dos grupos con el mismo nombre existan en la misma organización, lo cual generaría confusión tanto en la interfaz administrativa como en la lógica de asignación.
Delegación temporal y dispositivos confiables
Hay dos escenarios frecuentes en organizaciones que RBAC por sí solo no resuelve. El primero es la delegación temporal: un manager que se va de vacaciones y necesita que alguien apruebe en su lugar durante dos semanas. El segundo es la experiencia del usuario en sistemas con MFA: si ya completaste la verificación en tu laptop esta mañana, no deberías tener que hacerlo de nuevo cada quince minutos.
La delegación se implementa mediante delegacion_permiso, una tabla que tiene una fecha de inicio y una fecha de fin obligatoria. No existen delegaciones perpetuas en el modelo, y es una decisión consciente: cualquier delegación sin límite temporal es un riesgo de seguridad que eventualmente alguien olvidará revocar. La delegación puede ser un rol completo —"durante mis vacaciones, esta persona actúa como aprobador"— o permisos individuales —"solo necesita firmar esta factura específica".
Los dispositivos confiables funcionan con un concepto de fingerprint: una combinación de browser, sistema operativo y otros atributos del dispositivo, almacenada como hash. Cuando un usuario completa MFA exitosamente en un dispositivo y acepta marcarlo como confiable, el sistema crea un registro con una fecha de expiración de la confianza —configurada por tenant. En logins futuros desde ese mismo dispositivo, si la confianza aún no ha expirado, el paso de MFA se omite automáticamente.
CREATE TABLE cuenta_dispositivo (
dispositivo_id UUIDv7 PRIMARY KEY,
cuenta_id UUIDv7 NOT NULL REFERENCES cuenta_acceso(cuenta_id),
fingerprint VARCHAR(256) NOT NULL,
nombre VARCHAR(128) NULL,
confiable BOOLEAN NOT NULL DEFAULT FALSE,
confiable_hasta TIMESTAMP NULL,
creado_en TIMESTAMP NOT NULL DEFAULT NOW(),
ultimo_uso_en TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (cuenta_id, fingerprint)
);
El campo ultimo_uso_en tiene un propósito de seguridad que no es inmediatamente obvio: permite mostrar al usuario cuándo fue usado cada dispositivo registrado, lo cual es la mecanismo por el cual un usuario puede detectar que alguien más está usando su cuenta desde un dispositivo que no reconoce.
Extensiones enterprise: SSO, SCIM y ABAC
Cuando una aplicación necesita integrarse con el ecosistema de identidad corporativo existente —Active Directory, Okta, Azure AD— el modelo incluye las tablas necesarias para eso sin rediseñar lo que ya existe.
SSO se implementa mediante cuenta_identidad_externa, que vincula una cuenta del sistema con un identificador externo proporcionado por el proveedor de identidad corporativo. SCIM, el estándar de provisioning automático, agrega otra dimensión: cuando un empleado se crea o elimina en el directorio corporativo, los cambios se reflejan automáticamente en el sistema. La tabla scim_provisioning registra el estado de cada usuario sincronizado y sus metadatos, los cuales pueden ser usados por las políticas ABAC.
ABAC —Attribute-Based Access Control— es la extensión más potente del sistema de autorización. En lugar de depender únicamente de roles estáticos, permite definir políticas basadas en atributos dinámicos tanto del usuario como del recurso.
CREATE TABLE politica_abac (
politica_id UUIDv7 PRIMARY KEY,
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
nombre VARCHAR(128) NOT NULL,
expresion TEXT NOT NULL,
efecto VARCHAR(10) NOT NULL,
activa BOOLEAN NOT NULL DEFAULT TRUE,
creado_en TIMESTAMP NOT NULL DEFAULT NOW()
);
Una política ABAC puede expresar reglas como "permitir el acceso si el departamento del usuario coincide con el departamento del recurso y el nivel del usuario es mayor o igual a 2". El campo expresion debe estar en un lenguaje controlado evaluado por un motor dedicado —como OPA con Rego— y no como texto libre, precisamente para evitar que esas expresiones sean vectores de inyección y para poder testarlas de forma aislada antes de ponerlas en producción.
Auditoría: el registro que no puede faltar
La tabla de auditoría es el componente que conecta todo el modelo con los requisitos de compliance. Cada evento de seguridad que ocurre en el sistema —login, logout, cambio de contraseña, creación de usuario, modificación de roles— se registra aquí con el actor que realizó la acción, el actor afectado si es diferente, el tenant en cuyo contexto ocurrió, y metadatos adicionales.
CREATE TABLE auditoria_seguridad (
evento_id UUIDv7 PRIMARY KEY,
actor_id UUIDv7 NOT NULL REFERENCES actor(actor_id),
actor_afectado_id UUIDv7 NULL REFERENCES actor(actor_id),
tenant_id UUIDv7 NULL REFERENCES tenant(tenant_id),
accion VARCHAR(64) NOT NULL,
objeto_tipo VARCHAR(64) NULL,
objeto_id VARCHAR(256) NULL,
fecha TIMESTAMP NOT NULL DEFAULT NOW(),
ip INET NULL,
user_agent TEXT NULL,
metadata JSONB NULL
) PARTITION BY RANGE (fecha);
El detalle más importante desde el punto de vista de operación es la última línea: PARTITION BY RANGE (fecha). Esta tabla puede crecer a millones de filas por día en un sistema de actividad moderada. Sin partitioning, las consultas de auditoría se convierten en sequential scans que se vuelven más lentas semana tras semana hasta que el sistema necesita una intervención de emergencia. Con partitioning por mes, cada consulta solo escanea la partición relevante. Las particiones antiguas —más de doce meses— se archivan a almacenamiento frío y se eliminan de la base activa, manteniendo el tamaño operacional manejable.
Cifrado y clasificación de datos
No todos los datos requieren el mismo nivel de protección, y el modelo reconoce eso con una clasificación en cuatro niveles. Los datos críticos como hashes de contraseñas y tokens nunca se almacenan en texto plano. Los datos de identidad personal —email, teléfono, documento— se cifran en reposo a nivel de columna mediante cifrado a nivel de aplicación con AES-256-GCM, con las claves de cifrado almacenadas en un KMS externo, no en la base de datos.
Para campos PII que necesitan ser buscables, como el email, el modelo propone almacenar junto al valor cifrado un hash determinístico separado. El hash permite hacer búsquedas —"¿existe un usuario con este email?"— sin que la base de datos contenga el email en texto plano. Es un patrón que aparece también en las invitaciones y en los tokens de refresh.
Índices estratégicos
Los índices son donde el modelo pasa de ser un diseño teórico a ser un sistema que puede operar en producción. Las queries más frecuentes en un sistema IAM son predecibles: login, verificación de permisos, búsqueda de sesiones activas, consultas de auditoría. Sin los índices correctos para cada una de estas operaciones, cada request adicional de un usuario se convierte en un scan secuencial que crece linealmente con el tamaño de la tabla.
-- Login: el camino crítico de cada autenticación
CREATE UNIQUE INDEX idx_credencial_proveedor_identificador
ON cuenta_credencial (proveedor, identificador);
-- Sesiones: buscar y limpiar sesiones expiradas
CREATE INDEX idx_sesion_cuenta_expira
ON cuenta_sesion (cuenta_id, expira_en);
-- Intentos: detectar ataques por IP (los más recientes primero)
CREATE INDEX idx_intento_ip_fecha
ON cuenta_intento_login (ip, creado_en DESC);
-- Auditoría: queries de compliance por actor
CREATE INDEX idx_auditoria_actor_fecha
ON auditoria_seguridad (actor_id, fecha DESC);
-- Auditoría: queries de compliance por tenant
CREATE INDEX idx_auditoria_tenant_fecha
ON auditoria_seguridad (tenant_id, fecha DESC);
El orden de las columnas en los índices compuestos no es arbitrario. En un índice como (tenant_id, actor_id), la primera columna debe ser la que aparece más frecuentemente en las condiciones de búsqueda. Como las queries siempre filtran por organización antes de filtrar por usuario, tenant_id va primero. Los índices DESC en columnas de fecha evitan que la base de datos necesite ordenar los resultados después de buscarlos cuando la query busca los últimos N registros.
Crecimiento progresivo sin rediseños
Una de las frustraciones más comunes al diseñar sistemas IAM es que la arquitectura inicial no anticipa la complejidad futura y eventualmente requiere rediseños que costo tiempo y dinero. Este modelo resuelve esa fricción con una estratificación por niveles de implementación.
El nivel más básico —login con email y contraseña— requiere apenas las tablas de actor, cuenta, credencial, intentos y política de contraseñas. Es suficiente para una aplicación interna pequeña. A partir de ahí, cada nivel agrega las tablas correspondientes sin modificar las existentes: sesiones y MFA para aplicaciones modernas, roles para paneles administrativos, tenants y grupos para plataformas SaaS, y finalmente SSO, SCIM, ABAC y OAuth para entornos enterprise.
Esta progresión no es solo teórica. Cada tabla en el modelo fue diseñada con las relaciones futuras en mente, de modo que agregar el nivel siguiente es una operación aditiva, no una refactorización. La clave que habilita eso es la separación original entre actor y cuenta, y el uso de identificadores inmutables que no cambian independientemente de cuántas tablas se agreguen después.
Líneas futuras y consideraciones de madurez
El modelo que se describe aquí cubre las necesidades de la gran mayoría de aplicaciones desde un login básico hasta un sistema enterprise. Sin embargo, existen áreas donde la complejidad puede crecer más allá de lo que un modelo relacional estándar puede manejar eficientemente.
La primera es la verificación de permisos en tiempo real en sistemas de alta concurrencia. Cuando la cantidad de roles, grupos y políticas ABAC crecen significativamente, la consulta que determina si un actor puede realizar una acción puede volverse costosa. Una línea de investigación viable es implementar una capa de caché —Redis o similar— que almacene las resoluciones de permisos por actor y se invalide cuando ocurren cambios en roles o políticas. La tabla de auditoría puede ser el trigger para esa invalidación.
La segunda área es la escalabilidad horizontal de la tabla de auditoría. Aunque el partitioning por fecha resuelve el problema de crecimiento a mediano plazo, en sistemas con millones de eventos diarios puede ser necesario considerar la migración de datos históricos a un almacenamiento analítico separado —un data warehouse o un sistema de búsqueda como Elasticsearch— mientras que la base relacional retiene solo los datos activos necesarios para la operación.
Finalmente, la evolución hacia modelos de identidad decentralizada —DID, credenciales verificables— es una dirección que la industria está explorando activamente. El modelo actual, al separar la identidad del mecanismo de autenticación desde el principio, tiene la estructura necesaria para adaptarse a esos cambios sin rediseño fundamental. Es otra prueba de que las decisiones arquitectónicas correctas no solo resuelven los problemas de hoy, sino que reducen la fricción de los cambios que vienen después.