Tags (73)
- Agile
- Alta disponibilidad
- Alternativas cloud
- Aop
- Arquitectura
- Arquitectura distribuida
- Automatizacion
- Aws
- 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
- Snippets
- Spring boot
- Spring mvc
- Sql
- Streams
- Threadlocal
- Trazabilidad
- Versionado
- Web
- Webflux
- Websockets
- Zero trust
El Reintento que Sabe Esperar: Patrones de Resiliencia para Arquitecturas de Microservicios
- Mauricio ECR
- Arquitectura
- 03 Oct, 2026
Has estado ahí. Tu microservicio recibe la respuesta de un servicio externo y no es un error técnico: no hay stack trace, no hay excepción que capturar, no hay un HTTP 500. Es algo más sutil, un est
El Reintento que Sabe Esperar: Patrones de Resiliencia para Arquitecturas de Microservicios
- Mauricio ECR
- Arquitectura
- 03 Oct, 2026
Has estado ahí. Tu microservicio recibe la respuesta de un servicio externo y no es un error técnico: no hay stack trace, no hay excepción que capturar, no hay un HTTP 500. Es algo más sutil, un estado de negocio que dice ahora no. CANCELADO. NO DISPONIBLE. El servicio está vivo, responde, simplemente no puede atender tu solicitud en este momento.
Poco después llega la instrucción del negocio: inténtalo más tarde. Y ahí empieza el problema real.
«Más tarde» suena razonable hasta que se traduce a un sistema distribuido. ¿Cuánto es más tarde? ¿Quién guarda esa intención mientras transcurre? ¿Qué ocurre si el proceso que debía esperar se reinicia a mitad de camino, o si el mismo mensaje se entrega dos veces y el reintento se duplica? ¿Y si llegan mil respuestas CANCELADO en el mismo minuto y todas deben esperar a la vez sin que nadie gaste recursos esperando?
Lo que parecía un detalle de implementación es un problema de diseño con nombre, con componentes y con varias formas de salir mal si se improvisa. Este artículo recorre ese diseño completo: por qué la espera debe ser un dato y no un proceso, qué piezas la sostienen, cómo se evita que un fallo en cualquier punto produzca duplicados o transacciones atascadas, y cómo se concreta todo en AWS, sin olvidar que el patrón es más importante que cualquier servicio.
Por qué no basta con dormir el proceso
El primer instinto suele ser retener el proceso. Un Thread.sleep, un hilo bloqueado, o un mensaje que no se confirma para que el broker lo reentregue pasado un rato. Es tentador porque funciona en local, con poco volumen y en las demostraciones donde los casos borde son teóricos.
El costo, sin embargo, crece en silencio: un proceso que espera sigue siendo un proceso. Con diez esperas simultáneas el impacto es marginal. Con mil, cifra que cualquier sistema bajo carga real alcanza sin esfuerzo, la infraestructura paga por tiempo en el que no trabaja. Escalar horizontalmente no ayuda, porque solo multiplica los procesos igualmente bloqueados.
La raíz del problema es conceptual. Se está modelando la espera como una actividad, cuando en realidad es un estado. Un proceso dormido consume memoria, hilos y conexiones, mientras que un registro en una base de datos no consume nada mientras espera: existe, y el sistema lo consulta cuando necesita saber qué hacer.
De esa distinción nace el patrón de reintento diferido duradero. Consiste en convertir la intención de «volver a intentarlo en el instante T» en información persistente, en lugar de mantenerla como tiempo de ejecución.
Tres preguntas, tres responsabilidades
Para que la espera viva en el sistema sin ocupar procesos hay que responder tres preguntas distintas, y conviene que cada una tenga un dueño distinto. La primera es cuándo debe ocurrir el reintento: alguien tiene que conservar la intención «actúa en este instante» y cumplirla aunque pasen horas. La segunda es si el reintento todavía tiene sentido cuando el momento llegue, porque la transacción pudo resolverse por otra vía mientras esperaba. La tercera es cómo garantizar que el reintento se ejecute una sola vez, aunque el sistema falle en el medio.
El diseño reparte esas responsabilidades entre unas pocas piezas. Tres colas desacoplan a los participantes: una recibe las respuestas del servicio externo, otra entrega los eventos de reintento cuando el planificador los dispara, y una tercera lleva las solicitudes hacia el servicio externo. Un almacén de estado funciona como fuente de verdad: guarda el estado de cada transacción, el intento en curso y el instante previsto del próximo disparo, y solo acepta cambios condicionales. Un planificador diferido conserva la intención de «entregar este evento en el instante T» sin mantener ningún proceso ocupado. Dos manejadores ejecutan la lógica de negocio: el manejador de respuestas (lo llamaré A) interpreta lo que dice el servicio externo y decide si hay que planificar un reintento, y el manejador de reintentos (B) recibe el evento del planificador, comprueba que siga siendo válido y publica la nueva solicitud. Por último, las colas de mensajes fallidos (DLQ, por dead-letter queue) aíslan lo que no se pudo procesar o entregar y levantan alertas.
Lo que hace correcto al conjunto no es la sofisticación de cada pieza, sino que cada una resuelve exactamente una cosa y desconoce las demás. El planificador no sabe nada de negocio, el almacén no sabe cuándo disparar y los manejadores no se conocen entre sí. Esa ignorancia mutua permite que el sistema se recupere de un fallo sin que ninguna pieza tenga que coordinarse con otra.
Hay además una decisión que protege el modelo existente: el planificador nunca habla directamente con el servicio externo. Publica en la cola de reintento y es B quien decide qué hacer con ese evento. Así no se abren nuevos endpoints ni se introducen caminos de ejecución paralelos. El planificador responde al cuándo y la lógica de negocio sigue viviendo donde siempre vivió.
El siguiente diagrama sigue el recorrido de un mensaje desde que el servicio externo responde CANCELADO hasta que se publica una nueva solicitud, o se descarta si ya no hace falta. Las líneas punteadas marcan rutas de fallo.
flowchart TD
EXT["Servicio externo"] --> QR[["Cola de respuesta"]]
QR --> A["Manejador de respuestas (A)"]
QR -.->|mensajes fallidos| DLQ0[["DLQ de respuesta"]]
A <--> DB[("Almacén de estado")]
A -->|EXITOSO| OK["Flujo habitual"]
A -->|CANCELADO, intentos agotados| FAIL["Marcar FALLIDA<br/>+ alerta"]
A -->|"CANCELADO, intentos disponibles<br/>(1. PLANIFICANDO + proximo_disparo<br/>2. crear planificación)"| PL["Planificador diferido<br/>(espera creciente)"]
PL --> QRE[["Cola de reintento"]]
PL -.->|falla de entrega| DLQ1[["DLQ del planificador"]]
QRE -.->|mensajes fallidos| DLQ2[["DLQ de reintento"]]
QRE --> B["Manejador de reintentos (B)"]
B <--> DB
B -->|obsoleto o ya procesado| DROP["Descartar"]
B -->|sigue pendiente| QS[["Cola de solicitud"]]
QS --> EXT
Una convención pequeña que evita errores grandes
Antes de hablar de estados hay que fijar un detalle de numeración que parece trivial y, sin embargo, es responsable de errores muy difíciles de rastrear. El intento = 1 es la solicitud original, la primera vez que el microservicio contacta al servicio externo. El primer reintento es el intento = 2, el segundo es el intento = 3, y así sucesivamente. El campo max_intentos cuenta la solicitud original, no solo los reintentos: con max_intentos = 4 hay una solicitud original y hasta tres reintentos.
Si esto no queda escrito, el error de off-by-one aparece tarde y en producción. Una transacción hace un reintento de más porque alguien contó desde cero, o se detiene antes de tiempo porque otro incluyó el original en el conteo de reintentos. Son bugs silenciosos: el sistema funciona, pero no se comporta como el negocio espera.
El almacén de estado y las reglas del juego
Con la numeración clara, el siguiente paso es definir qué guarda el almacén para cada transacción. Son pocos campos, pero cada uno tiene una razón de ser.
| Campo | Qué significa | Cuándo cambia |
|---|---|---|
transaccion_id |
Clave primaria | Nunca |
estado |
EN_CURSO, PLANIFICANDO, PLANIFICADO, ENVIANDO, EXITOSA o FALLIDA |
En cada transición condicional |
intento_actual |
Último intento (n) cuya solicitud salió hacia el servicio externo |
Al pasar de ENVIANDO a EN_CURSO, donde se convierte en n+1 |
intento_siguiente |
Intento (n+1) que se está planificando o enviando; vacío en otro caso |
Se fija al pasar de EN_CURSO a PLANIFICANDO y se limpia al volver a EN_CURSO |
max_intentos |
Tope de intentos, contando el original | Constante |
proximo_disparo |
Instante UTC del reintento, con la variación aleatoria ya incorporada | Se escribe junto con la transición a PLANIFICANDO |
actualizado_en |
Auditoría y detección de transacciones detenidas | En cada escritura |
La razón de modelar así es que la corrección de un flujo distribuido no está en el orden de los pasos. La trampa más común al diseñar reintentos es suponer que, si cada paso se ejecuta en el orden correcto, el resultado será correcto. Pero los mensajes se reentregan, se duplican, llegan tarde, o el proceso se cae a mitad de una operación y reaparece con el mismo mensaje en la cola. En esas condiciones, la única forma de mantener la consistencia es trabajar con estados explícitos y permitir cada transición solo si el estado actual es exactamente el esperado. Si no coincide, el mensaje es obsoleto o duplicado, y se descarta sin ejecutar nada. En DynamoDB esto se logra con una ConditionExpression, y en una base relacional con un UPDATE ... WHERE estado = :esperado AND intento_actual = :n.
La vida de una transacción discurre así. Nace en EN_CURSO cuando se envía la solicitud original. Si la respuesta es exitosa, termina en EXITOSA. Si llega un CANCELADO y los intentos están agotados, termina en FALLIDA. Pero si todavía quedan intentos, pasa primero a PLANIFICANDO, un estado intermedio que significa «se decidió reintentar, aunque la planificación puede no existir aún», y luego a PLANIFICADO cuando la planificación queda confirmada. Cuando llega el evento, la transacción pasa a ENVIANDO mientras se publica la nueva solicitud y vuelve a EN_CURSO, ya con el intento incrementado, cuando la solicitud sale. Si mientras espera la transacción se resuelve por otra vía (un proceso manual, una corrección externa), puede pasar directamente a EXITOSA.
stateDiagram-v2
[*] --> EN_CURSO: solicitud enviada (intento n)
EN_CURSO --> EXITOSA: respuesta EXITOSO
EN_CURSO --> FALLIDA: CANCELADO y n = max_intentos
EN_CURSO --> PLANIFICANDO: CANCELADO y n < max_intentos<br/>(guarda intento_siguiente y proximo_disparo)
PLANIFICANDO --> PLANIFICADO: planificación confirmada
PLANIFICANDO --> ENVIANDO: evento llega antes de confirmar
PLANIFICADO --> ENVIANDO: llega el evento (intento n+1)
ENVIANDO --> ENVIANDO: reentrega retoma la publicación
ENVIANDO --> EN_CURSO: solicitud publicada (intento_actual = n+1)
PLANIFICANDO --> EXITOSA: resuelta por otra vía
PLANIFICADO --> EXITOSA: resuelta por otra vía
EXITOSA --> [*]
FALLIDA --> [*]
Cada flecha del diagrama es, en realidad, una condición: «pasa a este estado solo si el estado actual es aquel y el número de intento es este». Si cualquiera de las dos cosas falla, la operación no se ejecuta, y esa regla aplicada sin excepciones es lo que hace correcto al sistema aunque los mensajes lleguen duplicados o desordenados.
Conviene decir que la regla de «resuelta por otra vía» es una decisión, no una verdad universal. Aquí se permite desde PLANIFICANDO y PLANIFICADO, pero no desde ENVIANDO: en ese punto la solicitud ya está en vuelo y será la respuesta del servicio externo la que decida el destino. Otros equipos podrían optar por permitirla también desde ENVIANDO e intentar cancelar la solicitud. Lo importante es elegir y dejarlo documentado.
Tres escrituras que no son atómicas
Cuando A recibe un CANCELADO y decide planificar un reintento, tiene que hacer tres cosas: actualizar el almacén de estado, crear la planificación y confirmar el mensaje de la cola. Ninguna de las tres está conectada a las otras por una transacción. Si el proceso cae entre dos de ellas, el mensaje se reentregará y A deberá poder retomar desde donde quedó, sin duplicar ni perder nada.
La primera versión intuitiva de este flujo calculaba la fecha del reintento en el momento de crear la planificación. Funciona hasta que el proceso cae justo después de crearla y antes de anotar nada: la reentrega vuelve a calcular, y como la espera incluye una variación aleatoria, obtiene un instante distinto y crea una segunda planificación para el mismo intento. El negocio terminaba viendo reintentos duplicados sin que ningún componente hubiera «fallado» en sentido estricto.
La solución es cuestión de orden. Cuando A detecta un CANCELADO con intentos disponibles, lo primero que hace es una transición condicional de EN_CURSO(n) a PLANIFICANDO, y en esa misma escritura guarda intento_siguiente = n+1 y el proximo_disparo, el instante exacto del reintento. Solo después crea la planificación, usando ese instante ya persistido. Si el proceso cae entre ambos pasos, la reentrega encuentra la transacción en PLANIFICANDO, lee el proximo_disparo guardado e intenta crear la planificación de nuevo. Si ya existía, el planificador responde con un error de conflicto que A trata como éxito, porque la planificación está creada con el instante correcto. El resultado es el mismo: un único reintento programado.
El flujo completo de A queda así. Primero descarta las respuestas tardías: si el número de intento de la respuesta no coincide con intento_actual, pertenece a un intento viejo y se confirma sin más. Si es EXITOSO y la transacción está EN_CURSO, pasa a EXITOSA. Si es CANCELADO y n = max_intentos, pasa a FALLIDA y se emite una alerta de negocio. Y si es CANCELADO con intentos disponibles, ejecuta la secuencia descrita: guardar el instante, crear la planificación, marcar PLANIFICADO y confirmar el mensaje. Si en cualquier reentrega la transacción ya está más adelante, en PLANIFICADO o ENVIANDO, significa que otra ejecución ya hizo el trabajo: A confirma el mensaje y termina. Del mismo modo, si la transición final a PLANIFICADO falla porque el estado ya avanzó, es un éxito, no un error.
sequenceDiagram
participant Q as Cola de respuesta
participant A as Manejador A
participant DB as Almacén de estado
participant PL as Planificador
Q->>A: CANCELADO (intento n)
A->>DB: EN_CURSO(n) → PLANIFICANDO + proximo_disparo
Note over A,DB: Punto de fallo 1
A->>PL: Crear planificación (nombre determinista)
Note over A,PL: Punto de fallo 2
PL-->>A: OK o conflicto (= éxito)
A->>DB: PLANIFICANDO → PLANIFICADO
Note over A,DB: Punto de fallo 3
A->>Q: confirmar mensaje
Cuando el evento llega: ejecutar una sola vez
Horas después, el planificador dispara el evento y este aterriza en la cola de reintento, donde B lo recoge. Tanto el planificador como la cola ofrecen entrega al menos una vez, lo cual significa que el mismo evento puede llegar dos veces. B tiene que producir el mismo resultado en ambos casos, y para ello usa las mismas transiciones condicionales que A.
Al recibir el evento, B lee la transacción. Si el intento del evento no coincide con intento_siguiente, el evento es obsoleto y se descarta. Si coincide, mira el estado: desde PLANIFICADO o PLANIFICANDO intenta la transición a ENVIANDO; si ya está en EN_CURSO, EXITOSA o FALLIDA, el mensaje es un duplicado de un procesamiento terminado y se descarta. Una vez en ENVIANDO, publica la nueva solicitud en la cola del servicio externo con una clave de idempotencia que combina el identificador de la transacción con el número de intento (por ejemplo, TX-100200#2). Por último, la transición de ENVIANDO a EN_CURSO fija intento_actual = n+1, limpia intento_siguiente y cierra el ciclo.
Aquí aparecieron dos dificultades.
La primera es una carrera. Si el evento llega mientras la transacción sigue en PLANIFICANDO, porque A fue lento, cayó o su reentrega se demoró, un B que exigiera PLANIFICADO descartaría el evento, y después A marcaría PLANIFICADO sin que nadie volviera a disparar el reintento. La transacción quedaría esperando un evento que ya pasó. La salida es que B acepte también PLANIFICANDO como estado de partida, y que A trate como éxito el fallo condicional que encontrará después.
La segunda es más sutil. Si B cae después de pasar a ENVIANDO y antes de publicar, la reentrega encuentra la transacción en ENVIANDO. Con una regla ingenua de «si el estado no es el esperado, descartar», ese mensaje se tiraría y la transacción quedaría atascada para siempre, sin solicitud y sin nadie que la reintente. Por eso, cuando B encuentra ENVIANDO con el intento correcto, no descarta: retoma. Vuelve a publicar con la misma clave de idempotencia y completa la transición a EN_CURSO.
Esa decisión tiene un precio que hay que aceptar con los ojos abiertos. Si dos entregas del mismo evento llegan en paralelo, ambas pueden publicar la solicitud. Es inocuo únicamente porque el servicio externo descarta el duplicado gracias a la clave de idempotencia. Esa es una precondición del patrón, no un detalle: si el servicio externo no respeta la clave, el diseño necesita el patrón outbox. En él, la solicitud a publicar se guarda en el almacén junto con la transición de estado, y un publicador independiente se encarga de enviarla de forma fiable. Es más costoso de construir, pero elimina del todo la ventana entre publicar y confirmar.
sequenceDiagram
participant PL as Planificador
participant Q as Cola de reintento
participant B as Manejador B
participant DB as Almacén de estado
participant QS as Cola de solicitud
PL->>Q: evento (intento n+1)
Q->>B: entrega del evento
B->>DB: PLANIFICADO o PLANIFICANDO → ENVIANDO
Note over B,DB: Punto de fallo 1
B->>QS: publicar (clave transaccion_id#n+1)
Note over B,QS: Punto de fallo 2
B->>DB: ENVIANDO → EN_CURSO (intento_actual = n+1)
Note over B,DB: Punto de fallo 3
B->>Q: confirmar mensaje
Una espera que crece para no presionar siempre igual
Hay una pregunta que todavía no hemos contestado: cuánto hay que esperar. Si la causa del CANCELADO persiste, porque el servicio externo tiene un problema que tarda en resolverse, reintentar con el mismo intervalo cada vez aplica la misma presión sin darle tiempo real de recuperarse. La espera debería crecer. La fórmula, configurable, es la siguiente:
espera(k) = min( base × factor^(k-1) , tope ) × (1 + u), u ∈ [-jitter, +jitter]
Aquí k es el número de reintento (tras fallar el intento n, se usa k = n), base es la espera antes del primer reintento, factor es cuánto crece en cada paso, tope es el máximo de la espera nominal y jitter es la variación aleatoria relativa. Con valores típicos (base = 2 h, factor = 2, tope = 24 h, jitter = ±10 %), una transacción con max_intentos = 4 queda así:
| Reintento | Intento | Espera nominal | Rango con ±10 % |
|---|---|---|---|
| 1 | 2 | 2 h | 1 h 48 min – 2 h 12 min |
| 2 | 3 | 4 h | 3 h 36 min – 4 h 24 min |
| 3 | 4 | 8 h | 7 h 12 min – 8 h 48 min |
Un matiz que suele pasarse por alto: en la fórmula, la variación se aplica después del tope, de modo que la espera real puede llegar a tope × 1,1, es decir, 26,4 horas con estos valores. Si el negocio necesita un máximo estricto, hay que recortar de nuevo tras aplicar la variación. Cualquiera de las dos opciones es válida, siempre que esté documentada.
El jitter merece su propio párrafo porque no es un adorno. Imagina que llegan mil respuestas CANCELADO en el mismo minuto, por una caída momentánea del servicio externo. Si todas calculan exactamente la misma espera, sus reintentos se disparan al mismo instante horas después y golpean al servicio en ráfaga, justo cuando intentaba recuperarse. La variación aleatoria dispersa esa ráfaga a lo largo de un intervalo, y reparte la carga de forma natural.
Hay, además, una decisión con impacto directo en la corrección: el jitter se calcula en la aplicación, no se delega al planificador. Así el proximo_disparo, con su variación ya incorporada, queda registrado en el almacén. Si el proceso falla y el mensaje se reentrega, la planificación se recrea con exactamente el mismo instante, y el comportamiento es determinista aunque el proceso se haya reiniciado.
Esta política tampoco tiene por qué ser uniforme. No todo CANCELADO representa la misma situación de negocio: algunos justifican esperas cortas y otros largas, y el criterio puede depender del tipo de transacción, del cliente o del motivo concreto del rechazo.
Proteger al servicio que se quiere recuperar
El jitter ayuda, pero no es suficiente si el volumen es muy alto o si el servicio externo está en un estado delicado. B debería incorporar tres controles adicionales.
El primero es limitar su propia concurrencia al publicar en la cola de solicitud. Si B puede procesar cien eventos por segundo pero el servicio externo solo absorbe diez, el límite debe vivir en B y no depender de que el servicio resista.
El segundo es un cortacircuitos (circuit breaker). Si el servicio está claramente degradado, porque responde lento, con errores o directamente no responde, tiene más sentido pausar o reprogramar los reintentos que seguir gastando el presupuesto de max_intentos. Un fallo por indisponibilidad técnica no equivale a un CANCELADO de negocio, y tratarlos igual acorta artificialmente la vida de una transacción que aún podría resolverse. Conviene advertir que la máquina de estados que hemos descrito no modela esta transición; hacerlo implicaría permitir, por ejemplo, volver de PLANIFICADO o ENVIANDO a PLANIFICANDO conservando intento_siguiente y fijando un nuevo proximo_disparo, con un nombre de planificación que incluya un sufijo de secuencia para evitar conflictos con el anterior.
El tercero es la cancelación proactiva de planificaciones. Si una transacción se resuelve por otra vía y todavía tiene una planificación pendiente, el sistema es correcto de todas formas: cuando el planificador dispare, B encontrará un estado distinto de los esperados y descartará el evento. Pero eliminar la planificación en cuanto la transacción se resuelve es una buena práctica, porque reduce ruido, eventos inútiles y costo.
Qué pasa cuando algo falla en el camino
La prueba real de un diseño así no es el flujo feliz, sino lo que ocurre cuando algo se rompe entre dos operaciones que no son atómicas. Recorrer los fallos relevantes es la mejor manera de comprobar que las reglas anteriores se sostienen.
| Fallo | Estado en que queda | Qué ocurre en la reentrega |
|---|---|---|
A cae antes de pasar a PLANIFICANDO |
EN_CURSO |
El mensaje se reentrega y el proceso se repite completo, sin efectos duplicados |
A cae tras PLANIFICANDO, antes de crear la planificación |
PLANIFICANDO |
Se reutiliza el proximo_disparo guardado y se crea la planificación: un solo reintento |
A cae tras crear la planificación, antes de marcar PLANIFICADO |
PLANIFICANDO |
La creación responde «ya existe» y se trata como éxito |
El evento llega mientras A sigue en PLANIFICANDO |
PLANIFICANDO |
B acepta ese estado y avanza a ENVIANDO; el fallo condicional posterior de A se trata como éxito |
| El planificador no puede entregar el evento | PLANIFICADO |
Reintentos de entrega y, al agotarse, DLQ del planificador, con el mensaje conservado |
| El evento se entrega dos veces | PLANIFICADO → ENVIANDO |
B descarta el duplicado por transición condicional o retoma si la primera ejecución quedó a medias |
B cae tras ENVIANDO, antes de publicar |
ENVIANDO |
La reentrega retoma y publica |
B cae tras publicar, antes de pasar a EN_CURSO |
ENVIANDO |
Se republica con la misma clave de idempotencia y el servicio externo descarta el duplicado |
| Llega una respuesta de un intento anterior | Cualquiera | Se descarta por el número de intento; el estado queda intacto |
| Se agotan los intentos | FALLIDA |
Alerta de negocio: es un fallo de negocio, no técnico |
El patrón se repite en todas las filas: el almacén de estado, con sus transiciones condicionales, actúa como árbitro. Cualquier operación que llegue cuando el estado no es el esperado simplemente no tiene efecto.
Hay un caso que esta tabla deja fuera a propósito, y es el de los mensajes que terminan en una DLQ. Un mensaje que cae allí significa que la transacción quedó detenida en un estado intermedio, y cada cola cuenta una historia diferente. La DLQ de respuesta indica que A falló repetidamente con una respuesta, y la transacción quedó en EN_CURSO o PLANIFICANDO. La DLQ del planificador indica que el planificador no pudo entregar el evento a la cola, y la transacción espera en PLANIFICADO. La DLQ de reintento indica que B falló repetidamente con un evento que sí llegó, y la transacción puede estar en PLANIFICANDO, PLANIFICADO o ENVIANDO. En los tres casos, el procedimiento es el mismo: diagnosticar la causa, corregirla y reenviar el mensaje a su cola de origen. Como los manejadores son idempotentes, reprocesar es seguro. Toda DLQ debería tener una alarma que salte con el primer mensaje, porque cualquiera de ellos es una incidencia.
Aun así, un diseño serio no debería depender únicamente de que alguien mire las DLQ. Por eso conviene sumar un reconciliador, un proceso periódico que busca transacciones detenidas en un estado intermedio durante más de un umbral razonable (por ejemplo, el doble de la latencia máxima esperada). Si encuentra una en PLANIFICANDO, reintenta la creación de la planificación con el proximo_disparo guardado. Si encuentra una en PLANIFICADO con el instante muy pasado, publica directamente el evento en la cola de reintento. Si la encuentra en ENVIANDO, republica el evento para que B retome. Como todas esas acciones pasan por los mismos manejadores idempotentes, el reconciliador no introduce riesgos nuevos. Para ejecutarlo de forma eficiente en DynamoDB hace falta un índice secundario por estado y actualizado_en.
Llevarlo a AWS
Con los conceptos claros, el mapeo a servicios de AWS es casi directo, porque cada componente lógico tiene un equivalente natural. Las colas de solicitud, respuesta y reintento son colas Amazon SQS de tipo Standard. El planificador diferido es Amazon EventBridge Scheduler, usando planificaciones de una sola ejecución (one-time schedules). El almacén de estado puede ser Amazon DynamoDB, con ConditionExpression para las transiciones, o una base relacional con UPDATE ... WHERE si ya forma parte del stack. Las DLQ son colas SQS adicionales, y la plataforma de ejecución es Amazon EKS, con pods que obtienen sus permisos mediante IRSA o EKS Pod Identity. Es una combinación razonable, pero no la única posible: lo esencial del patrón sobrevive a cualquier otra elección.
La elección del planificador responde a una limitación concreta. El retraso nativo de SQS tiene un máximo de 15 minutos, suficiente para reintentos rápidos pero insuficiente cuando el negocio pide esperas de horas. EventBridge Scheduler permite registrar un evento para cualquier instante futuro, sean horas o días, sin mantener ningún proceso activo durante la espera. Las planificaciones one-time se disparan una vez y, si se configura ActionAfterCompletion = DELETE, se eliminan solas.
Un detalle que suele confundir al principio: las DLQ de esta arquitectura se configuran de dos maneras distintas. La del planificador se define en el destino de cada planificación, mediante DeadLetterConfig. Las de SQS (respuesta y reintento) se definen en la cola de origen mediante una redrive policy con un maxReceiveCount, y conviene que el visibility timeout de esas colas sea mayor que el tiempo máximo de procesamiento del manejador, para que un mensaje en proceso no reaparezca en otro consumidor.
Cómo configurar cada planificación
Cada reintento se registra como una planificación con una expresión at(yyyy-mm-ddThh:mm:ss) que fija el instante exacto. Hay varios parámetros que es fácil dejar implícitos y que después producen comportamientos inesperados.
El ScheduleExpressionTimezone debe ser explícito. Sin él, la expresión at() se interpreta en UTC, y si alguien del equipo no lo tiene presente puede esperar disparos en otro horario. Lo más sencillo es calcular siempre en UTC y dejarlo escrito. El FlexibleTimeWindow debe estar en OFF: esa propiedad permite al planificador añadir una ventana de flexibilidad para optimizar recursos, pero con ella activa el instante real de disparo deja de ser predecible, y no tiene sentido ceder ese determinismo cuando el proximo_disparo ya incorpora el jitter y está guardado. El ActionAfterCompletion debe ser DELETE, para que las planificaciones ejecutadas desaparezcan en lugar de acumularse y ocupar cuota sin propósito. Conviene, también, usar un grupo de planificaciones dedicado a los reintentos, que permite aislar permisos, etiquetas y cuotas del resto de la cuenta. Y, por último, la RetryPolicy y la DeadLetterConfig del destino garantizan que, si el planificador no logra entregar el evento a SQS, lo reintente y, al agotarse los intentos, lo envíe a la DLQ del planificador para su análisis.
Este es un ejemplo concreto, el de la planificación del segundo intento de la transacción TX-100200:
{
"Name": "retry-TX-100200-2",
"GroupName": "reintentos-ms-procesamiento",
"ScheduleExpression": "at(2026-10-03T16:38:00)",
"ScheduleExpressionTimezone": "UTC",
"FlexibleTimeWindow": { "Mode": "OFF" },
"ActionAfterCompletion": "DELETE",
"Target": {
"Arn": "arn:aws:sqs:<region>:<cuenta>:cola-reintento",
"RoleArn": "arn:aws:iam::<cuenta>:role/scheduler-reintentos",
"Input": "{\"transaccion_id\":\"TX-100200\",\"origen\":\"PLANIFICADOR_REINTENTO\",\"intento\":2}",
"RetryPolicy": {
"MaximumRetryAttempts": 5,
"MaximumEventAgeInSeconds": 3600
},
"DeadLetterConfig": {
"Arn": "arn:aws:sqs:<region>:<cuenta>:dlq-planificador"
}
}
}
Como JSON no admite comentarios, vale la pena leer el ejemplo campo por campo: Name es un nombre determinista (prefijo fijo, identificador de la transacción y número de intento); ScheduleExpression contiene el proximo_disparo en UTC; Target.Arn apunta a la cola de reintento; RoleArn es el rol que el planificador asume para escribir en esa cola; Input es la carga que B recibirá, con el número de intento incluido; y RetryPolicy y DeadLetterConfig gobiernan lo que ocurre si la entrega falla.
Idempotencia al crear la planificación
El nombre retry-TX-100200-2 es determinista a propósito. Si A intenta crear una planificación que ya existe, el planificador responde con un ConflictException, y A debe tratarlo explícitamente como un éxito y no como un error.
Pero hay un matiz importante. Con ActionAfterCompletion = DELETE, la planificación desaparece después de dispararse, y el nombre queda libre de nuevo. Eso significa que la unicidad del nombre no es la barrera definitiva contra los duplicados. Esa barrera sigue siendo el almacén de estado con sus transiciones condicionales: el planificador resuelve el cuándo, pero la corrección la garantiza el almacén. Un último detalle práctico: los nombres de planificación tienen restricciones de longitud y de caracteres, así que si el identificador de la transacción es largo o contiene símbolos especiales conviene codificarlo, y un hash corto funciona bien.
Permisos con el menor privilegio posible
Los pods del microservicio necesitan scheduler:CreateSchedule para crear planificaciones, scheduler:DeleteSchedule si se implementa la cancelación proactiva, e iam:PassRole restringido al rol del planificador, todo acotado al grupo de planificaciones dedicado. El rol del planificador, por su parte, solo necesita sqs:SendMessage sobre la cola de reintento y la DLQ del planificador. Si las colas están cifradas con una clave KMS gestionada por el cliente, ese rol requiere además kms:GenerateDataKey y kms:Decrypt sobre la clave. Y la política de la cola de reintento debería aceptar mensajes únicamente del rol del planificador: esa restricción cierra la posibilidad de que cualquier otro componente inyecte reintentos de forma no autorizada.
Las cuotas que conviene mirar antes de producción
Crear planificaciones es una llamada a una API con límites de tasa, y existe además un máximo de planificaciones activas simultáneamente por cuenta y región. Esos valores cambian con el tiempo, así que la fuente definitiva es Service Quotas en la consola de AWS y no la documentación de terceros.
El volumen que importa estimar no es solo la ráfaga máxima de CANCELADO por segundo, sino también cuántas planificaciones pueden estar activas al mismo tiempo. Con espera creciente, cada planificación vive más, hasta varias horas, y con muchas transacciones en espera el número puede crecer más de lo esperado. Para absorber ráfagas sin perder mensajes hay tres medidas. La primera es limitar la concurrencia del consumidor de la cola de respuesta: SQS aplica contrapresión de forma natural, y si la creación falla por throttling, el mensaje reaparece tras el visibility timeout y se reintenta. La segunda es usar reintentos con backoff y jitter en la propia llamada de creación, para no amplificar el throttling. Y la tercera es solicitar un aumento de cuota con antelación si el análisis de capacidad lo indica.
Si no hay un planificador gestionado
Todo lo anterior es independiente de la tecnología, porque el planificador diferido es un componente lógico y no necesariamente un servicio gestionado. Si el entorno no ofrece uno, la alternativa más directa es una tabla de reintentos pendientes en la base de datos, junto con un proceso periódico (poller) que busque las transacciones cuyo proximo_disparo ya pasó y las envíe a la cola de reintento.
Es más portable y no introduce dependencias externas, pero tiene un costo real: un componente más que operar, una latencia que depende de la frecuencia del sondeo en lugar de ser casi inmediata, y una presión de lectura adicional sobre el almacén proporcional al número de transacciones pendientes. La máquina de estados, la idempotencia y la política de espera creciente no cambian en absoluto; solo cambia quién dispara el evento cuando llega el momento.
La corrección del patrón no depende de tener un planificador diferido gestionado. Depende del diseño: la máquina de estados, las transiciones condicionales y los manejadores idempotentes funcionan igual con cualquier mecanismo de disparo.
Lo que hay que medir para confiar en el sistema
Un flujo con esta complejidad necesita observabilidad proporcional. Sin métricas concretas es imposible saber si el sistema funciona como se diseñó o si acumula problemas que solo aflorarán bajo carga.
En cada cola SQS hay dos métricas esenciales: ApproximateNumberOfMessagesVisible, que indica cuántos mensajes esperan procesamiento, y ApproximateAgeOfOldestMessage, que indica cuánto lleva esperando el más antiguo y suele ser la primera señal de un consumidor atascado o caído. Las DLQ merecen vigilancia aparte, porque significan cosas distintas, como vimos antes, y su alarma debería saltar ante el primer mensaje. EventBridge Scheduler publica en CloudWatch métricas de invocaciones al destino, errores de entrega, entregas enviadas a la DLQ y throttling; un pico de errores de entrega suele delatar un problema de permisos o de disponibilidad de SQS que de otro modo pasaría desapercibido. Los nombres exactos de esas métricas conviene verificarlos en la documentación vigente de AWS antes de definir alarmas.
Las métricas más valiosas para entender el comportamiento de negocio son las del propio microservicio: el porcentaje de respuestas CANCELADO sobre el total, el número de planificaciones creadas y de conflictos recibidos (que mide, indirectamente, la frecuencia de las reentregas), las transacciones que terminan en FALLIDA por intentos agotados, y la distribución de transacciones por estado e intento. Esta última permite detectar, por ejemplo, un acúmulo de transacciones en PLANIFICANDO que no avanzan a PLANIFICADO, señal clara de un problema al crear planificaciones.
Pero lo que más se agradece en un incidente real no es una métrica sino un registro de auditoría: la posibilidad de reconstruir el historial completo de una transacción, con qué intentos ocurrieron, con qué identificador de planificación, cuándo se creó, cuándo se esperaba el disparo y cuál fue el resultado. El almacén de estado solo guarda el estado actual, así que este historial exige una tabla de eventos de solo-añadir, donde cada transición deje una fila. Sin ella, cuando algo sale mal en producción la respuesta es especulación; con ella, el diagnóstico es una consulta.
Lo que el patrón garantiza, y lo que no
Conviene nombrar con claridad las garantías. Una transacción no produce reintentos duplicados. El ciclo es finito, porque cada transacción termina en un estado terminal, sea EXITOSA o FALLIDA. Y el sistema se comporta correctamente ante reentregas y fallos en cualquier punto del flujo, siempre que se cumpla la precondición de idempotencia del servicio externo, o se adopte outbox.
Lo que no garantiza es precisión de reloj. El instante programado es el momento en que el evento queda disponible en la cola, no el momento en que se ejecuta, y entre ambos intervienen la cola, la disponibilidad del consumidor y su concurrencia. La desviación es de segundos en condiciones normales. Es algo distinto del jitter, que es intencional y puede ser de minutos; ambas cosas coexisten y hay que distinguirlas al documentar el comportamiento esperado.
Antes de llevar el patrón a producción, estas garantías deberían convertirse en pruebas formales y no en afirmaciones de diseño. Una ráfaga de N cancelaciones simultáneas no debe perder ni duplicar ningún reintento. La reentrega forzada de cada mensaje, en cada paso del flujo, no debe alterar el resultado final, lo que incluye el caso del evento que llega con la transacción aún en PLANIFICANDO y el de B caído en ENVIANDO. Ninguna transacción debe superar max_intentos. Una respuesta tardía de un intento anterior no debe modificar el estado. Las alertas de DLQ y de intentos agotados deben dispararse, el reconciliador debe reanudar transacciones detenidas en cada estado intermedio, y el servicio externo no debe recibir más de X solicitudes por segundo durante una ráfaga de reintentos. No son criterios opcionales de calidad: son la forma de demostrar que el diseño funciona en condiciones reales y no solo en el camino feliz.
La espera como decisión de diseño
Volvamos al punto de partida. El negocio pide reintentar más tarde, y alguien tiene que decidir cómo se modela ese «más tarde» dentro del sistema.
Como resumen, estos son los puntos que sostienen todo lo anterior. La espera debe ser un estado persistente y no un proceso dormido, porque lo primero no cuesta nada mientras transcurre y lo segundo se paga hora a hora. Cada pieza resuelve una sola responsabilidad (cuándo, si todavía tiene sentido, y una sola vez) y por eso el conjunto se recupera sin coordinación central. La corrección vive en las transiciones condicionales del almacén y no en el orden de los pasos, de modo que cualquier mensaje duplicado, tardío u obsoleto simplemente no tiene efecto. Guardar el instante del reintento antes de crear la planificación, aceptar PLANIFICANDO en B y retomar desde ENVIANDO son las tres decisiones que cierran los huecos que las primeras versiones del diseño dejaban abiertos. Y la espera creciente con jitter calculado en la aplicación protege al servicio externo y mantiene el comportamiento determinista.
La diferencia entre un Thread.sleep y este patrón no es de complejidad superficial, sino de qué tan bien el sistema entiende su propia situación. Un proceso que duerme no sabe que está esperando un reintento, no puede decirlo, no puede auditarse y no puede recuperarse de un fallo sin ayuda externa. Un almacén de estado con transiciones condicionales sí sabe en qué punto está, puede registrarlo, puede responder preguntas sobre su historial y puede retomar exactamente donde lo dejó si algo falla. Eso es lo que hace escalable la solución, y no el hecho de que use servicios concretos: el planificador es intercambiable, mañana puede ser una tabla con un poller o un servicio de otro proveedor, y lo que permanece es la máquina de estados, la idempotencia y la política de espera.
Quedan, además, varias líneas abiertas que merecen exploración. La más inmediata es modelar formalmente el cortacircuitos dentro de la máquina de estados, distinguiendo con claridad las indisponibilidades técnicas de los rechazos de negocio, para que las primeras no consuman el presupuesto de intentos. Otra es refinar la política de espera según el motivo del rechazo, o incluso volverla adaptativa, ajustando la base y el factor a partir de la tasa de éxito observada en reintentos anteriores. También vale la pena estudiar la adopción de outbox de forma sistemática en los casos donde el servicio externo no ofrezca garantías de idempotencia, y evaluar su costo frente al de aceptar un duplicado ocasional. Y, en el plano de la validación, tiene mucho potencial la inyección controlada de fallos (chaos engineering) aplicada a cada uno de los puntos intermedios descritos, para comprobar de forma continua, y no solo antes del lanzamiento, que las garantías se mantienen a medida que el sistema evoluciona.
Darle tiempo al servicio externo para recuperarse no es una concesión ante un fallo. Es reconocer que en sistemas distribuidos los fallos suelen ser temporales, que una segunda oportunidad bien diseñada tiene un valor real, y que esperar bien, sin desperdiciar recursos, sin duplicar y sin perder el hilo, es una forma de resiliencia tan importante como actuar rápido cuando todo va bien.
Paginacion: Cuando offset ya no es suficiente
- Mauricio ECR
- Arquitectura
- 27 Sep, 2026
Te llega la tarea y parece de las fáciles: "agregar paginación al listado de artículos". Añades ?page=0&size=20, Spring te proporciona Pageable, el repositorio hereda findAll(pageable) y, en poc
Paginacion: Cuando offset ya no es suficiente
- Mauricio ECR
- Arquitectura
- 27 Sep, 2026
Te llega la tarea y parece de las fáciles: "agregar paginación al listado de artículos". Añades ?page=0&size=20, Spring te proporciona Pageable, el repositorio hereda findAll(pageable) y, en pocos minutos, tienes un endpoint funcionando.
En desarrollo, con mil registros, todo va bien. En staging, con diez mil, probablemente también. El problema aparece más adelante, cuando la tabla alcanza cientos de miles de filas, los filtros se vuelven más complejos y los tiempos de respuesta empiezan a crecer de una forma que ya no resulta tan fácil de explicar.
Y esa lentitud no aparece porque sí. Tiene una causa concreta, relacionada con la forma en que la base de datos resuelve la consulta internamente.
El costo invisible de OFFSET
La paginación tradicional suele apoyarse en dos cláusulas SQL: LIMIT, que determina cuántos registros devolver, y OFFSET, que indica cuántos registros deben quedar fuera antes de comenzar a devolver resultados.
SELECT * FROM articulos
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;
La consulta parece directa. Pero lo que ocurre para resolverla no siempre lo es.
Para obtener los veinte registros solicitados, el motor necesita avanzar hasta la posición indicada por el OFFSET. Los registros anteriores no forman parte del resultado final, pero el trabajo necesario para llegar hasta ellos existe.
Por eso, a medida que aumenta la profundidad de la página, puede aumentar también el trabajo que debe realizar el motor. La página 1 con tamaño 20 apenas necesita avanzar. Una página mucho más profunda tiene que recorrer una cantidad considerablemente mayor de registros antes de llegar al conjunto que finalmente devolverá.
La diferencia puede ser especialmente importante en tablas grandes. Los índices ayudan a reducir el trabajo necesario, pero no eliminan por completo la naturaleza del problema: la posición se expresa como una cantidad de registros que deben quedar atrás.
Existe además otro problema, menos visible durante las pruebas: la posición de una página puede cambiar mientras el usuario navega.
Imagina que un cliente solicita la página 3 y, antes de solicitar la página 4, se inserta un nuevo registro que queda al principio del orden.
Ese nuevo registro desplaza las posiciones siguientes. Como consecuencia, el cliente puede recibir un registro que ya había visto o dejar de recibir otro que esperaba encontrar en la siguiente página.
El mismo tipo de desplazamiento puede producirse cuando se eliminan registros o cuando una modificación cambia su posición dentro del orden.
No es necesariamente un error que aparezca durante una prueba funcional. Es un comportamiento que puede hacerse visible cuando varios usuarios navegan mientras el sistema continúa recibiendo escrituras.
Tenemos, entonces, dos problemas diferentes pero relacionados:
- El costo puede aumentar a medida que se profundiza en las páginas.
- Los resultados pueden desplazarse entre una solicitud y la siguiente cuando los datos cambian.
Estos problemas explican por qué una implementación de OFFSET que funcionaba perfectamente con una tabla pequeña puede empezar a presentar dificultades cuando cambian el volumen o la forma de utilizar el endpoint.
Pero todavía no podemos concluir que haya que reemplazar OFFSET.
La pregunta siguiente es otra: ¿qué características tiene el caso de uso que estamos intentando resolver?
Las variables que realmente determinan la solución
No existe una única solución para todos los escenarios de paginación. La estrategia adecuada depende de cómo se navegan los resultados, cómo se filtran, cómo se ordenan y cuánto crecerán los datos.
Antes de comparar alternativas, conviene responder cuatro preguntas.
La primera es probablemente la más importante:
¿El usuario navega secuencialmente o necesita saltar a páginas arbitrarias?
Navegar secuencialmente significa avanzar a partir del resultado anterior: siguiente, anterior, scroll infinito o cualquier otra interfaz en la que el usuario recorre los resultados de forma progresiva.
La navegación aleatoria es diferente. Aquí el usuario puede seleccionar directamente una página concreta, por ejemplo la 53, sin haber recorrido las anteriores.
Esta diferencia es fundamental porque algunas estrategias utilizan precisamente el resultado anterior como punto de referencia. Si el usuario necesita saltar directamente a una posición arbitraria, ese modelo deja de encajar tan bien.
La segunda pregunta aparece cuando entran en juego los filtros:
¿Los filtros utilizan valores discretos o rangos abiertos?
Un filtro discreto trabaja con un conjunto relativamente acotado de posibilidades: estado, categoría, autor o tipo.
Un rango abierto permite prácticamente cualquier combinación dentro de un continuo: una fecha entre X e Y, un precio entre A y B o una búsqueda de texto libre.
Esta diferencia se vuelve especialmente importante cuando se considera una estrategia basada en caché. Si existen pocas combinaciones posibles, una consulta puede reutilizar un resultado previamente calculado. Cuando las combinaciones son prácticamente infinitas, esa reutilización se vuelve mucho menos probable.
La tercera pregunta tiene que ver con el orden:
¿El orden de los resultados es estable o puede cambiar?
Un orden estable puede ser, por ejemplo, created_at DESC, acompañado de un identificador como desempate.
Un orden dinámico permite que el usuario cambie la columna o los criterios de orden durante la navegación. En ese caso, una referencia calculada para el orden anterior deja de representar correctamente la posición dentro del nuevo orden.
Finalmente está el volumen:
¿Cuántos registros existen hoy y cuánto se espera que crezca la tabla?
Una tabla con cincuenta mil registros presenta unas necesidades diferentes a una que puede alcanzar varios millones.
El volumen no determina por sí solo la solución, pero sí cambia el costo de las estrategias. Algo que resulta perfectamente razonable en una tabla pequeña puede dejar de serlo cuando aumentan la profundidad de las páginas, la cantidad de consultas y la concurrencia.
Con estas cuatro variables definidas, ya podemos comparar las alternativas de manera más precisa. Cada una resuelve una combinación diferente de necesidades y, al mismo tiempo, introduce sus propios límites.
Cuando OFFSET sigue siendo suficiente
La primera posibilidad es también la más sencilla: mantener OFFSET, pero controlar las condiciones en las que se utiliza.
Esto tiene sentido cuando el volumen es moderado, las páginas profundas son poco frecuentes y la navegación aleatoria forma parte de los requisitos.
En muchos sistemas, los usuarios rara vez llegan a páginas extremadamente profundas. Si una interfaz obliga a recorrer miles de páginas para encontrar un registro, probablemente el problema de fondo sea que falta una búsqueda o un mecanismo de filtrado más adecuado.
Por eso, establecer un límite máximo de profundidad puede ser una decisión razonable. Por ejemplo, se puede impedir que una API consulte páginas más allá de cierto límite y obligar al consumidor a utilizar filtros o búsqueda para localizar registros concretos.
El segundo elemento importante es el índice.
Supongamos que la consulta utiliza:
ORDER BY estado, created_at, id
Si esas columnas participan habitualmente en el orden y los filtros, un índice compuesto diseñado de acuerdo con el patrón real de consulta puede reducir considerablemente el trabajo necesario.
El costo asociado con la profundidad no desaparece por completo, pero puede disminuir de forma importante.
Aquí aparece una idea importante: no toda paginación necesita una arquitectura sofisticada.
Si el problema es pequeño y los requisitos son sencillos, introducir cursores, Redis o un motor de búsqueda puede añadir mucha más complejidad de la que realmente se necesita.
Cuándo encaja
OFFSET puede seguir siendo una buena opción cuando:
- El volumen es moderado.
- Las páginas profundas son poco frecuentes.
- El usuario necesita saltar a páginas arbitrarias.
- Los filtros y órdenes pueden cambiar dinámicamente.
- Se quiere mantener un contrato de API convencional basado en
pageysize. - La simplicidad de implementación es importante.
Qué no resuelve
El costo de las páginas profundas sigue existiendo.
Además, los cambios en los datos entre solicitudes pueden desplazar los resultados de una página a otra.
Cuando esas limitaciones dejan de ser aceptables, aparece una estrategia basada en una idea diferente: dejar de identificar la posición mediante un número y utilizar el propio orden de los datos como referencia.
Cuando la navegación es secuencial: cursor-based pagination
Aquí aparece la paginación basada en cursores, también conocida como keyset pagination.
La diferencia conceptual es sencilla.
Con OFFSET, la consulta pregunta:
"Dame los registros que están después de las primeras N posiciones."
Con un cursor, la consulta pregunta:
"Dame los registros que vienen después de este punto concreto del orden."
Por ejemplo:
SELECT * FROM articulos
WHERE (created_at, id) < (:ultima_fecha, :ultimo_id)
ORDER BY created_at DESC, id ASC
LIMIT 20;
En este caso, el último registro recibido se convierte en el punto de referencia para solicitar el siguiente conjunto.
La base de datos ya no necesita interpretar la página como una posición numérica. Puede utilizar los valores del orden para localizar el punto desde el que debe continuar.
Cuando existe un índice adecuado, esto permite evitar gran parte del trabajo asociado con recorrer posiciones profundas mediante OFFSET.
Por eso, la profundidad de la navegación deja de tener el mismo efecto que tenía en la estrategia anterior.
El cursor que recibe el cliente suele ser un token opaco. Puede contener los valores de las columnas utilizadas para determinar la posición y estar codificado, por ejemplo, mediante Base64URL.
El cliente no necesita conocer su estructura. Solo necesita conservarlo y devolverlo cuando solicite la siguiente página.
Esto permite que el backend cambie la representación interna del cursor sin obligar al cliente a interpretar sus componentes.
El requisito que hace posible un cursor
Para que este modelo funcione correctamente, el orden debe ser determinista.
Si varios registros tienen exactamente el mismo valor para el criterio principal, necesitamos una columna adicional que permita desempatar.
Por ejemplo:
ORDER BY created_at DESC, id ASC
Aquí created_at determina el orden principal y id permite distinguir registros que tienen la misma fecha.
Cuando el orden de negocio tiene varios niveles, todos ellos forman parte de la referencia.
Por ejemplo:
estado → created_at → id
El cursor deberá contener la información necesaria para reproducir esa posición dentro del orden.
El backend recibe el token, recupera esos valores y construye la condición correspondiente.
La contrapartida: la navegación deja de ser aleatoria
Aquí aparece la principal diferencia con OFFSET.
Un cursor representa un punto dentro del orden, no un número de página.
Por eso, si el usuario está recorriendo:
página 1 → página 2 → página 3 → página 4
es natural solicitar la siguiente posición.
Pero si quiere saltar directamente a la página 53, el cursor de esa página no puede calcularse simplemente a partir del número 53.
Esto significa que los cursores son especialmente adecuados cuando la navegación es secuencial.
No son simplemente una optimización de SQL: también representan un cambio en el contrato de la API y, en algunos casos, en la interfaz.
Una tabla tradicional basada en números de página no puede sustituirse por cursores manteniendo exactamente la misma semántica.
Cuándo encaja
La estrategia basada en cursores resulta especialmente apropiada para:
- Feeds.
- Historiales.
- Scroll infinito.
- Exportaciones secuenciales.
- Integraciones entre servicios.
- Tablas de gran volumen.
- Sistemas con escrituras frecuentes.
- Casos en los que el usuario no necesita saltar a una página arbitraria.
Qué no resuelve
No permite una navegación aleatoria equivalente a page=53.
Además, necesita un orden estable y determinista.
Cuando el requisito de navegación aleatoria es obligatorio, debemos buscar otra estrategia.
Cuando necesitas saltar directamente a una página
Supongamos ahora que el volumen es grande y que el usuario sí necesita ir directamente a una página concreta.
En ese caso, un cursor no encaja con el requisito principal de la interfaz.
Una posibilidad consiste en separar dos problemas que hasta ahora estaban mezclados: determinar qué registros ocupan cada posición y recuperar después los datos de esas posiciones.
La idea es construir un índice de navegación que contenga únicamente los identificadores de los registros que cumplen el filtro, en el orden correspondiente.
Por ejemplo:
SELECT id
FROM articulos
WHERE estado = :estado
AND categoria = :categoria
ORDER BY
CASE estado
WHEN 'BORRADOR' THEN 1
WHEN 'PUBLICADO' THEN 2
ELSE 3
END ASC,
created_at DESC,
id ASC;
El resultado conceptual sería:
[uuid_1, uuid_2, uuid_3, ..., uuid_N]
No estamos almacenando los artículos completos. Estamos almacenando el mapa que permite saber qué identificadores corresponden a cada posición.
Ese resultado puede guardarse en una caché utilizando como clave una representación de los filtros y del criterio de orden.
Por ejemplo:
hash(filtros + orden) → [id_1, id_2, id_3, ...]
Si la misma combinación se solicita nuevamente mientras el índice sigue siendo válido, puede reutilizarse.
Si el usuario cambia el filtro o el orden, cambia la clave y se genera otro índice.
A partir de ese mapa, solicitar la página 53 significa seleccionar las posiciones correspondientes.
Con un tamaño de página de 20:
página 53 → posiciones 1040 a 1059
Después, el sistema puede recuperar directamente los registros identificados:
SELECT *
FROM articulos
WHERE id = ANY(:ids_pagina);
Finalmente, debe reconstruir el mismo orden utilizado por el índice.
Una consecuencia importante: la consulta representa una fotografía
Esta estrategia introduce una propiedad que conviene hacer explícita.
El índice representa el conjunto de resultados en el momento en que fue construido.
Si se inserta un nuevo registro después de crear el índice, ese registro no tiene por qué aparecer en la navegación actual.
Si se elimina uno de los registros, el índice puede seguir haciendo referencia a un elemento que ya no existe y será necesario decidir cómo gestionar ese caso.
La ventaja es que el comportamiento deja de depender de desplazamientos implícitos entre solicitudes y pasa a formar parte explícita del diseño.
En otras palabras, el sistema está diciendo:
"Esta navegación corresponde a esta fotografía del conjunto de resultados."
Dependiendo del caso de uso, eso puede ser precisamente lo que se necesita.
El papel de los filtros
Aquí las características de los filtros que definimos antes adquieren importancia.
Si existen filtros discretos y relativamente acotados, es posible que diferentes usuarios soliciten repetidamente las mismas combinaciones.
Por ejemplo:
estado=PUBLICADO
categoria=TECNOLOGIA
orden=created_at
Ese tipo de consulta tiene más posibilidades de reutilizar un índice existente.
En cambio, si cada usuario puede especificar una fecha inicial, una fecha final, un precio mínimo, un precio máximo y otros parámetros arbitrarios, el número de combinaciones crece rápidamente.
En ese escenario, la caché puede tener menos oportunidades de reutilización y la generación del índice inicial puede convertirse en un costo importante.
Cuándo encaja
Este enfoque resulta especialmente interesante cuando:
- La navegación aleatoria es necesaria.
- El volumen de datos es elevado.
- Los filtros son relativamente discretos y repetibles.
- El orden de negocio es complejo.
- La misma combinación de filtros se consulta con frecuencia.
- Se dispone de infraestructura de caché.
Qué no resuelve
La generación inicial del índice sigue teniendo un costo proporcional al conjunto de resultados que debe procesar.
Además, mantener la caché introduce complejidad operacional: expiración, invalidación, memoria utilizada y comportamiento ante cambios en los datos.
Cuando los filtros dejan de ser discretos y pasan a incluir texto libre o rangos arbitrarios, puede ser necesario cambiar nuevamente de enfoque.
Cuando el problema ya es de búsqueda
Llegados a este punto, aparece un escenario diferente.
El problema ya no consiste únicamente en decidir cómo recorrer una lista grande.
Supongamos que el endpoint necesita combinar:
- Texto libre.
- Rangos arbitrarios.
- Múltiples dimensiones de filtrado.
- Orden dinámico.
- Millones de registros.
- Consultas frecuentes y concurrentes.
- Navegación sobre grandes conjuntos de resultados.
Aquí puede tener sentido utilizar un motor de búsqueda especializado.
Herramientas como Elasticsearch u OpenSearch utilizan estructuras de indexación diseñadas específicamente para búsquedas y filtrados complejos.
En lugar de depender exclusivamente del modelo de consulta de una tabla relacional, mantienen estructuras especializadas que permiten localizar documentos a partir de los términos y valores buscados.
También ofrecen mecanismos orientados a recorrer grandes conjuntos de resultados, como search_after, que permite continuar una búsqueda a partir de una posición determinada.
Esto cambia la naturaleza del problema.
Ya no estamos intentando hacer que una tabla relacional resuelva eficientemente cualquier combinación imaginable de búsqueda, filtro y orden.
Estamos utilizando una infraestructura especializada para ese patrón de acceso.
Pero esa decisión introduce un costo nuevo: ahora existe una infraestructura adicional que debe mantenerse sincronizada con la base de datos principal.
El flujo puede verse, conceptualmente, así:
Base de datos principal
↓
Proceso de sincronización
↓
Índice de búsqueda
Esto obliga a resolver preguntas que no existían con una única base de datos:
- ¿Cuándo se actualiza el índice?
- ¿Qué ocurre si la sincronización falla?
- ¿Cuánto retraso puede existir entre ambos sistemas?
- ¿Cuál es la fuente de verdad?
- ¿Cómo se reconstruye el índice?
- ¿Cómo se monitoriza?
Por eso, un motor de búsqueda no debería incorporarse simplemente porque OFFSET sea lento.
Tiene sentido cuando las necesidades de búsqueda y filtrado justifican la infraestructura adicional.
Cuándo encaja
Puede resultar adecuado cuando:
- El texto libre es una parte central del caso de uso.
- Existen rangos y filtros complejos.
- Se combinan múltiples dimensiones de búsqueda.
- El volumen de datos es elevado.
- Las consultas son frecuentes y concurrentes.
- La base de datos principal ya no ofrece una solución eficiente para el patrón de búsqueda requerido.
Qué no resuelve
No elimina la complejidad: la desplaza hacia la arquitectura.
Aparecen nuevos componentes, sincronización, monitorización, gestión de índices y una nueva forma de consultar los datos.
Si una solución más sencilla satisface los requisitos, incorporar otro sistema puede ser innecesario.
El árbol de decisión
A estas alturas, las cuatro estrategias ya no aparecen como alternativas aisladas. Cada una responde a las condiciones que acabamos de analizar.
El recorrido puede resumirse así:
flowchart TD
A([Necesito paginar una tabla])
--> B{¿Volumen moderado<br/>y páginas superficiales?}
B -->|Sí| S1[OFFSET con índices<br/>bien diseñados]
B -->|No| C{¿El usuario necesita<br/>saltar a páginas arbitrarias?}
C -->|No — navegación secuencial| D{¿El orden puede ser<br/>determinista?}
D -->|Sí| S2[Cursor-based pagination<br/>CursorRequest + CursorPage<T>]
D -->|No| D2[Añadir una columna<br/>de desempate única al orden]
D2 --> D
C -->|Sí — acceso aleatorio| E{¿Los filtros son<br/>discretos y acotados?}
E -->|Sí| S3[Índice de navegación con caché<br/>Hash del filtro → IDs en Redis]
E -->|No| F{¿La búsqueda y el volumen<br/>justifican infraestructura adicional?}
F -->|Sí| S4[Motor de búsqueda dedicado<br/>Elasticsearch / OpenSearch]
F -->|No| S1b[OFFSET con límite<br/>de profundidad]
El árbol no debe interpretarse como una fórmula rígida.
Por ejemplo, el número de registros por sí solo no determina qué estrategia utilizar. Lo que importa es cómo se combina ese volumen con la profundidad de navegación, el patrón de consulta, los filtros, el orden y la frecuencia de acceso.
El objetivo del árbol es obligarnos a formular las preguntas correctas antes de introducir complejidad.
Lo que cada solución no resuelve
Después de recorrer las alternativas, aparece una conclusión importante: ninguna estrategia elimina el costo de la paginación; cada una lo desplaza hacia un lugar diferente.
OFFSET mantiene un contrato sencillo y permite navegar directamente a páginas arbitrarias. Su costo aparece principalmente cuando la profundidad aumenta y los datos son numerosos.
Los cursores reducen el trabajo asociado con posiciones profundas y funcionan especialmente bien para navegación secuencial. A cambio, el cliente deja de trabajar con páginas numéricas y debe conservar un punto de referencia.
El índice de navegación con caché permite recuperar páginas arbitrarias a partir de un mapa previamente construido. A cambio, introduce un costo inicial y una infraestructura adicional para almacenar y gestionar ese mapa.
El motor de búsqueda permite resolver escenarios donde el problema ya no es solamente paginar, sino buscar y filtrar grandes volúmenes de información. A cambio, introduce otra pieza de infraestructura y la necesidad de mantenerla coordinada con la fuente de datos principal.
Por eso, la decisión no consiste en encontrar una solución que no tenga costos.
Consiste en decidir qué costo es aceptable para el contexto concreto del sistema.
Volver al endpoint que "simplemente funcionaba"
Cuando aquel endpoint que inicialmente "simplemente funcionaba" empieza a mostrar tiempos de respuesta cada vez mayores, es tentador concluir que OFFSET fue una mala decisión desde el principio.
Pero esa conclusión sería demasiado simple.
OFFSET puede ser una solución perfectamente válida para determinados escenarios.
El problema aparece cuando cambian las condiciones para las que fue elegido y nadie revisa la decisión.
Una tabla que comenzó con 20.000 registros puede terminar teniendo varios millones.
Una interfaz que inicialmente mostraba cinco páginas puede terminar necesitando búsqueda avanzada.
Un listado que solo se consultaba ocasionalmente puede convertirse en una de las rutas más utilizadas de la aplicación.
Y un criterio de orden sencillo puede terminar acompañado de múltiples filtros y reglas de negocio.
Por eso, la pregunta importante no es si OFFSET es bueno o malo.
La pregunta es si sigue siendo adecuado para las condiciones actuales del endpoint.
Ese cambio de perspectiva también modifica la forma de diseñar la solución desde el principio.
Antes de implementar la paginación, conviene conocer:
- cómo navegará el usuario;
- si necesita saltos arbitrarios;
- qué filtros tendrá disponibles;
- si esos filtros son discretos o abiertos;
- cómo se ordenarán los resultados;
- si ese orden es determinista;
- cuánto volumen existe actualmente;
- cuánto se espera que crezca;
- y cuánto pueden cambiar los datos mientras el usuario navega.
Con esa información, OFFSET, cursores, un índice de navegación o un motor de búsqueda dejan de ser decisiones basadas en preferencias técnicas.
Se convierten en respuestas a requisitos concretos.
Y esa es probablemente la idea más importante de todo el problema: la paginación no debería elegirse por costumbre ni por la tecnología que tenemos disponible, sino por la forma en que el endpoint realmente necesita ser utilizado.
La tarea puede seguir siendo "agregar paginación al listado de artículos".
Lo que cambia es la pregunta que hacemos antes de tocar el código.
No:
"¿Cuál es la mejor técnica de paginación?"
Sino:
"¿Qué tipo de navegación, filtrado, orden y volumen necesita realmente este endpoint?"
A partir de ahí, la implementación deja de ser una decisión aislada y pasa a formar parte del diseño técnico del sistema.
Y cuando el volumen, los filtros o la forma de navegación cambien, la estrategia puede revisarse de nuevo.
Ese punto de revisión es tan importante como la decisión inicial.
El Arte de Filtrar Datos en HTTP: Entre la Elegancia de la URL y la Potencia del Payload
- Mauricio ECR
- Arquitectura
- 12 Sep, 2026
El Arte de Filtrar Datos en HTTP: Entre la Elegancia de la URL y la Potencia del Payload Seguro que alguna vez empezaste con un endpoint de listado que parecía no necesitar demasiado. Un par de fil
El Arte de Filtrar Datos en HTTP: Entre la Elegancia de la URL y la Potencia del Payload
- Mauricio ECR
- Arquitectura
- 12 Sep, 2026
El Arte de Filtrar Datos en HTTP: Entre la Elegancia de la URL y la Potencia del Payload
Seguro que alguna vez empezaste con un endpoint de listado que parecía no necesitar demasiado. Un par de filtros simples: nombre, categoría, quizá un estado. Todo cabía cómodamente en una URL como ?categoria=electronica&estado=activo, era fácil de leer y nadie tenía demasiadas razones para cuestionar la decisión.
El problema aparece cuando esa pantalla deja de ser simple.
Alguien de producto pide filtrar por varias categorías al mismo tiempo, añadir un rango de precios, restringir por fecha de publicación y, además, ordenar por relevancia. Nada especialmente extraño: es el tipo de evolución que termina teniendo cualquier sistema de búsqueda que crece un poco. Lo que cambia no es la necesidad funcional, sino la dificultad de representar esa necesidad dentro de HTTP.
Y es ahí donde una decisión que parecía puramente sintáctica empieza a afectar cosas bastante más importantes: la legibilidad de las URLs, su capacidad para compartirse, el caché, la infraestructura que las procesa y, finalmente, la forma en que el propio código del backend tiene que recibirlas.
Cuando los filtros dejan de caber cómodamente en una URL
La primera reacción suele ser intentar mantener el modelo que ya funciona y llevarlo un poco más lejos. Si un parámetro alcanza para un filtro, quizá varios parámetros puedan representar varios filtros.
Pronto aparece algo parecido a esto:
filter[0][field]=categoria&filter[0][op]=in&filter[0][value][]=electronica&filter[0][value][]=hogar&filter[1][field]=precio&filter[1][op]=between&filter[1][value][]=100000&filter[1][value][]=500000
El problema no es que esta representación sea imposible. El problema es que, a medida que aumenta la complejidad, empieza a depender de una sintaxis que la propia API tiene que inventar y documentar. No existe una convención universal que garantice que otro equipo, otro framework o incluso otro endpoint vaya a interpretar exactamente esa estructura de la misma manera.
Con dos condiciones todavía se puede tolerar. Con tres o cuatro, la URL deja de comunicar lo que está ocurriendo y pasa a convertirse en una estructura que hay que descifrar.
La siguiente idea parece más atractiva precisamente porque elimina esa sintaxis inventada: si el filtro es estructurado, ¿por qué no representarlo directamente como JSON?
Por ejemplo:
{
"and": [
{
"field": "categoria",
"op": "in",
"value": ["electronica", "hogar"]
}
]
}
Conceptualmente es mucho mejor. La estructura expresa exactamente lo que se quiere consultar y no hace falta inventar una notación propia para representar operadores, campos y valores.
El problema llega cuando ese JSON tiene que viajar dentro de una URL.
Después de aplicar URL encoding, una estructura que originalmente era legible termina convertida en una secuencia como esta:
%7B%22and%22%3A%5B%7B%22field%22%3A%22categoria%22%2C%22op%22%3A%22in%22%2C%22value%22%3A%5B%22electronica%22%2C%22hogar%22%5D%7D%5D%7D
La información sigue estando ahí. El problema es que dejó de ser visible.
Esto parece una molestia estética hasta que aparece un caso real de diagnóstico. Una URL así puede terminar en un mensaje de chat del equipo, en Postman, en un log de producción o en un ticket donde alguien intenta explicar por qué una búsqueda devolvió resultados inesperados. En ninguno de esos escenarios resulta evidente qué filtro representa la cadena.
Hay que decodificarla antes de poder entenderla.
Y entonces aparece una tercera posibilidad, casi inevitable: abandonar la URL y enviar el JSON en el body de un POST.
La solución fácil también cambia el contrato
La propuesta de utilizar POST tiene una ventaja evidente: el filtro vuelve a verse exactamente como lo que es.
POST /productos/search
Content-Type: application/json
{
"and": [
{
"field": "categoria",
"op": "in",
"value": ["electronica", "hogar"]
},
{
"field": "precio",
"op": "between",
"value": [100000, 500000]
}
]
}
Desde el punto de vista de representación, es probablemente la solución más cómoda. Pero resuelve el problema de legibilidad introduciendo otro: la búsqueda ya no está contenida en una URL que pueda copiarse, pegarse y compartir como un recurso de lectura.
Además, aunque el caché de respuestas POST es posible con configuraciones específicas, no tiene la misma interoperabilidad ni la misma naturalidad que el caché de un GET. Una petición GET encaja directamente con las expectativas de navegadores, proxies, CDN y clientes HTTP para operaciones de lectura.
Esto no significa que POST sea incorrecto. Significa que, al elegirlo, se está renunciando deliberadamente a ciertas propiedades.
Y esa distinción es importante porque permite formular mejor el problema. No se trata de encontrar una opción universalmente correcta entre query params, JSON en la URL y POST. Se trata de decidir qué propiedad del contrato HTTP se necesita conservar.
Si la búsqueda debe poder compartirse como enlace y además interesa mantenerla como GET, todavía quedan alternativas que merece la pena explorar — y antes de comprometerse con una, conviene entender de qué depende cada una.
Dos caminos para conservar el GET, con distinta dependencia
Cuando la meta es mantener la búsqueda como GET compartible sin caer en URLs ilegibles, hay dos familias de solución, y no son intercambiables porque no piden lo mismo al resto del sistema.
Una es codificar el filtro tal cual el frontend lo pensó (un objeto JSON con la forma que el dominio necesite) y transportarlo como un blob opaco en un único parámetro. Esta idea no le exige nada a la capa de persistencia: el servidor recibe una cadena, la decodifica, obtiene un JSON y lo interpreta como quiera. Es agnóstica al motor de datos por completo.
La otra es expresar el filtro directamente como una gramática de texto pensada para mapear campo-operador-valor, apoyándose en que exista una capa de acceso a datos capaz de traducir esa gramática a una consulta ejecutable. Esta idea sí depende de algo externo a la propia idea: solo funciona si hay un traductor gramática→query, típicamente provisto por un ORM con soporte de construcción dinámica de condiciones (Criteria API en el mundo Java, query builders equivalentes en otros stacks).
Esa diferencia — necesitar o no una capa de traducción externa — es la que determina cuál conviene en cada caso, y conviene resolverla antes de escribir una sola línea de código.
El camino agnóstico: JSON opaco vía Base64URL
Cuando el filtro es estructurado pero sigue siendo razonablemente pequeño, una posibilidad es serializarlo como JSON y después codificar ese JSON utilizando Base64URL.
El flujo conceptual es sencillo:
- Se construye el filtro como un objeto.
- Se serializa a JSON.
- Ese JSON se codifica mediante Base64URL.
- El resultado se envía como un único parámetro de query.
- El servidor lo decodifica y recupera nuevamente el JSON.
La diferencia importante es que no se intenta hacer legible el contenido del filtro dentro de la URL. Se acepta que sea opaco, pero se consigue que la representación sea mucho más adecuada para convivir con la sintaxis de una URL.
También conviene precisar el nombre. No se trata simplemente de Base64 tradicional.
Base64 utiliza caracteres como +, / y =, que pueden requerir tratamiento adicional cuando aparecen dentro de una URL. Base64URL, definido en la sección 5 del RFC 4648, utiliza - y _ en lugar de esos caracteres y permite omitir el padding cuando el protocolo que lo utiliza lo contempla.
Tomemos el filtro completo:
{
"and": [
{
"field": "categoria",
"op": "in",
"value": ["electronica", "hogar"]
},
{
"field": "precio",
"op": "between",
"value": [100000, 500000]
}
]
}
Después de codificarlo, la petición puede quedar así:
GET /productos?filter=eyJhbmQiOlt7ImZpZWxkIjoiY2F0ZWdvcmlhIiwib3AiOiJpbiIsInZhbHVlIjpbImVsZWN0cm9uaWNhIiwiaG9nYXIiXX0seyJmaWVsZCI6InByZWNpbyIsIm9wIjoiYmV0d2VlbiIsInZhbHVlIjpbMTAwMDAwLDUwMDAwMF19XX0
La cadena sigue siendo opaca. No pretende ser legible para una persona. Pero ya no está dominada por secuencias %XX, y lo más importante es que la operación continúa siendo un GET.
Eso permite conservar varias propiedades útiles: la búsqueda puede copiarse, guardarse y compartirse como enlace y, cuando la infraestructura está configurada para ello, la respuesta puede aprovechar el caché asociado a una petición GET.
Hay, eso sí, una condición que no conviene esconder: el filtro debe seguir teniendo un tamaño razonable. Si la consulta contiene decenas de condiciones, listas enormes o estructuras profundamente anidadas, el problema deja de ser cómo codificarla y empieza a ser cuánto sentido tiene seguir transportándola dentro de una URL.
Pero para una búsqueda con un conjunto moderado de filtros, Base64URL puede ser un punto intermedio razonable.
Lo que queda pendiente es hacer que el backend pueda consumir esa representación sin convertir cada controlador en responsable de la decodificación.
El camino dependiente de la persistencia: RSQL
Existe otra manera de mantener el GET sin sacrificar legibilidad, pero que parte de una premisa distinta: en lugar de esconder el filtro dentro de un blob opaco, expresarlo directamente como texto legible dentro del propio query param.
RSQL es una extensión de FIQL (Feed Item Query Language) que permite escribir eso: una gramática de comparaciones encadenadas con AND/OR, pensada para mapear uno a uno contra atributos de una entidad. La URL sigue siendo un GET, sigue siendo compartible, y además no pierde legibilidad:
GET /productos?filter=categoria=in=(electronica,hogar);precio=ge=100000;precio=le=500000
El punto y coma es AND, la coma es OR, los paréntesis agrupan. Los paths de relaciones se escriben con notación de punto:
GET /productos?filter=marca.pais==Argentina;estado!=suspendido
Comparado con Base64URL, la ganancia es evidente: no hace falta decodificar nada para entender qué se está pidiendo. La cadena es el filtro.
Pero esa legibilidad no es gratis, y ahí está el punto que conviene no pasar por alto: RSQL no resuelve nada por sí mismo. Es una gramática de texto; alguien tiene que parsearla y, sobre todo, alguien tiene que traducirla a una consulta ejecutable contra el almacenamiento real. A diferencia de Base64URL — donde el servidor simplemente decodifica y deserializa un JSON con sus propias reglas —, RSQL necesita que exista, entre el parser y la base de datos, una capa capaz de convertir cada nodo de comparación en una condición de persistencia. Sin esa capa, RSQL es solo una cadena de texto sin ningún lugar donde ejecutarse.
En el ecosistema Java, esa capa la provee típicamente la Criteria API de JPA a través de una Specification, y existen librerías (no oficiales de Spring, sino de terceros) que hacen ese puente automáticamente. En otros stacks el concepto se mantiene — una gramática campo-operador-valor traducida a un query builder dinámico —, pero el mecanismo concreto cambia según el ORM disponible. Quien quiera ver cómo se ve esa integración en un proyecto Spring Boot real puede saltar directamente al anexo al final del artículo.
Esa dependencia tiene además una consecuencia de seguridad que conviene resolver antes de decidirse por este camino, no después.
El vector de seguridad que ninguna gramática resuelve por defecto
Cualquier mecanismo que traduzca texto libre a una condición de persistencia enfrenta el mismo riesgo, independientemente del lenguaje o el framework: el parser no sabe, por sí solo, qué campos del modelo deberían ser alcanzables desde afuera.
Si una entidad tiene una relación hacia otra entidad con datos sensibles, nada en la gramática impide que un consumidor intente comparar directamente contra esos campos:
GET /productos?filter=usuario.password==algo
GET /productos?filter=usuario.resetToken==abc123
El sistema no devolvería esos valores en el body, pero sí variaría el número de resultados según si la condición se cumple o no. Es suficiente para montar un ataque de oráculo e inferir, comparación a comparación, valores que nunca deberían ser accesibles desde un filtro de listado.
La mitigación es siempre la misma en cualquier stack: recorrer la estructura del filtro ya parseado (su AST) antes de ejecutar la consulta y rechazar cualquier campo que no esté en una allowlist explícita, y componer esa consulta junto con las condiciones base que el usuario nunca debería poder eludir (soft-delete, tenant, permisos).
Esto es exactamente lo que distingue este camino del de Base64URL con un DTO tipado: ahí, los campos filtrables son exactamente los que el desarrollador declaró en la clase. No hay forma de filtrar por usuario.resetToken si ese campo no existe en el tipo — la allowlist es estructural, la garantiza el propio DTO. En el camino de la gramática de texto, en cambio, la allowlist es una responsabilidad operacional: hay que construirla y mantenerla activamente, y es un paso que se puede omitir bajo presión sin que nada lo impida a nivel de compilación.
Cuándo conviene cada camino
La pregunta que separa un camino del otro no es cuál es "mejor", sino qué tan bien el filtro se mapea contra atributos simples de una entidad persistida:
- Si los filtros son AND/OR sobre atributos que existen tal cual en el modelo de datos, y se cuenta con una capa de persistencia capaz de traducir esa gramática (Specification, Criteria API o equivalente), una gramática de texto como RSQL resuelve el problema con muy poco código y conserva la legibilidad de la URL — a cambio de mantener activamente una allowlist.
- Si el contrato del filtro es propio del dominio, con operadores específicos, estructuras anidadas particulares o simplemente no hay (o no se quiere depender de) una capa de traducción gramática→persistencia, Base64URL con un DTO tipado ofrece un contrato explícito, no le pide nada al motor de datos, y su allowlist es estructural en lugar de operacional.
Esta bifurcación es la que después vuelve a aparecer, ya formalizada, en el árbol de decisión al final del artículo.
Llevar la idea de Base64URL a un endpoint real con Spring Boot
En cualquier framework aparece la misma pregunta: alguien tiene que recibir el parámetro, decodificarlo, convertir el JSON en el DTO correspondiente y manejar los errores que puedan producirse durante el proceso.
En Spring Boot, la solución más directa consiste en hacerlo dentro del controlador:
@GetMapping("/productos")
public ResponseEntity<List<ProductoDto>> buscar(
@RequestParam("filter") String filterBase64,
ObjectMapper objectMapper) throws IOException {
byte[] jsonBytes = Base64.getUrlDecoder().decode(filterBase64);
FiltroProductosDto filtro =
objectMapper.readValue(jsonBytes, FiltroProductosDto.class);
return ResponseEntity.ok(productoService.buscar(filtro));
}
Para un solo endpoint, no hay nada especialmente problemático en este enfoque. El inconveniente aparece cuando el sistema crece.
Si existen diez endpoints de búsqueda, probablemente aparezcan diez implementaciones prácticamente iguales. Y entonces la cuestión deja de ser si podemos decodificar Base64URL y pasa a ser si queremos mantener esa lógica repartida por todos los controladores.
En ese punto aparecen problemas de consistencia bastante concretos.
¿Todos los endpoints manejan el padding de la misma forma? ¿Todos distinguen entre un Base64URL inválido y un JSON inválido? ¿Todos responden de la misma manera cuando falta el parámetro? ¿Todos deserializan el contenido con las mismas reglas? ¿Todos aplican las mismas validaciones?
La duplicación empieza a convertir una decisión de transporte en una responsabilidad del código de negocio.
Una solución más limpia consiste en hacer que el controlador reciba directamente el DTO que necesita:
@GetMapping("/productos")
public ResponseEntity<List<ProductoDto>> buscar(
@Base64Filter @Valid FiltroProductosDto filtro) {
return ResponseEntity.ok(productoService.buscar(filtro));
}
El controlador ya no necesita saber que el filtro llegó codificado. Esa información pertenece a la capa que transforma la petición HTTP en los argumentos del método.
Spring MVC proporciona precisamente un mecanismo para realizar esa transformación: HandlerMethodArgumentResolver.
Sacar la infraestructura del controlador
El primer paso es definir una anotación que identifique los parámetros que deben resolverse mediante este mecanismo:
import java.lang.annotation.*;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Base64Filter {
String value() default "filter";
}
El valor permite mantener filter como nombre por defecto, pero también utilizar otro parámetro cuando el endpoint lo necesite:
@Base64Filter("criteria")
FiltroProductosDto filtro
La anotación por sí sola no hace la transformación. El trabajo lo realiza el resolver.
Aquí aparece además una cuestión importante que puede pasar desapercibida: cuando Spring construye automáticamente un objeto mediante mecanismos como @RequestBody, el ciclo de validación habitual puede ejecutarse de forma transparente. Cuando el objeto lo construimos manualmente dentro de un HandlerMethodArgumentResolver, esa validación no aparece automáticamente solo porque hayamos escrito @Valid.
Por eso el resolver tiene que encargarse también de esa parte.
Una implementación posible es:
@Component
public class Base64FilterArgumentResolver implements HandlerMethodArgumentResolver {
private final ObjectMapper objectMapper;
private final Validator validator;
public Base64FilterArgumentResolver(ObjectMapper objectMapper, Validator validator) {
this.objectMapper = objectMapper;
this.validator = validator;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(Base64Filter.class);
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
Base64Filter annotation = parameter.getParameterAnnotation(Base64Filter.class);
String paramName = annotation.value();
String rawValue = webRequest.getParameter(paramName);
Class<?> targetType = parameter.getParameterType();
Object filtro;
if (rawValue == null || rawValue.isBlank()) {
filtro = targetType.getDeclaredConstructor().newInstance();
} else {
try {
byte[] jsonBytes = Base64.getUrlDecoder().decode(rawValue);
filtro = objectMapper.readValue(jsonBytes, targetType);
} catch (IllegalArgumentException e) {
throw new FiltroInvalidoException(
"El parámetro '" + paramName + "' no es Base64URL válido");
} catch (JacksonException e) {
throw new FiltroInvalidoException(
"El parámetro '" + paramName + "' no contiene un JSON de filtro válido");
}
}
if (parameter.hasParameterAnnotation(Valid.class)) {
Set<ConstraintViolation<Object>> violations = validator.validate(filtro);
if (!violations.isEmpty()) {
throw new FiltroInvalidoException(
"El filtro no cumple las validaciones: " + violations);
}
}
return filtro;
}
}
Hay varias decisiones dentro de este código que conviene entender porque son parte del contrato y no simples detalles de implementación.
El método supportsParameter hace que el resolver intervenga únicamente cuando el parámetro tiene la anotación @Base64Filter. Esto mantiene el comportamiento localizado y evita que todos los parámetros de la aplicación intenten pasar por la misma lógica.
Después se obtiene el nombre del parámetro desde la anotación y se recupera su valor desde la petición. Si no existe o está vacío, el ejemplo construye una instancia vacía del DTO. Esa decisión puede ser apropiada cuando la ausencia del filtro significa "sin restricciones", pero no debería convertirse en una regla universal. Si en el dominio la ausencia del parámetro tiene otro significado, ese comportamiento debe reflejarlo.
Cuando sí existe un valor, primero se decodifica Base64URL y después se deserializa el JSON utilizando ObjectMapper.
Las dos operaciones pueden fallar por motivos diferentes, y distinguirlas permite devolver mensajes de error más útiles. Un contenido que no es Base64URL válido no representa el mismo problema que una cadena correctamente codificada que contiene un JSON malformado.
Una vez construido el objeto, el resolver comprueba si el parámetro utiliza @Valid. Solo en ese caso ejecuta explícitamente Bean Validation.
Eso permite mantener en el DTO las reglas habituales:
public class FiltroProductosDto {
@Size(
max = 10,
message = "No se permiten más de 10 categorías"
)
private List<String> categorias;
@Min(0)
private Long precioMinimo;
@Min(0)
private Long precioMaximo;
// getters y setters
}
De esta manera, colocar @Valid junto con @Base64Filter activa las validaciones sintácticas habituales sin introducir lógica adicional en el controlador.
Es importante, sin embargo, no confundir validación sintáctica con validación de negocio.
Una regla como "no se permiten más de diez categorías" o "el precio mínimo no puede ser negativo" pertenece naturalmente a esta capa. En cambio, comprobar que precioMinimo sea menor que precioMaximo, que una categoría exista realmente o que determinado filtro esté permitido para un usuario concreto puede requerir información que el resolver no debería conocer.
Esas decisiones pertenecen a una capa posterior, normalmente al servicio o a la lógica de dominio.
Registrar el resolver y completar la separación
Una vez creado el resolver, hay que registrarlo en Spring MVC:
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final Base64FilterArgumentResolver resolver;
public WebConfig(Base64FilterArgumentResolver resolver) {
this.resolver = resolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(resolver);
}
}
A partir de ahí, el controlador puede mantenerse completamente centrado en su responsabilidad:
@GetMapping("/productos")
public ResponseEntity<List<ProductoDto>> buscar(
@Base64Filter @Valid FiltroProductosDto filtro) {
return ResponseEntity.ok(
productoService.buscar(filtro)
);
}
El beneficio real de esta arquitectura no es que Spring haya aprendido a decodificar Base64URL. El beneficio es haber separado dos decisiones que no deberían estar mezcladas.
Una decisión responde a cómo viaja el filtro por HTTP.
La otra responde a qué filtro necesita el caso de uso.
El controlador solo debería preocuparse por la segunda.
Cuando llegue el endpoint de búsqueda número once, no será necesario copiar la misma secuencia de decodificación, deserialización y validación. El mecanismo de transporte queda encapsulado en una pieza transversal y los endpoints pueden expresar únicamente el contrato que necesitan.
Pero conseguir que el código quede limpio no significa que el diseño esté terminado. Hay varias cuestiones que el resolver no puede solucionar por sí solo.
Codificar no significa proteger
La primera es seguridad.
Base64URL no cifra el contenido. Tampoco lo convierte en secreto. Cualquier persona que tenga acceso a la cadena puede decodificarla y recuperar el JSON original.
Por eso nunca debe interpretarse la codificación como una medida de protección.
Después de deserializar el filtro siguen siendo necesarias las comprobaciones correspondientes: campos permitidos, operadores válidos, tipos esperados, tamaños máximos y, cuando la estructura lo permita, profundidad máxima de anidamiento.
Esto es especialmente relevante cuando el filtro admite estructuras complejas. Una petición puede ser perfectamente válida desde el punto de vista sintáctico y, aun así, provocar un consumo excesivo de CPU o memoria si el servidor acepta estructuras arbitrariamente grandes o profundas.
La validación, por tanto, no es un accesorio que se añade después de implementar Base64URL. Forma parte del contrato de entrada.
Los errores también forman parte del diseño
El segundo punto es el manejo de errores.
El resolver puede lanzar FiltroInvalidoException, pero eso no significa que cada controlador deba capturarla individualmente. La aplicación puede centralizar la conversión de esa excepción en una respuesta HTTP uniforme:
@RestControllerAdvice
public class FiltroExceptionHandler {
@ExceptionHandler(FiltroInvalidoException.class)
public ResponseEntity<ErrorResponse> handleFiltroInvalido(FiltroInvalidoException ex) {
return ResponseEntity.badRequest().body(new ErrorResponse(ex.getMessage()));
}
}
Esto elimina otro nivel de duplicación, pero todavía conviene conservar internamente las distintas categorías de fallo.
No es lo mismo recibir un Base64URL inválido que recibir un JSON malformado. Tampoco es lo mismo un JSON válido que no puede convertirse al DTO esperado, un DTO que incumple las validaciones, o un filtro completamente válido que no está permitido para determinado usuario.
No siempre es necesario exponer todas esas diferencias al consumidor. De hecho, en algunos casos sería contraproducente. Pero mantenerlas diferenciadas internamente puede resultar decisivo cuando haya que investigar un comportamiento extraño en producción.
La uniformidad hacia fuera no debería implicar perder información hacia dentro.
Si el consumidor no sabe qué codificar, el contrato está incompleto
Hay todavía otra pieza que suele olvidarse porque no afecta directamente al funcionamiento del endpoint: la documentación.
Una anotación personalizada como @Base64Filter no necesariamente será interpretada automáticamente por herramientas como springdoc-openapi como un parámetro de query convencional. Si el consumidor no recibe una explicación explícita, terminará teniendo que averiguar por su cuenta cómo construir la cadena.
Por ejemplo:
@Operation(
summary = "Búsqueda de productos con filtro estructurado"
)
@Parameter(
name = "filter",
description =
"Filtro JSON codificado en Base64URL según RFC 4648 §5",
example =
"eyJhbmQiOlt7ImZpZWxkIjoiY2F0ZWdvcmlhIn1dfQ",
schema = @Schema(type = "string")
)
@GetMapping("/productos")
public ResponseEntity<List<ProductoDto>> buscar(
@Base64Filter @Valid FiltroProductosDto filtro) {
return ResponseEntity.ok(
productoService.buscar(filtro)
);
}
Una buena documentación debería mostrar las dos representaciones.
Por un lado, el JSON conceptual que el consumidor realmente quiere expresar. Por otro, la cadena Base64URL que debe enviar.
La transformación puede ser transparente para el servidor, pero no debe ser un misterio para quien consume la API.
El coste que aparece después: una URL que ya no se puede leer
Hasta este punto, Base64URL parece haber conseguido un equilibrio bastante atractivo: mantiene GET, conserva una URL autocontenida y evita llenar el query string de secuencias de escape.
Pero ese equilibrio tiene un precio evidente.
La URL deja de ser legible.
Un log como este:
GET /productos?filter=eyJhbmQiOlt7ImZpZWxkIjoiY2F0ZWdvcmlhI...
no permite saber inmediatamente qué filtros se utilizaron.
Y eso importa especialmente porque las URLs aparecen en muchos lugares que no están bajo el control directo del desarrollador que diseñó el endpoint: logs de servidores, herramientas de monitoreo, historial del navegador, sistemas de analítica y mecanismos de diagnóstico.
Es exactamente el costo que, como vimos antes, RSQL evita — a cambio de pedir una capa de traducción a persistencia y una allowlist mantenida activamente. Ninguna de las dos alternativas gana en todo; cada una conserva una propiedad distinta.
Cuándo RSQL se queda corto
Incluso en el escenario donde sí conviene la gramática de texto — filtros que mapean bien a atributos y operadores relacionales estándar —, hay límites concretos donde deja de alcanzar.
El primero es semántica propia. Un operador =between= con dos valores puede registrarse como extensión del parser, pero si el dominio necesita expresar algo como un rango con extremos configurables o un operador geoespacial con parámetros propios, la gramática obliga a inventar una notación que ya no es estándar. El DTO tipado puede incluir esos campos con semántica explícita.
El segundo es correlación en colecciones. Si Producto tiene una lista de Variante y la consulta necesita encontrar productos que tengan una variante que sea simultáneamente roja y con stock mayor a cero, una traducción directa campo-operador-valor genera un JOIN que evalúa las condiciones por separado. Puede devolver un producto con una variante roja sin stock y otra variante con stock pero de distinto color. Para expresar esa correlación hace falta un subquery o un EXISTS, que la gramática no modela.
El tercero es contexto de runtime. Un score de relevancia calculado en el momento de la petición, una distancia geoespacial o un ranking de machine learning no tienen representación natural como par campo-operador-valor.
Cuando el filtro cruza alguno de esos límites, Base64URL con DTO tipado vuelve a ser la opción más honesta: el contrato es explícito, los campos posibles están acotados por el tipo y la estructura puede ser tan específica como lo exija el dominio.
Cuando el filtro crece, el problema deja de ser de codificación
Hasta aquí estamos hablando de un escenario bastante concreto: filtros estructurados pero todavía razonablemente pequeños.
La situación cambia cuando la consulta empieza a crecer.
Decenas de condiciones. Listas extensas de identificadores. Estructuras anidadas varios niveles hacia abajo. Combinaciones complejas de operadores.
En ese momento, insistir en mantener todo dentro de una URL deja de ser una cuestión de encontrar una codificación más conveniente.
Se convierte en una decisión de diseño de interfaz.
Y es precisamente ahí donde POST vuelve a tener sentido, pero esta vez por una razón diferente a la del comienzo.
No se utiliza porque no hayamos encontrado una manera suficientemente ingeniosa de meter el filtro en un GET. Se utiliza porque el contrato ha cambiado y ahora el body es un lugar más apropiado para transportar una estructura grande.
POST /productos/search
Content-Type: application/json
{
"and": [
{
"field": "categoria",
"op": "in",
"value": ["electronica", "hogar"]
},
{
"field": "precio",
"op": "between",
"value": [100000, 500000]
}
]
}
Se gana una representación natural del filtro y se evita someterlo a las limitaciones prácticas de una URL.
A cambio, la búsqueda deja de ser un recurso autocontenido en forma de enlace y pierde la misma naturalidad para el caché que tendría un GET.
Pero si el filtro ya ha alcanzado un tamaño que hace poco razonable mantenerlo en la URL, esa pérdida probablemente sea aceptable.
Una tercera posibilidad: QUERY
Existe además una alternativa más reciente que resulta conceptualmente interesante: el método HTTP QUERY.
La motivación es precisamente separar la semántica de una operación de consulta de la necesidad de transportar una estructura compleja en el body.
En teoría, encaja muy bien con el problema: una consulta segura podría mantener una semántica específica de consulta y, al mismo tiempo, recibir contenido estructurado sin tener que comprimirlo artificialmente dentro de la URL.
El inconveniente está en el ecosistema.
Que un método esté definido mediante un estándar no significa que navegadores, proxies, firewalls, gateways y otras piezas de infraestructura lo soporten con la misma madurez que GET o POST.
Por eso puede ser una opción interesante para mantener en el radar, especialmente cuando se controla de extremo a extremo la infraestructura, pero no necesariamente es la elección más conservadora para una API pública que necesita máxima interoperabilidad.
Cuando ni siquiera el body es suficiente
Hay todavía un escenario más extremo.
Supongamos que la consulta es muy grande, pero además se reutiliza constantemente. Enviar todo el filtro en cada petición puede empezar a resultar innecesario.
En ese caso, quizá el problema ya no consista en decidir cómo transportar el filtro, sino en dejar de transportarlo.
Una alternativa es convertir la búsqueda en un recurso persistente:
POST /saved-searches
Content-Type: application/json
{
"and": [
{
"field": "categoria",
"op": "in",
"value": ["electronica", "hogar"]
}
]
}
El servidor puede almacenar esa definición y devolver un identificador:
HTTP/1.1 201 Created
Location: /saved-searches/abc123
A partir de ahí, las peticiones posteriores pueden referirse al identificador en lugar de reenviar toda la estructura.
La decisión es diferente porque también lo es el problema.
Ya no estamos intentando representar una búsqueda compleja dentro de una URL. Estamos modelando la búsqueda como un recurso que puede persistir y reutilizarse.
Del lado del frontend
Todas estas decisiones del backend tienen una contraparte en el cliente que construye la petición.
El problema no es solo cómo codificar el filtro. Es que si el filtro vive únicamente en el estado del componente, el usuario pierde la posibilidad de compartir la búsqueda, usar el botón atrás del navegador o guardarla como favorito. La URL deja de ser el estado de la pantalla.
La solución en Angular es sincronizar el filtro con los query params del Router en ambas direcciones. Cuando el usuario aplica filtros, la URL se actualiza. Cuando alguien llega con una URL que ya tiene filtros, el formulario se rellena y la búsqueda se ejecuta automáticamente.
Para Base64URL el helper de codificación es simple:
encode(filter: object): string {
const json = JSON.stringify(filter);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Y la sincronización con el router:
// Al cambiar filtros → actualizar la URL
this.router.navigate([], {
relativeTo: this.route,
queryParams: { filter: this.encode(filter) },
queryParamsHandling: 'merge'
});
// Al inicializar → leer filtros de la URL
this.route.queryParams.pipe(
map(params => params['filter']),
filter(Boolean),
map(encoded => this.decode(encoded))
).subscribe(filter => {
this.applyToForm(filter);
this.search(filter);
});
Para RSQL la mecánica es idéntica. Solo cambia el formato del string que viaja en el param: en lugar de Base64URL opaco viaja una cadena legible como categoria=in=(electronica,hogar);precio=ge=100000. El router no distingue entre uno y otro.
Volver a aquella URL de 400 caracteres
Aquella URL llena de %7B%22and%22%3A no era realmente un problema de sintaxis.
Era una señal.
La complejidad de la consulta había empezado a superar la comodidad del mecanismo elegido para transportarla, pero todavía no se había tomado una decisión consciente sobre qué propiedad del contrato era importante conservar.
Si los filtros se mapean bien a campos de una entidad persistida y a operadores relacionales estándar, una gramática de texto como RSQL resuelve el problema con muy poco código y conserva la legibilidad de la URL — a costa de depender de una capa de traducción a persistencia y de mantener activamente una allowlist. Si el contrato del filtro es propio del dominio, con operadores específicos o estructuras que no encajan en el modelo relacional directo, Base64URL con DTO tipado ofrece un contrato explícito, no depende de ningún motor de datos particular, y su allowlist es estructural en lugar de operacional.
flowchart TD
A[¿Qué tipo de filtros necesitás?] --> B
A --> C
A --> D
A --> E
B["Campos simples\nsin lógica compleja"]
--> F["QueryDSL Web Bindings\nCero código, nativo Spring Data"]
C["AND / OR sobre atributos\nde una entidad persistida"]
--> G{¿Existe una capa que traduzca\ngramática de texto a persistencia?}
G -->|Sí| H["RSQL / gramática similar\nCon allowlist explícita"]
G -->|No| D
D["Operadores custom\nlógica de dominio\ncolecciones correlacionadas"]
--> I{¿Necesitás URL compartible?}
I -->|Sí, filtro moderado| J["Base64URL + HandlerMethodArgumentResolver"]
I -->|No, o filtro grande| K["POST /search\nBody JSON estructurado"]
E["Filtros enormes\nreutilizados entre sesiones"]
--> L["saved-searches como recurso\nPOST para crear, GET con ID para reutilizar"]
Cuando el filtro deja de ser razonablemente pequeño, el problema cambia de naturaleza. POST con JSON puede ser un contrato mucho más apropiado. Y si la búsqueda se vuelve grande y reutilizable, puede tener más sentido convertirla en un recurso persistente y referenciarla mediante un identificador.
La decisión correcta no consiste en elegir una tecnología y aplicarla a todas las búsquedas.
Consiste en reconocer que cada representación conserva unas propiedades y sacrifica otras.
Query params simples conservan una URL muy legible, pero se vuelven incómodos cuando la estructura crece. Una gramática de texto como RSQL mantiene esa legibilidad con más expresividad, pero exige que exista una capa de traducción a persistencia y una allowlist explícita mantenida activamente, o la superficie de ataque crece silenciosamente. Base64URL mantiene la búsqueda como GET y permite un contrato de filtro completamente propio, sin depender de ningún motor de datos particular, aunque sacrifica legibilidad en logs. POST recupera la representación natural del JSON cuando la consulta ya es demasiado grande. Y una búsqueda persistente elimina directamente la necesidad de reenviar una estructura que ya existe en el servidor.
El verdadero cambio, entonces, no está en Base64URL, en RSQL, en Spring Boot ni siquiera en HTTP.
Está en dejar de pedirle al mismo mecanismo que resuelva problemas de tamaños y necesidades diferentes.
Cuando una búsqueda es pequeña, puede viajar como una consulta.
Cuando necesita estructura pero todavía cabe razonablemente en una URL, puede codificarse, con la representación que mejor encaje con el contrato.
Cuando crece demasiado, puede pasar al body.
Y cuando se convierte en algo que se reutiliza como entidad propia, puede dejar de viajar por completo y convertirse en un recurso.
Ese es el criterio que permite que la arquitectura evolucione con la complejidad real del problema, en lugar de obligar a cada nueva necesidad a encajar a la fuerza en la decisión que se tomó cuando el endpoint todavía parecía sencillo.
Anexo: RSQL en el stack Java (Spring Boot + JPA)
Todo lo dicho sobre RSQL en el cuerpo del artículo es válido para cualquier stack que cuente con una capa de traducción gramática→persistencia equivalente. Este anexo muestra cómo se ve concretamente esa integración en el ecosistema Java, donde esa capa la provee la Criteria API de JPA a través de una Specification.
La integración más difundida usa un starter de terceros (no es un proyecto oficial de Spring) que convierte directamente la cadena RSQL en una Specification:
<dependency>
<groupId>io.github.perplexhub</groupId>
<artifactId>rsql-jpa-spring-boot-starter</artifactId>
<version>6.0.18</version>
</dependency>
@GetMapping("/productos")
public Page<Producto> buscar(
@RequestParam(defaultValue = "") String filter,
Pageable pageable) {
return productoRepository.findAll(
RSQLJPASupport.toSpecification(filter),
pageable
);
}
Eso es todo el controlador. Sin anotaciones custom, sin resolver, sin ObjectMapper — la contrapartida es la dependencia externa a un motor de persistencia relacional vía JPA, que es justamente lo que el cuerpo del artículo señala como condición de existencia de este camino.
La allowlist mencionada antes se implementa recorriendo el AST que produce el parser de RSQL, antes de convertirlo en Specification, y rechazando cualquier campo fuera de lo permitido:
private void validarCamposPermitidos(String filter, Set<String> permitidos) {
if (filter.isBlank()) return;
new RSQLParser().parse(filter).accept(new RSQLVisitor<Void, Void>() {
public Void visit(AndNode node, Void p) {
node.getChildren().forEach(c -> c.accept(this, null)); return null;
}
public Void visit(OrNode node, Void p) {
node.getChildren().forEach(c -> c.accept(this, null)); return null;
}
public Void visit(ComparisonNode node, Void p) {
if (!permitidos.contains(node.getSelector()))
throw new FiltroInvalidoException(
"Campo no permitido: " + node.getSelector());
return null;
}
});
}
Y componer la Specification resultante con los filtros base que el usuario nunca debe poder eludir:
Specification<Producto> spec = RSQLJPASupport.<Producto>toSpecification(filter)
.and((root, query, cb) -> cb.equal(root.get("eliminado"), false))
.and((root, query, cb) -> cb.equal(root.get("tenantId"), TenantContext.get()));
En otro stack o con otro ORM, el nombre de las piezas cambia, pero el rol de cada una se mantiene: un parser de la gramática, un traductor hacia el mecanismo de consulta dinámica del ORM disponible, y una allowlist que se aplica sobre el árbol ya parseado antes de ejecutar nada contra la base de datos.
Vistas y funciones como contratos de API sobre una base unificada
- Mauricio ECR
- Arquitectura
- 29 Aug, 2026
En proyectos donde los microservicios comparten una misma base de datos, hay momentos en los que un cambio que comienza como una tarea rutinaria dentro de un equipo puede terminar en una reunión con t
Vistas y funciones como contratos de API sobre una base unificada
- Mauricio ECR
- Arquitectura
- 29 Aug, 2026
En proyectos donde los microservicios comparten una misma base de datos, hay momentos en los que un cambio que comienza como una tarea rutinaria dentro de un equipo puede terminar en una reunión con tres equipos distintos. Clientes, el servicio responsable de las cuentas de usuario, despliega una limpieza de cuentas inactivas, cambia el tipo de su identificador o renombra una columna que hasta entonces consideraba interna. Sus pruebas pasan y el despliegue parece correcto. Poco después, Pedidos empieza a fallar.
Normalmente, la situación comienza de una forma mucho más sencilla. Pedidos necesita consultar determinada información de Clientes y, como ambos servicios comparten la misma base de datos, acceder directamente a sus tablas parece una solución rápida y práctica. Con el tiempo, esa consulta puede dejar de ser algo puntual y convertirse en parte del funcionamiento habitual de Pedidos. El servicio comienza entonces a asumir que las tablas, columnas y estructuras que consulta estarán disponibles y conservarán el mismo significado.
Lo que inicialmente parecía una integración sencilla puede terminar haciendo que decisiones internas de Clientes tengan consecuencias sobre Pedidos. Clientes puede ser responsable de la identidad, el estado y las reglas de ciclo de vida de una persona, mientras que Pedidos solo necesita conservar una referencia estable al cliente y obtener determinados datos para cumplir con sus propias responsabilidades. Sin embargo, cuando Pedidos resuelve esa necesidad consultando directamente las tablas de Clientes, termina dependiendo no solo de los datos que necesita, sino también de la forma en que Clientes los almacena y organiza.
Este escenario plantea una cuestión que va más allá de una consulta concreta o de una columna que haya cambiado. Si dos microservicios comparten la misma base de datos, ¿cómo pueden relacionarse sin convertir las estructuras internas de un dominio en dependencias del otro? Y, sobre todo, ¿cómo se puede establecer una frontera clara cuando la infraestructura sigue siendo compartida?
El problema real: acoplamiento al esquema ajeno
Supongamos que Clientes es responsable de la identidad, el estado y las reglas de ciclo de vida de una persona. Pedidos, en cambio, es responsable de los pedidos y necesita conservar una referencia estable al cliente asociado.
Pedidos necesita saber quién es el cliente, pero no necesita conocer cómo Clientes organiza internamente esa información. No debería depender de si el nombre se almacena en una columna, en dos columnas, en una tabla normalizada o mediante una relación con otra entidad. Tampoco debería decidir qué significa anonimizar una cuenta ni asumir que todas las columnas existentes en clientes son datos que puede interpretar.
Sin embargo, cuando ambos servicios comparten una base de datos, es fácil terminar con algo como esto:
CREATE TABLE pedidos (
id UUID PRIMARY KEY,
cliente_id UUID NOT NULL,
total NUMERIC(12, 2) NOT NULL,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_pedidos_cliente
FOREIGN KEY (cliente_id) REFERENCES clientes(id)
);
Al estudiar pedidos, la FOREIGN KEY deja visible una relación con clientes. Esa relación es importante, pero conviene distinguir dos conceptos que suelen mezclarse.
Una clave foránea expresa una garantía de integridad referencial. Le dice al motor que un valor de pedidos.cliente_id debe corresponder a un registro existente en clientes.id, de acuerdo con las reglas de la restricción.
Eso no significa que clientes se haya convertido en una API para Pedidos.
Tampoco significa que Pedidos tenga derecho a consultar cualquier columna de la tabla, ejecutar JOIN arbitrarios o depender de la forma en que Clientes almacena sus datos. La clave foránea hace visible una relación entre estructuras; no define una interfaz de integración entre dominios.
El verdadero acoplamiento aparece cuando Pedidos empieza a hacer algo como esto:
SELECT
p.id,
p.total,
c.nombre,
c.estado,
c.tipo_documento
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id;
La consulta parece inocente. El problema es que convierte clientes en una interfaz accidental.
A partir de ese momento, Clientes no puede modificar libremente nombre, estado o tipo_documento porque Pedidos ha asumido que esas columnas forman parte de su contrato. Si mañana Clientes normaliza el nombre, cambia el modelo de estados o separa la información personal de la comercial, el cambio deja de ser exclusivamente suyo.
flowchart LR
Clientes[(Clientes)]
Pedidos[(Pedidos)]
Pedidos -->|JOIN y acceso a tablas privadas| Clientes
La frontera se ha roto porque el consumidor depende de la estructura interna del propietario para funcionar.
Por eso, al analizar una base de datos compartida, la pregunta importante no es únicamente «¿existe una FOREIGN KEY?». La pregunta relevante es «¿qué parte de esta estructura está siendo utilizada como interfaz por otro dominio?».
Relación local o contrato entre dominios
Antes de modificar el DDL conviene clasificar la relación.
Cuando la relación protege una invariante dentro del mismo dominio, la FOREIGN KEY sigue siendo una herramienta apropiada. Si dos tablas pertenecen al mismo modelo y la existencia de una fila depende de la existencia de otra, eliminar la restricción únicamente para conseguir una apariencia de autonomía significa renunciar a una garantía que el motor puede proporcionar de forma fiable.
La situación cambia cuando la relación cruza límites de propiedad.
Pedidos puede conservar cliente_id como referencia estable al cliente. Ese identificador expresa una relación entre conceptos, pero no concede a Pedidos permiso para interpretar el modelo interno de Clientes.
Esta distinción es importante porque eliminar una FOREIGN KEY no crea automáticamente autonomía. Solo elimina una garantía de integridad referencial.
La autonomía aparece cuando cada dominio puede modificar su estructura interna sin romper a sus consumidores.
De aquí surge una estrategia especialmente útil para escenarios de transición: Database as an API. La base de datos continúa siendo compartida desde el punto de vista físico, pero sus esquemas dejan de funcionar como una superficie de acceso indiscriminado. Cada dominio mantiene sus estructuras privadas y decide explícitamente qué información publica y qué operaciones permite.
La frontera lógica aparece antes que la frontera física.
Vistas como contratos de lectura
Para las lecturas entre dominios, una de las herramientas más sencillas disponibles en una base relacional es una vista.
Clientes puede mantener sus tablas privadas y publicar únicamente la información que Pedidos necesita:
CREATE VIEW clientes_publicos_para_pedidos AS
SELECT
id AS cliente_id,
nombre_comercial,
estado_comercial
FROM clientes;
Pedidos deja entonces de depender directamente de clientes:
SELECT
cliente_id,
nombre_comercial,
estado_comercial
FROM clientes_publicos_para_pedidos
WHERE cliente_id = :cliente_id;
La diferencia parece pequeña desde el punto de vista de SQL, pero es significativa desde el punto de vista arquitectónico.
La tabla clientes representa el modelo interno del dominio. La vista clientes_publicos_para_pedidos representa una superficie publicada.
La vista puede funcionar conceptualmente como un DTO o como un endpoint GET: expone un conjunto definido de datos y oculta cómo se obtiene internamente.
Clientes podría, por ejemplo, pasar de una tabla monolítica a varias tablas normalizadas:
clientes
├── identidades
├── perfiles
└── estados_comerciales
Mientras la vista conserve su contrato:
cliente_id
nombre_comercial
estado_comercial
Pedidos no necesita conocer ese cambio.
La vista, por supuesto, no hace que el contrato desaparezca. Al contrario: lo hace explícito. Cambiar el nombre de una columna publicada, eliminarla o alterar significativamente su semántica debe tratarse como un cambio contractual, aunque no exista HTTP de por medio.
Esta estrategia resulta particularmente útil cuando varios servicios ya utilizan el mismo motor de base de datos. Permite reducir el acoplamiento sin exigir inmediatamente una migración física completa.
También puede tener ventajas operativas. Una lectura que permanece dentro del mismo motor evita llamadas adicionales entre microservicios y puede evitar tráfico de red o costos de egress que aparecerían al sacar la consulta fuera de la infraestructura. Pero conviene no confundir esto con «latencia cero»: la consulta sigue consumiendo CPU, memoria, I/O y capacidad de concurrencia de la base de datos.
Además, una vista no crea independencia física. Los servicios siguen compartiendo disponibilidad, capacidad, credenciales, copias de seguridad, mantenimiento y, potencialmente, una misma condición de fallo.
La vista reduce el acoplamiento al esquema. No elimina el acoplamiento operativo a la infraestructura compartida.
Las lecturas y las escrituras necesitan fronteras diferentes
Aquí aparece una distinción fundamental. No todas las interacciones con una base de datos pueden modelarse de la misma manera.
Una lectura puede exponerse mediante una vista porque, conceptualmente, está proporcionando una representación de información. Una escritura, en cambio, puede modificar el estado del dominio y activar decisiones de negocio.
Por eso conviene distinguir entre una operación técnica y una operación que expresa una decisión de negocio.
Cuando la operación pertenece al negocio
Si una escritura necesita validar reglas, permisos, transiciones de estado, límites, invariantes o efectos secundarios, la autoridad debería permanecer en el servicio propietario.
En ese caso, REST o gRPC son canales adecuados para expresar la operación:
Pedidos
|
| POST /clientes/{id}/suspension
v
Clientes
|
+--> valida autorización
+--> comprueba estado actual
+--> aplica reglas de negocio
+--> registra auditoría
+--> persiste el nuevo estado
Pedidos no debería ejecutar directamente una función SQL equivalente a «suspender cliente» simplemente porque ambos servicios comparten una base de datos.
La suspensión no es solamente un UPDATE. Es una decisión.
Clientes debe determinar si la transición es válida, quién puede ejecutarla, qué auditoría requiere y qué otros efectos deben producirse. Si Pedidos pudiera modificar directamente la fila, el servicio propietario perdería el control de su propio dominio.
Cuando la operación es puramente técnica
Existe otro tipo de operación que sí puede tener sentido encapsular en SQL: una operación técnica, acotada, atómica y sin decisiones de negocio.
Por ejemplo, una función podría eliminar un registro temporal identificado explícitamente:
CREATE FUNCTION purgar_registro_temporal(p_registro_id UUID)
RETURNS VOID
LANGUAGE SQL
AS $$
DELETE FROM registros_temporales
WHERE id = p_registro_id;
$$;
La semántica es deliberadamente limitada. Si el registro existe, se elimina; si no existe, la operación no necesita tomar una decisión adicional.
La función no determina quién tiene derecho a suspender una cuenta, qué significa que una cuenta esté inactiva ni qué transición de negocio corresponde.
Este tipo de función puede ser útil como mecanismo de infraestructura, pero existe un riesgo importante: convertir gradualmente la base de datos en una segunda capa de aplicación.
Cuando cada nueva regla termina implementada como un stored procedure, la lógica queda repartida entre el backend y la base de datos. Las pruebas, el versionado, la observabilidad y el razonamiento sobre las reglas se vuelven progresivamente más difíciles.
La regla práctica es sencilla:
La base puede encapsular acceso y operaciones técnicas; el servicio debe conservar las decisiones del dominio.
Una matriz para decidir dónde vive cada operación
Esta separación puede resumirse en una regla operativa:
| Operación | Tipo de lógica | Canal | Responsabilidad |
|---|---|---|---|
| Lectura de datos publicados | Consulta | VIEW |
El dominio propietario define el contrato |
| Escritura técnica acotada | Infraestructura | FUNCTION SQL |
La función ejecuta una operación atómica y limitada |
| Escritura con decisiones | Negocio | REST/gRPC | El servicio propietario valida y persiste |
| Lectura entre dominios | Consulta | VIEW |
Se evita el JOIN sobre tablas privadas |
La matriz no pretende convertir la base de datos en un reemplazo universal de los servicios. Su propósito es asignar cada responsabilidad al canal que puede sostenerla sin volver a abrir el acceso indiscriminado al esquema interno.
El contrato también necesita gobierno
Una vista por sí sola no resuelve el problema. Si cualquier desarrollador puede modificarla sin considerar a sus consumidores, simplemente se habrá sustituido una dependencia implícita por otra.
Cada esquema y cada tabla privada deben tener un propietario claro. Las credenciales de Pedidos no deberían disponer de escritura sobre las tablas de Clientes y, cuando sea posible, tampoco deberían tener lectura directa sobre ellas.
El acceso público debe concederse sobre objetos concretos:
clientes
├── tablas privadas
├── funciones internas
└── vistas publicadas
└── acceso para Pedidos
El principio de mínimo privilegio ayuda a convertir la arquitectura deseada en una restricción técnica. Si Pedidos no tiene permiso para leer clientes, un nuevo JOIN directo deja de ser una tentación que depende exclusivamente de la disciplina del equipo.
Los contratos publicados también necesitan versionado.
Si la vista expone:
cliente_id
nombre_comercial
estado_comercial
y una nueva versión requiere eliminar estado_comercial, no debería tratarse como una modificación trivial. Es un cambio de contrato.
Una estrategia puede ser crear una nueva versión:
clientes_publicos_para_pedidos_v1
clientes_publicos_para_pedidos_v2
y mantener ambas durante un período de transición.
La nomenclatura concreta puede variar. Lo importante es que exista una forma de distinguir entre cambios compatibles y cambios incompatibles y que los consumidores puedan migrar deliberadamente.
El mismo principio aplica a las funciones SQL. Su firma, parámetros, comportamiento y permisos forman parte de una interfaz. No porque exista HTTP, sino porque otro componente depende de ella.
Medir antes de retirar
Una de las dificultades prácticas de estas migraciones es descubrir quién utiliza realmente una tabla.
En sistemas maduros, la documentación rara vez contiene todas las dependencias. Puede haber consultas en servicios antiguos, procesos batch, scripts operativos, herramientas de análisis, trabajos programados o accesos manuales que nadie recuerda.
Por eso la migración debería empezar con un inventario.
No basta con buscar referencias en el código fuente. También conviene revisar permisos, consultas observables en el motor, jobs programados, procesos de integración y consumidores conocidos.
El objetivo es construir un mapa aproximado:
┌── Pedidos
clientes ────────┼── Facturación
├── Reportes
└── Batch histórico
A partir de ahí, cada dependencia puede clasificarse.
Algunas serán invariantes legítimas. Otras serán lecturas que deberían convertirse en vistas. Otras serán escrituras que necesitan regresar al servicio propietario. Y algunas serán dependencias históricas que ya pueden eliminarse.
La observabilidad también permite medir el éxito de la transición: número de consumidores, frecuencia de consultas, latencia, errores, volumen de datos y tráfico entre dominios.
El objetivo no es solamente cambiar SQL. Es poder demostrar que la frontera está funcionando.
Una migración gradual sobre la misma infraestructura
Una de las ventajas de este enfoque es que no exige separar físicamente las bases desde el primer día.
La transición puede comenzar con la infraestructura actual.
Primero se construye un inventario de JOIN, consultas directas, procesos batch y permisos entre esquemas. Después se clasifican las relaciones para distinguir invariantes locales de dependencias entre dominios.
A continuación se declara quién es propietario de cada tabla, identificador y regla de ciclo de vida. Esta definición es importante porque una arquitectura no puede establecer fronteras si no está claro quién tiene autoridad sobre aquello que queda dentro de ellas.
Las lecturas necesarias para otros dominios se trasladan a vistas públicas. Las escrituras que expresan reglas de negocio se llevan a REST o gRPC. Las funciones SQL que permanezcan se mantienen deliberadamente pequeñas y técnicas.
Después se versionan los contratos y se empieza a medir su utilización.
Solo cuando los consumidores han dejado de depender de las tablas privadas tiene sentido retirar gradualmente esos permisos y revisar las FOREIGN KEY que atraviesan límites de propiedad.
Este orden importa.
Eliminar primero las restricciones o mover físicamente las bases no resuelve las dependencias semánticas. Es posible tener dos bases de datos completamente separadas y seguir manteniendo un acoplamiento fuerte si un servicio depende de la estructura interna del otro mediante consultas, replicaciones o procesos frágiles.
La frontera lógica debe preceder a la frontera física.
Preparar una futura separación física
Este modelo también puede funcionar como una etapa intermedia hacia una arquitectura con bases independientes.
Mientras ambos dominios comparten el mismo motor, Pedidos puede consumir:
VIEW clientes_publicos_para_pedidos
Más adelante, si Clientes pasa a tener su propia base de datos, esa misma semántica puede representarse mediante:
GET /clientes/{id}
o mediante un contrato equivalente en gRPC.
El cambio de infraestructura no necesita redefinir desde cero qué información necesita Pedidos. La interfaz conceptual ya existía.
Esto permite entender Database as an API no como una arquitectura final obligatoria, sino como una técnica de transición: primero se estabiliza el contrato y después, si es necesario, se separa la infraestructura que lo implementa.
La separación física deja de ser el mecanismo que crea la frontera y pasa a ser una consecuencia posible de una frontera que ya estaba definida.
Conclusión: la frontera que realmente hay que proteger
El desafío de trabajar con microservicios sobre una base de datos compartida no está en la existencia de una relación entre tablas, sino en determinar qué parte del modelo pertenece a cada dominio y qué información puede ser utilizada por los demás. Una relación entre pedidos y clientes puede ser necesaria desde el punto de vista de los datos, pero eso no significa que Pedidos deba conocer o depender de la estructura interna con la que Clientes gestiona sus propias entidades.
La autonomía comienza cuando cada dominio puede evolucionar su modelo interno sin obligar a los demás servicios a conocer esos cambios. Para conseguirlo, la base de datos compartida necesita límites explícitos. Las vistas permiten publicar únicamente los datos que un consumidor necesita; las funciones SQL pueden encapsular operaciones técnicas acotadas; y las decisiones que contienen reglas de negocio deben permanecer bajo la responsabilidad del servicio propietario, mediante REST, gRPC u otro mecanismo de integración apropiado.
Esto convierte la base de datos en algo más que un repositorio común. Puede actuar como una infraestructura compartida que ofrece contratos de acceso definidos y gobernados, en lugar de convertirse en un espacio donde cualquier servicio puede consultar y modificar libremente las estructuras de los demás.
Para que este modelo sea sostenible, los contratos necesitan las mismas garantías que cualquier otra interfaz entre componentes: propietarios claros, permisos restringidos, versionado, compatibilidad entre cambios, observabilidad y un proceso controlado para retirar consumidores. De esta manera, una vista o una función no son simplemente objetos de base de datos, sino parte de una superficie que un dominio decide publicar y mantener.
Este enfoque también permite avanzar de forma gradual. No es necesario separar físicamente las bases de datos para comenzar a establecer límites entre los servicios. Primero pueden definirse los contratos y eliminarse las dependencias directas sobre las tablas privadas. Más adelante, si las necesidades operativas lo requieren, esos mismos contratos pueden trasladarse a una API o a otra forma de comunicación entre servicios.
La separación física, por tanto, no tiene que ser el punto de partida para conseguir autonomía. Puede ser una evolución posterior de una frontera que ya existe a nivel lógico.
La idea central es sencilla:
Compartir una base de datos no obliga a compartir el modelo interno de cada dominio.
La cuestión importante no es cuándo eliminar una relación entre tablas ni cuándo separar físicamente las bases de datos. La cuestión es qué información y qué operaciones está dispuesto a publicar cada dominio, bajo qué condiciones y con qué garantías de estabilidad.
Cuando esa frontera está claramente definida, la base de datos compartida deja de ser una fuente de dependencias accidentales y puede convertirse en una etapa controlada hacia una arquitectura con mayor independencia. El siguiente paso natural consiste en estudiar cómo versionar estos contratos, detectar automáticamente a sus consumidores y establecer mecanismos que permitan evolucionar desde una base unificada hacia servicios con almacenamiento independiente cuando la arquitectura y las necesidades operativas lo justifiquen.
El tramo en cascada que todo proyecto ágil necesita (y casi ninguno tiene)
- Mauricio ECR
- Gestion
- 02 Aug, 2026
Llevas un tiempo trabajando en equipos que dicen practicar Scrum, y algo no termina de cuadrarte. Los sprints avanzan, la demo sale en tiempo, el tablero se vacía cada dos semanas. Y aun así, al cabo
El tramo en cascada que todo proyecto ágil necesita (y casi ninguno tiene)
- Mauricio ECR
- Gestion
- 02 Aug, 2026
Llevas un tiempo trabajando en equipos que dicen practicar Scrum, y algo no termina de cuadrarte. Los sprints avanzan, la demo sale en tiempo, el tablero se vacía cada dos semanas. Y aun así, al cabo de varios meses, el sistema tiene algo raro: funciona por partes, pero no tiene columna vertebral. Cada módulo fue una decisión tomada en el momento, sin conversación con las anteriores. Se ve ágil desde afuera. Se siente frágil desde adentro.
No es que el equipo sea poco disciplinado. Los rituales se hacen, el backlog está priorizado, el Product Owner participa. El problema es más silencioso que eso: nadie definió, antes de arrancar, qué parte del proyecto no puede improvisarse sprint a sprint. Y esa omisión, que parece menor al principio, se cobra con intereses compuestos.
Lo que nadie dice en la retrospectiva
El síntoma más claro aparece cuando alguien intenta cambiar algo que parecía simple. Un ajuste en el modelo de datos toca cuatro historias ya entregadas. Un nuevo servicio necesita un contrato que nadie documentó porque se asumió. Una integración que "ya estaba resuelta" resulta que cada equipo la resolvió a su manera. Cada uno de esos problemas tiene la misma raíz: una decisión estructural que se tomó con la misma liviandad que una tarea de sprint.
La fragilidad no nace de hacer sprints cortos. Nace de confundir dos tipos de decisiones que tienen costos de reversión completamente distintos, y tratarlas como si fueran iguales.
Hay decisiones de implementación —cómo se resuelve una historia de usuario específica, en qué orden se atacan las funcionalidades dentro de una misma capa, qué tan grande es cada tarea— que son baratas de revertir. Si una no funciona, la corriges en el siguiente sprint sin que nada estructural se rompa. Esas son exactamente las decisiones que se benefician de la flexibilidad ágil: se ajustan con la información más fresca posible, sprint a sprint, sin necesidad de comité.
Y hay decisiones estructurales —el modelo de datos central, los contratos entre servicios, los patrones de comunicación del sistema— que son caras de cambiar una vez que varios equipos ya construyeron sobre ellas. Cada sprint que pasa sin que esas decisiones estén tomadas es un sprint que agrega capas sobre cimientos que nadie inspeccionó. El costo de revertirlas no crece linealmente: crece con cada historia que asumió que esa decisión ya estaba resuelta.
El error que produce proyectos frágiles no es aplicar demasiado ágil. Es aplicar la flexibilidad de la capa barata en la capa cara.
La solución incómoda
La respuesta es que esa capa cara necesita el tratamiento opuesto: rigidez deliberada antes de que comience el primer sprint. Se define una vez, con la misma seriedad que un plano estructural, y cambiarla requiere un proceso formal, no una conversación de pasillo. Es, en ese tramo específico, exactamente lo que hace la cascada.
Decirlo así genera resistencia inmediata. "Eso es cascada" es la objeción más rápida, y es comprensible: el malestar con la rigidez de los proyectos tradicionales es real y justificado. Pero la objeción parte de una confusión sobre qué es lo que realmente distingue cascada de ágil, y vale la pena deshacerla antes de continuar.
Lo que define a la cascada no es tener fases, ni tener diseño antes de construcción, ni tener decisiones tomadas de antemano. Lo que la define es que el mapa completo de trabajo —qué se construye, en qué orden, con qué criterios— se cierra una sola vez al principio y no se vuelve a abrir con lo que se aprende en el camino. Un proyecto en cascada puede tener veinte subproyectos y entregas parciales. Sigue siendo cascada porque el subproyecto quince ya estaba escrito en el mes uno, aunque se ejecute en el mes diez, y lo que se aprendió construyendo el subproyecto tres no tuvo ningún efecto sobre él.
Lo que distingue a ágil es una sola cosa: cuando terminas de ejecutar una unidad de trabajo, lo que aprendiste ahí puede reescribir el contenido, el orden o la existencia de la unidad que sigue. Es un bit de información viajando en dirección contraria al plan. Ese bit es lo que mantiene vivo el aprendizaje. Y ese bit puede existir perfectamente en un proyecto que tiene una arquitectura base cerrada, contratos entre servicios definidos antes del primer sprint, y una Definition of Ready rigurosa. Nada de eso impide que lo aprendido en el sprint tres cambie el alcance del sprint seis. Solo impide que lo aprendido en el sprint tres destruya los cimientos sobre los que ya construyó el resto del equipo.
Aplicar rigidez en la capa cara no es cascada. Es reconocer que no todas las decisiones tienen el mismo costo de reversión, y tratarlas en consecuencia.
Dónde vive esa rigidez dentro de Scrum
Lo interesante es que no necesitas inventar nada por fuera del framework. Ágil ya tiene nombre para cada capa de este sistema de gobernanza, y los tres elementos son igual de obligatorios.
El primero es el walking skeleton: antes de que arranque el primer sprint de construcción, el equipo define un esqueleto funcional y liviano que recorre el sistema de punta a punta. No es el diseño completo — es lo mínimo necesario para que todos los equipos construyan sobre la misma base sin pisarse. Incluye las decisiones que son caras de revertir: el modelo de datos central, los contratos entre servicios, los patrones de comunicación, las convenciones técnicas que el resto del proyecto va a asumir como dadas. Quién lo construye es el equipo técnico completo, antes del sprint uno, con la misma formalidad con que un arquitecto firma un plano estructural. Lo que viene después puede crecer y cambiar libremente — precisamente porque ese esqueleto existe.
El segundo y el tercero viven dentro de cada sprint y protegen ese esqueleto historia por historia: la Definition of Ready es la puerta de entrada —ninguna historia entra a un sprint sin demostrar que respeta los contratos que el walking skeleton estableció—, y la Definition of Done es la puerta de salida —ninguna historia se declara terminada sin haber verificado que sigue encajando con el sistema completo, no solo con el módulo recién construido.
Dicho así suena razonable. El problema es que la mayoría de los equipos tratan esa puerta de entrada como un trámite: "la historia tiene criterios de aceptación, ya está lista". Eso responde apenas una parte de la pregunta. Antes de que una historia entre a un sprint, alguien tiene que haber respondido, con la misma seriedad con que un ingeniero civil revisa un plano antes de excavar, qué depende de qué.
No basta con saber que la historia es viable en abstracto. Hay que identificar explícitamente qué habilitadores necesita para poder construirse: un endpoint que todavía no existe, un cambio en el modelo de datos que otra historia debe entregar primero, un permiso o una integración externa que tarda en aprobarse. Y aquí está el punto que casi siempre se salta: no puedes nombrar esas dependencias con honestidad si nadie diseñó, aunque sea a nivel de solución técnica, cómo se va a construir esa historia en concreto.
Decir "esto depende de X" sin haber bajado al menos un boceto de la solución —qué componentes toca, qué contrato de datos necesita, por dónde entra y por dónde sale la información— no es identificar una dependencia, es adivinarla. Y las dependencias adivinadas son exactamente las que aparecen a mitad del sprint disfrazadas de sorpresa.
Lo que una DoR seria realmente exige
Por eso la rigidez concreta no está en agregar más preguntas a un checklist de planning. Está en aceptar que ninguna historia entra a un sprint sin haber pasado antes por un ejercicio de diseño de solución propio, específico para esa actividad. No el diseño de arquitectura completo del sistema —eso vive en el walking skeleton y rara vez se toca. Es un diseño más modesto pero igual de innegociable: la DoR deja de ser una intención difusa en el momento en que se convierte en un entregable verificable con campos obligatorios.
Ninguna historia entra a Sprint Planning sin esos campos completos, y no admite medias respuestas. Como mínimo:
- El diseño de solución específico de esa actividad: qué componentes toca, por dónde entra y sale la información, qué contratos consume o expone.
- La lista explícita de dependencias y habilitadores identificados a partir de ese diseño, no adivinados desde la descripción funcional.
- La estimación de esfuerzo apoyada en esa lista. Construir algo que necesita tres piezas ajenas no cuesta lo mismo que construir algo autocontenido, aunque la descripción funcional suene igual de simple.
- Los criterios de aceptación funcionales: lo que ve el usuario.
- Los criterios no funcionales: rendimiento, seguridad, manejo de errores, mantenibilidad. Una historia puede cumplir su criterio funcional y aun así ser un desastre para el sistema si nadie exigió que respetara los estándares que el resto del proyecto ya adoptó.
- Dos validaciones distintas: la del dueño de producto, que confirma que resuelve el problema del usuario; y la del responsable técnico, que confirma que la solución respeta la arquitectura y los lineamientos que el walking skeleton estableció.
Si a la historia le falta cualquiera de esos ítems, no está lista, sin importar qué tan urgente parezca meterla al sprint. No es un principio abstracto: es un documento con campos obligatorios que nadie puede saltarse por falta de tiempo, exactamente igual que un plano estructural no se salta porque la obra vaya con retraso.
Esa segunda validación —la del responsable técnico— es la que casi nunca se sienta en la conversación. El usuario final valida si la historia resuelve su problema, pero eso es necesario y no suficiente. Falta quien evalúe si el modo en que se va a construir mantiene viva la coherencia del sistema completo. Puedes tener una historia perfectamente aprobada por el producto y absolutamente inaceptable para quien tiene que mantener esa base de código dentro de un año. Si tu Definition of Ready solo mira la primera validación, ya sembraste el mismo desacople que estás intentando evitar.
La Definition of Done cierra el ciclo: exigir pruebas de integración contra el sistema completo —y no solo contra el módulo recién construido— garantiza que lo que se declara terminado realmente encaja con todo lo demás. Ninguna de estas tres piezas rompe el Scrum Guide. Todas simplemente convierten en obligatorio, para ese proyecto específico, algo que el framework siempre dejó como decisión de cultura de equipo —y que la mayoría, por prisa o por comodidad, nunca llegó a decidir en serio.
De vuelta al tablero
Lo que hace funcionar este sistema no es la cantidad de rigor que aplicas, sino dónde lo aplicas. La mayor parte del proyecto —reglas de negocio, funcionalidades, flujos de usuario— se beneficia de decidirse tarde, con la información más fresca posible. Solo una fracción pequeña necesita el tratamiento opuesto: definirse antes, con formalidad, y no tocarse sin proceso. El walking skeleton, la DoR y la DoD son exactamente los tres puntos donde esa fracción vive.
Cuando vuelves al tablero de ese sprint en el que todo se veía bien —los puntos avanzaban, la demo salía en tiempo, nadie levantaba la mano— y lo comparas con el sistema que quedó meses después, frágil, sin columna vertebral, con cada módulo viviendo en su propia realidad, la distancia entre los dos momentos ya tiene explicación. No fueron los rituales, ni la disciplina del equipo, ni el framework. Fue que nadie definió los tres puntos donde el rigor no es opcional: el walking skeleton que estableciera la base común, la DoR que obligara a diseñar antes de construir, y la DoD que verificara que cada pieza encajaba con el resto.
Eso no es traicionar el manifiesto ágil. Es leerlo con más cuidado.
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III
- Mauricio ECR
- Arquitectura
- 07 Jun, 2026
Las dos primeras partes de esta serie resolvieron un problema bien delimitado: construir un sistema de logging centralizado que operara de forma transversal sobre una arquitectura DDD sin contaminar l
Observabilidad sin Ruido: Diseñando un Sistema de Logs con AOP en Arquitecturas DDD — Parte III
- Mauricio ECR
- Arquitectura
- 07 Jun, 2026
Las dos primeras partes de esta serie resolvieron un problema bien delimitado: construir un sistema de logging centralizado que operara de forma transversal sobre una arquitectura DDD sin contaminar la lógica de negocio. Al final de ese recorrido, el sistema producía registros estructurados, consistentes y con métricas de tiempo precisas en cada capa, todo sin una sola línea de log escrita manualmente en ninguna clase del dominio o la infraestructura.
Pero quedaba una deuda pendiente, y era visible precisamente porque el sistema funcionaba tan bien. Al serializar los argumentos y resultados de cada método, el aspecto exponía los datos tal como viajan por el sistema: nombres completos, direcciones de correo, identificadores, cualquier dato que el método recibiera o retornara aparecía en texto plano en el log. En un entorno de desarrollo o en una demostración técnica eso es aceptable. En producción, con herramientas de observabilidad accesibles a equipos de soporte, operaciones o incluso a proveedores externos, es un problema real.
La respuesta convencional a este problema es la convención: no logueen datos personales. Ya se exploró en la primera parte por qué las convenciones fallan, y el argumento aplica aquí con la misma fuerza. Una convención requiere que cada desarrollador, en cada momento, recuerde aplicarla. El día que alguien olvida, o que un nuevo integrante del equipo no la conoce, la protección desaparece sin dejar rastro. La única solución que escala es que la privacidad deje de ser responsabilidad de quien escribe el log y pase a ser una propiedad declarada en el modelo. Esta tercera parte documenta cómo se implementa exactamente eso.
El punto de partida: qué tenía el sistema y qué faltaba
Al concluir la segunda parte, el sistema de logs contaba con cinco artefactos: LoggingAopProperties para la configuración de patrones de interceptación, JacksonConfig para el ObjectMapper del aspecto, MethodLoggingAspect como motor de interceptación, la clase principal de la aplicación con @EnableConfigurationProperties, y el archivo application.properties. Cinco piezas que funcionaban como una unidad cohesionada.
El problema que esta iteración viene a resolver surgió de una decisión de diseño inicial que parecía razonable en ese momento: el ObjectMapper que usaba el aspecto para serializar argumentos y resultados era el mismo que Spring MVC usaba para las respuestas HTTP. Esto implicaba que cualquier cambio en la serialización para los logs afectaría también al contrato público de la API. Si se añadía un introspector que enmascarara emails, los emails llegarían enmascarados no solo al log, sino también al cliente que consumía la API. Es exactamente el tipo de acoplamiento involuntario que un diseño cuidadoso debe evitar: dos preocupaciones distintas compartiendo la misma pieza de infraestructura, sin que ninguna de las dos pueda evolucionar independientemente.
La primera tarea, entonces, era separar los dos mappers: uno para las respuestas HTTP, sin ninguna modificación, y otro exclusivo para los logs, que sería el que recibiría toda la lógica de enmascaramiento. Esta separación no es un detalle técnico menor. Es la decisión arquitectónica que hace posible todo lo que viene después.
La separación de los ObjectMapper
La solución es directa. Se crean dos configuraciones de Jackson independientes, cada una produciendo su propio bean con un calificador distinto.
SpringJacksonConfig, en el paquete applications/config, produce el ObjectMapper principal de la aplicación anotado con @Primary. Este mapper no tiene ningún introspector especial ni ninguna lógica de enmascaramiento. Es el que Spring MVC usa por defecto para serializar las respuestas HTTP y para deserializar los cuerpos de los requests, exactamente igual que antes:
@Configuration
public class SpringJacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
JacksonConfig, en el paquete applications/shared/serialization/config, produce un segundo ObjectMapper identificado con el calificador "loggingObjectMapper". Este es el que el aspecto recibe por inyección y el único que conoce la existencia del sistema de enmascaramiento:
@Configuration
public class JacksonConfig {
@Bean("loggingObjectMapper")
public ObjectMapper objectMapper(MaskingStrategyRegistry registry, MaskingProperties maskingProperties) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setAnnotationIntrospector(new DomainAnnotationIntrospectorConfig(registry, maskingProperties));
return mapper;
}
}
La línea que marca la diferencia es mapper.setAnnotationIntrospector(...). Un introspector en Jackson es el componente que decide, campo por campo, cómo debe serializarse cada propiedad de un objeto. Al inyectar un introspector personalizado, se puede interceptar el proceso de serialización en el momento exacto en que Jackson va a escribir un campo y aplicar la lógica de enmascaramiento antes de que el valor llegue al log. El aspecto, por su parte, pasa a inyectar el mapper correcto usando el calificador:
private final @Qualifier("loggingObjectMapper") ObjectMapper objectMapper;
A partir de este punto, los dos mappers evolucionan de forma completamente independiente. Añadir una nueva estrategia de enmascaramiento, modificar el comportamiento de una existente, o cambiar cómo se resuelven las reglas por nombre de campo son operaciones que ocurren en el sistema de logs sin afectar en absoluto las respuestas HTTP de la aplicación.
Las anotaciones del dominio
Con la infraestructura de serialización dividida, el siguiente paso es definir el vocabulario que el modelo de dominio usará para declarar la sensibilidad de sus campos. Ese vocabulario son tres anotaciones que viven en el paquete del dominio, completamente aisladas de cualquier dependencia de infraestructura.
La primera es @Hidden. Cuando un campo está anotado con ella, el introspector le indica a Jackson que lo omita completamente durante la serialización. No aparece como null, no aparece enmascarado: directamente no existe en el JSON producido para el log. El caso de uso más claro es el de campos cuyo tamaño o naturaleza los hace inadecuados para cualquier registro: imágenes en Base64, documentos adjuntos, objetos anidados muy grandes. En el proyecto de ejemplo se aplica sobre el campo fechaRegistro del modelo Usuario, que es un dato técnico interno sin valor para el diagnóstico operacional:
@Hidden
private LocalDateTime fechaRegistro;
La segunda es @Masked. Esta anotación indica que el campo contiene información sensible y que su valor debe transformarse antes de escribirse en el log. A diferencia de @Hidden, el campo sigue apareciendo en el registro, pero con su contenido protegido. La anotación acepta cuatro parámetros que controlan cómo se aplica la transformación: type define la estrategia de enmascaramiento, visibleStart y visibleEnd especifican cuántos caracteres se preservan al inicio y al final del valor original, y maskChar define el carácter de relleno. Cuando no se especifica ningún parámetro, el comportamiento por defecto es enmascaramiento total con asteriscos:
@Masked(type = MaskType.EMAIL)
private String email;
@Masked
private Integer edad;
@Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*')
private String username;
La tercera anotación, @NoMask, sirve como escape explícito del sistema. Si un campo pertenece a una clase que tiene reglas globales por nombre aplicadas desde application.properties, pero ese campo en particular no debe enmascararse aunque su nombre coincida con alguna regla, @NoMask garantiza que el introspector lo serialice sin ninguna transformación. Es la forma de decir explícitamente que este campo, en este contexto, es seguro para el log.
Las tres se definen con retención RUNTIME para que estén disponibles mediante reflexión en el momento de la serialización, y con target FIELD porque se aplican sobre los campos del modelo:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Hidden { }
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
MaskType type() default MaskType.FULL;
int visibleStart() default -1;
int visibleEnd() default -1;
char maskChar() default '*';
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMask { }
Junto a las anotaciones, el enumerado MaskType define el catálogo de estrategias disponibles. En lugar de pasar strings o constantes al sistema, cada campo declara su tipo de máscara usando un valor tipado:
public enum MaskType {
EMAIL,
PHONE,
CREDIT_CARD,
DOCUMENT,
PASSWORD,
TOKEN,
IBAN,
FULL,
CUSTOM
}
El catálogo incluye tanto tipos genéricos —FULL para enmascaramiento total y CUSTOM para transformaciones configuradas con los parámetros de la anotación— como tipos específicos por categoría de dato. El tipo CUSTOM merece una mención especial: es el que habilita las máscaras de desplazamiento, donde el desarrollador controla exactamente cuántos caracteres quedan visibles y desde dónde, sin necesidad de crear una estrategia nueva para cada variante.
Vale la pena detenerse un momento en dónde viven estas definiciones. Las anotaciones y el enumerado están en el paquete domain/shared/serialization/masking, dentro del dominio. No en infraestructura, no en la capa de aplicación: en el dominio. Esto es deliberado y tiene una implicación directa en el modelo de propiedad: quien define qué es sensible es el modelo de dominio mismo, en el mismo lugar donde se define la estructura del dato. Cuando un desarrollador abre Usuario.java y ve @Masked(type = MaskType.EMAIL) sobre el campo email, la intención es inmediata y no requiere buscar configuración en ningún otro archivo.
Las estrategias de enmascaramiento
Con el vocabulario de declaración definido, se necesita el mecanismo de ejecución: las clases que saben cómo transformar un valor según cada tipo de máscara. El diseño usa el patrón Strategy, con una interfaz común que todas las implementaciones respetan:
public interface MaskingStrategy {
String mask(String value, Masked annotation);
}
El parámetro annotation no es ceremonial. Algunas estrategias, como CUSTOM, necesitan leer los valores de visibleStart, visibleEnd y maskChar de la anotación para saber cómo operar. Pasarla como argumento en lugar de extraerla en cada implementación hace que la interfaz sea suficientemente expresiva para todos los casos sin requerir que las estrategias simples la utilicen.
Cada implementación se anota con @MaskTypeHandler, una anotación personalizada que actúa como metadato de registro:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface MaskTypeHandler {
MaskType value();
}
La anotación también incluye @Component, lo que hace que cada estrategia sea automáticamente un bean de Spring. Esto permite que MaskingStrategyRegistry, el componente que centraliza el acceso a las estrategias, las reciba todas por inyección de lista y construya un mapa indexado por tipo en su constructor:
@Component
public class MaskingStrategyRegistry {
private final Map<MaskType, MaskingStrategy> strategies = new EnumMap<>(MaskType.class);
public MaskingStrategyRegistry(List<MaskingStrategy> strategiesList) {
for (MaskingStrategy strategy : strategiesList) {
MaskTypeHandler annotation = strategy.getClass().getAnnotation(MaskTypeHandler.class);
if (annotation != null) {
strategies.put(annotation.value(), strategy);
}
}
}
public MaskingStrategy get(MaskType type) {
return strategies.get(type);
}
}
Este diseño tiene una propiedad muy conveniente: añadir una nueva estrategia de enmascaramiento al sistema se reduce a crear una clase que implemente MaskingStrategy y anotarla con @MaskTypeHandler indicando el tipo. El registry la descubre automáticamente en el siguiente arranque de la aplicación, sin ningún lugar central que modificar.
Las tres estrategias que el proyecto implementa ilustran el rango de transformaciones posibles. FullMaskingStrategy es la más simple: reemplaza cualquier valor con "****" independientemente de su contenido, cuando el dato no debe revelar ninguna información ni siquiera estructural:
@MaskTypeHandler(MaskType.FULL)
public class FullMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null) return null;
return "****";
}
}
EmailMaskingStrategy preserva la estructura del correo electrónico, manteniendo el dominio visible y enmascarando la parte local excepto los primeros dos caracteres. Un correo como [email protected] se convierte en ju***@empresa.com. Esta transformación comunica que el valor era un email y a qué dominio pertenecía, sin revelar la identidad del destinatario:
@MaskTypeHandler(MaskType.EMAIL)
public class EmailMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || !value.contains("@")) {
return value;
}
String[] parts = value.split("@", 2);
String local = parts[0];
String domain = parts[1];
if (local.length() <= 2) {
return "*@" + domain;
}
return local.substring(0, 2) + "***@" + domain;
}
}
CustomMaskingStrategy delega en OffsetMasker, un componente que implementa la lógica de máscara por desplazamiento. Recibe los parámetros visibleStart, visibleEnd y maskChar directamente de la anotación y preserva exactamente esa cantidad de caracteres en cada extremo del valor, reemplazando el centro con el carácter de máscara configurado. Si la suma de los caracteres visibles es mayor o igual a la longitud total del valor, la cadena se retorna sin modificación, evitando transformaciones que no aportarían ningún tipo de protección real:
@RequiredArgsConstructor
@MaskTypeHandler(MaskType.CUSTOM)
public class CustomMaskingStrategy implements MaskingStrategy {
private final OffsetMasker offsetMasker;
@Override
public String mask(String value, Masked annotation) {
return offsetMasker.mask(value, annotation);
}
}
@Component
public class OffsetMasker {
public String mask(String value, Masked annotation) {
return Optional.ofNullable(value)
.filter(v -> !v.isBlank())
.filter(v -> annotation != null)
.map(v -> {
int length = v.length();
int visibleStart = annotation.visibleStart();
int visibleEnd = annotation.visibleEnd();
if (visibleStart + visibleEnd >= length) {
return v;
}
String start = v.substring(0, visibleStart);
String end = v.substring(length - visibleEnd);
String fixedMask = String.valueOf(annotation.maskChar()).repeat(4);
return start + fixedMask + end;
})
.orElse(value);
}
}
Aplicado sobre el campo username con la declaración @Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*'), un valor como juanperez se transforma en ju****ez. Los dos primeros y los dos últimos caracteres permanecen visibles; el centro queda reemplazado por cuatro asteriscos, independientemente de cuántos caracteres haya entre los extremos. Esta consistencia en la longitud del bloque de máscara es deliberada: evita que la longitud del valor enmascarado revele indirectamente la longitud del valor original.
El introspector: donde todo se conecta
Las estrategias saben cómo transformar valores, las anotaciones declaran qué campos son sensibles, y el registry mapea tipos a estrategias. La pieza que conecta todos estos elementos durante la serialización es DomainAnnotationIntrospectorConfig, la implementación personalizada del introspector de Jackson.
Esta clase extiende JacksonAnnotationIntrospector, que es el introspector estándar de Jackson. Al extender en lugar de reemplazar, se hereda todo el comportamiento normal de serialización y solo se sobreescriben los dos métodos relevantes para el enmascaramiento: hasIgnoreMarker, que controla si un campo debe omitirse, y findSerializer, que controla qué serializador se aplica sobre un campo.
La lógica de hasIgnoreMarker implementa la precedencia entre @NoMask y @Hidden. Si un campo tiene @NoMask, devuelve false independientemente de cualquier otra condición. Si tiene @Hidden, devuelve true para que Jackson lo excluya del JSON resultante. En cualquier otro caso delega al comportamiento estándar del padre:
@Override
public boolean hasIgnoreMarker(AnnotatedMember m) {
if (m.hasAnnotation(NoMask.class)) {
return false;
}
return m.hasAnnotation(Hidden.class) || super.hasIgnoreMarker(m);
}
La lógica de findSerializer implementa tres niveles de prioridad. El primer nivel es @NoMask: si el campo tiene esta anotación, el método devuelve el serializador estándar sin ninguna modificación. El segundo nivel es @Masked: si el campo tiene esta anotación, se construye un MaskedSerializer con el tipo y la anotación completa. El tercer nivel son las reglas por nombre de campo definidas en application.properties: si el nombre del campo coincide con alguna de esas reglas, se construye una instancia sintética de @Masked con el tipo resuelto y se aplica el mismo MaskedSerializer:
@Override
public Object findSerializer(Annotated am) {
if (am.hasAnnotation(NoMask.class)) {
return super.findSerializer(am);
}
Masked masked = am.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
if (!resolvedRules.isEmpty()
&& am instanceof AnnotatedMethod
&& am.getName() != null) {
String fieldName = am.getName();
MaskType resolvedType = resolveByFieldName(fieldName);
if (resolvedType != null) {
Masked syntheticMasked = buildSyntheticMasked(resolvedType);
return new MaskedSerializer(registry, syntheticMasked.type(), syntheticMasked);
}
}
return super.findSerializer(am);
}
Este sistema de prioridades tiene una consecuencia operacional importante: las reglas por nombre de campo desde application.properties actúan como una red de seguridad para los datos que todavía no tienen anotación en el modelo. Si el equipo decide que todo campo cuyo nombre contenga email debe enmascararse como EMAIL aunque ningún campo del dominio tenga @Masked, basta con agregar la regla en el archivo de configuración.
La resolución de las reglas por nombre usa coincidencia parcial insensible a mayúsculas. Si más de una regla coincide con el mismo campo, el sistema aplica FULL como estrategia por defecto, eligiendo siempre la opción más conservadora ante la ambigüedad:
private MaskType resolveByFieldName(String fieldName) {
String fieldNameLower = fieldName.toLowerCase();
List<MaskType> matches = resolvedRules.entrySet().stream()
.filter(entry -> fieldNameLower.contains(entry.getKey()))
.map(Map.Entry::getValue)
.collect(Collectors.toList());
if (matches.isEmpty()) return null;
if (matches.size() > 1) return MaskType.FULL;
return matches.get(0);
}
Las reglas se pre-procesan en el constructor del introspector, convirtiendo los strings del mapa de propiedades a valores tipados de MaskType una sola vez en el momento de creación del bean. Esto garantiza que la comparación durante la serialización sea siempre una operación de bajo costo:
private Map<String, MaskType> buildResolvedRules(Map<String, String> rawRules) {
if (rawRules == null || rawRules.isEmpty()) {
return Map.of();
}
return rawRules.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().toLowerCase().trim(),
entry -> resolveMaskType(entry.getValue())));
}
Si el string del valor en las propiedades no corresponde a ningún valor del enumerado MaskType, el método resolveMaskType devuelve FULL como fallback. Ante una configuración incorrecta o ambigua, el sistema protege más de lo necesario en lugar de exponer datos que deberían estar protegidos.
El serializador contextual
MaskedSerializer es el componente que Jackson invoca directamente cuando necesita escribir el valor de un campo que el introspector ha marcado para enmascarar. Implementa dos interfaces: JsonSerializer<Object>, que es el contrato estándar de serialización, y ContextualSerializer, que permite a Jackson pasar información adicional sobre el contexto del campo en el momento de la serialización.
La implementación de ContextualSerializer a través del método createContextual resuelve un problema sutil. Jackson no siempre invoca directamente el serializador registrado para un campo: a veces lo crea primero y luego le pasa el contexto del campo a través de createContextual. Sin esta interfaz, el serializador puede perder acceso a la anotación @Masked del campo concreto y por tanto a sus parámetros de configuración:
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Masked masked = property.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
}
if (maskType != null && maskedAnnotation != null) {
return this;
}
return new MaskedSerializer(registry);
}
La serialización del valor en sí es directa: si el valor es nulo se escribe null, de lo contrario se convierte a string, se consulta la estrategia correspondiente en el registry y se escribe el resultado transformado. Si por alguna razón no hay estrategia disponible para el tipo indicado, el campo se escribe como "****", garantizando que ningún dato sensible llegue al log incluso en casos de configuración incompleta:
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String strValue = value.toString();
if (maskType != null && maskedAnnotation != null) {
MaskingStrategy strategy = registry.get(maskType);
if (strategy != null) {
gen.writeString(strategy.mask(strValue, maskedAnnotation));
return;
}
}
gen.writeString("****");
}
El enmascaramiento de tipos simples en los parámetros de entrada
Las anotaciones sobre los campos del modelo de dominio cubren la serialización de los objetos que el aspecto captura como argumentos o resultados. Pero hay una categoría de casos que ese mecanismo no alcanza: los métodos que reciben tipos simples como parámetros directos, sin que haya un objeto con campos anotados de por medio.
Un ejemplo concreto está en el propio proyecto de ejemplo. El método existeEmail del adapter recibe un String como argumento:
public boolean existeEmail(String email)
Cuando el aspecto intercepta esa llamada y loguea el INPUT, el argumento es directamente el string con el correo electrónico. No hay ningún objeto Usuario que serializar, no hay ninguna anotación @Masked sobre el parámetro —Java no permite aplicar las anotaciones de campo sobre parámetros de método con la misma semántica—, y el ObjectMapper con el introspector no tiene forma de saber que ese string en particular es un email que debe enmascararse.
Para cubrir este caso, el aspecto implementa un mecanismo complementario de enmascaramiento a nivel de parámetro, basado en el nombre del parámetro en lugar de en una anotación sobre el campo. La clase MaskingProperties expone un mapa de reglas configurables desde application.properties:
logging.masking.field-name-rules.email=EMAIL
logging.masking.field-name-rules.phone=PHONE
El aspecto aplica esta lógica en el método maskIfSimpleType, que se invoca sobre cada argumento antes de que llegue a formatArg para la serialización:
private Object maskIfSimpleType(String paramName, Object value) {
if (value == null) return null;
if (!LoggingUtils.isSimpleType(value)) return value;
Map<String, String> rules = maskingProperties.getFieldNameRules();
if (rules == null || rules.isEmpty()) return value;
String paramNameLower = paramName.toLowerCase();
List<MaskType> matches = rules.entrySet().stream()
.filter(entry -> paramNameLower.contains(
entry.getKey().toLowerCase().trim()))
.map(entry -> LoggingUtils.resolveMaskType(entry.getValue()))
.collect(Collectors.toList());
if (matches.isEmpty()) return value;
MaskType maskType = matches.size() > 1 ? MaskType.FULL : matches.get(0);
MaskingStrategy strategy = maskingStrategyRegistry.get(maskType);
if (strategy == null) return "****";
return strategy.mask(value.toString(), LoggingUtils.buildSyntheticMasked(maskType));
}
El método solo actúa sobre tipos simples: String, Number, Boolean y Character. Para cualquier otro tipo, devuelve el valor sin modificación y deja que el ObjectMapper con el introspector maneje el enmascaramiento a través de las anotaciones del modelo. Esta separación evita la duplicación: los objetos complejos se enmascaran por vía del introspector, los tipos simples por vía del nombre del parámetro.
Cuando la estrategia necesita aplicarse sobre un tipo simple que no tiene una anotación real, se construye una instancia sintética de @Masked con los valores por defecto del tipo correspondiente. LoggingUtils.buildSyntheticMasked centraliza esa construcción:
public static Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
Los valores negativos en visibleStart y visibleEnd son intencionales. Las estrategias que no usan esos parámetros, como FULL o EMAIL, simplemente los ignoran. La estrategia CUSTOM, que sí los usa, los interpreta como ausencia de configuración y aplica su lógica de fallback. De esta forma, la instancia sintética es válida para cualquier estrategia sin necesidad de crear variantes distintas según el tipo.
El registro de las propiedades en el bootstrap
Con todos los componentes del sistema de enmascaramiento en su lugar, la clase principal de la aplicación necesita registrar tanto LoggingAopProperties como la nueva MaskingProperties para que Spring Boot las enlace con el prefijo correspondiente del archivo de configuración:
@SpringBootApplication
@EnableConfigurationProperties({
LoggingAopProperties.class,
MaskingProperties.class
})
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
Sin MaskingProperties en esta lista, Spring Boot crea el bean pero no lo enlaza con el prefijo logging.masking. El mapa de reglas permanece vacío, el introspector construye un resolvedRules vacío, y el aspecto devuelve todos los tipos simples sin enmascarar. Es el mismo error silencioso que ocurriría si LoggingAopProperties no estuviera registrada: el sistema arranca sin errores, pero el comportamiento es diferente al esperado y sin ninguna advertencia visible.
La nueva estructura del sistema de logs
Con estas incorporaciones, el sistema de logs pasa de cinco artefactos a diecisiete. La división entre applications/shared/serialization y domain/shared/serialization refleja con precisión el límite de responsabilidades:
src/main/java/com/app_247/blog/id202603212000art/
│
├── Id202603212000artApplication.java
│
├── applications/
│ ├── config/
│ │ └── SpringJacksonConfig.java ← ObjectMapper principal (@Primary)
│ │
│ └── shared/
│ ├── log/
│ │ ├── aspect/
│ │ │ └── MethodLoggingAspect.java ← Motor de interceptación
│ │ ├── config/
│ │ │ └── LoggingAopProperties.java ← Patrones de interceptación
│ │ └── tool/
│ │ ├── LoggingUtils.java ← Utilidades de formato
│ │ └── PatternMatcher.java ← Evaluación y cache de patrones
│ │
│ └── serialization/
│ ├── config/
│ │ ├── DomainAnnotationIntrospectorConfig.java ← Introspector de máscaras
│ │ ├── JacksonConfig.java ← ObjectMapper de logs
│ │ └── MaskingProperties.java ← Reglas por nombre de campo
│ ├── strategy/
│ │ ├── MaskedSerializer.java ← Serializador contextual
│ │ ├── MaskingStrategy.java ← Interfaz de estrategias
│ │ ├── MaskingStrategyRegistry.java ← Registro de estrategias
│ │ ├── MaskTypeHandler.java ← Anotación de registro
│ │ └── strategies/
│ │ ├── CustomMaskingStrategy.java
│ │ ├── EmailMaskingStrategy.java
│ │ └── FullMaskingStrategy.java
│ └── util/
│ └── OffsetMasker.java ← Lógica de máscara por offset
│
└── domain/
└── shared/
└── serialization/
└── masking/
├── annotation/
│ ├── Hidden.java
│ ├── Masked.java
│ └── NoMask.java
└── vo/
└── MaskType.java
El dominio declara la intención: este campo es un email sensible, este campo no debe aparecer en ningún registro. La capa de aplicación ejecuta esa intención: sabe cómo transformar un email, sabe cómo invocar a Jackson con el introspector correcto, sabe cómo resolver los nombres de parámetro contra las reglas de configuración. El dominio no sabe nada de Jackson. La capa de aplicación no necesita saber qué datos son sensibles porque el dominio ya se lo comunicó a través de las anotaciones.
El flujo completo con enmascaramiento activo
Con todos los componentes integrados, vale la pena recorrer la salida real del sistema para el mismo flujo de registro de usuario que se documentó en la segunda parte, esta vez con el enmascaramiento activo.
La solicitud llega al Controller con nombre Juan Perez, email [email protected] y edad 25. El INPUT del Controller serializa el objeto RegistrarUsuarioRequest completo. Si la regla logging.masking.field-name-rules.email=EMAIL está activa, el campo email del request aparece transformado:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
>>> [INPUT] | args: {request={"nombre":"Juan Perez","email":"ju***@empresa.com","edad":25}}
El UseCase recibe el RegistrarUsuarioIn y el aspecto loguea su INPUT. Los campos de este DTO tampoco tienen anotaciones directas, pero el email sigue siendo detectado por la regla de nombre de campo:
INFO : c.a.b.i.d.u.registrarusuario.RegistrarUsuarioUseCase#ejecutar
>>> [INPUT] | args: {command={"nombre":"Juan Perez","email":"ju***@empresa.com","edad":25}}
El adapter consulta si el email existe. Aquí el parámetro es un String directo, y el mecanismo de enmascaramiento de tipos simples entra en acción. El nombre del parámetro es email, coincide con la regla configurada, y la estrategia EMAIL se aplica sobre el string antes de que llegue al log:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
>>> [INPUT] | args: {email="ju***@empresa.com"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
<<< [OUTPUT] | return: false
WARN : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#existeEmail
*** [TIMING] | start: 15:47:05.750 | end: 15:47:05.870 | elapsed: 120ms ⚠️ superó umbral de 100ms
El UseCase construye el objeto Usuario y llama a gateway.guardar(). Aquí es donde el enmascaramiento basado en anotaciones del modelo muestra su efecto más visible. El objeto Usuario tiene cuatro campos con comportamiento especial: email con @Masked(type = MaskType.EMAIL), edad con @Masked por defecto que aplica FULL, username con @Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*'), y fechaRegistro con @Hidden. El introspector lee esas anotaciones en el momento de la serialización y produce un JSON donde cada campo respeta exactamente la declaración del modelo:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
>>> [INPUT] | args: {usuario={"id":null,"nombre":"Juan Perez",
"email":"ju***@empresa.com","edad":"****",
"username":"ju****ez"}}
Tres aspectos de este registro merecen atención. Primero: fechaRegistro no aparece en absoluto, ni como null, ni como "****". La anotación @Hidden le indica al introspector que omita el campo completamente, y Jackson lo excluye sin dejar ninguna huella de su existencia. Segundo: edad es un entero, pero en el log aparece como "****", una cadena; el MaskedSerializer convierte cualquier valor a string antes de aplicar la máscara, porque la representación en el log es siempre texto. Tercero: username muestra "ju****ez", preservando exactamente dos caracteres al inicio y dos al final, con cuatro asteriscos en el centro independientemente de cuántos caracteres tenga el username real.
La persistencia ocurre y el adapter retorna el objeto Usuario con el id ya asignado. El OUTPUT aplica exactamente el mismo enmascaramiento sobre el objeto de retorno:
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez",
"email":"ju***@empresa.com","edad":"****",
"username":"ju****ez"}
DEBUG : c.a.b.i.i.d.j.u.adapter.UsuarioPersistenciaAdapter#guardar
*** [TIMING] | start: 15:47:05.877 | end: 15:47:05.939 | elapsed: 62ms
Finalmente, el Controller retorna el response HTTP. El RegistrarUsuarioResponse tampoco tiene anotaciones de enmascaramiento, pero las reglas por nombre de campo del introspector siguen activas:
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
<<< [OUTPUT] | return: {"id":1,"nombre":"Juan Perez",
"email":"ju***@empresa.com","username":"ju****ez",
"fechaRegistro":"2026-05-18T15:47:05.8756894",
"mensaje":"Usuario registrado exitosamente"}
INFO : c.a.b.i.i.e.a.registrarusuario.RegistrarUsuarioController#registrar
*** [TIMING] | start: 15:47:05.747 | end: 15:47:05.944 | elapsed: 197ms
La respuesta HTTP que el cliente recibe es completamente diferente. El SpringJacksonConfig produce un ObjectMapper sin ningún introspector de enmascaramiento, así que Spring MVC serializa el RegistrarUsuarioResponse tal como está, con todos sus campos en texto plano. El cliente ve el email completo, el username completo y la fecha de registro. El enmascaramiento existe exclusivamente en los logs y no tiene ningún efecto sobre el contrato público de la API.
Dos canales, dos contratos
Recorrer el flujo completo con enmascaramiento activo hace visible algo que conviene nombrar explícitamente porque es la garantía central de todo el diseño: los datos sensibles tienen dos representaciones distintas que nunca se contaminan entre sí.
En el canal de respuesta HTTP, los datos viajan en su forma original. El ObjectMapper principal, marcado con @Primary, es el que Spring Boot usa por defecto para cualquier serialización no calificada. No tiene introspector de enmascaramiento, no conoce la existencia de @Masked ni de @Hidden, y serializa exactamente lo que recibe.
En el canal de logs, los datos viajan en su forma protegida. El ObjectMapper de logs, identificado con el calificador "loggingObjectMapper", es el único que conoce el sistema de enmascaramiento. Solo lo recibe el aspecto, explícitamente a través de @Qualifier. Ningún otro componente del sistema puede confundirlo con el mapper principal porque @Primary garantiza que Spring resuelva cualquier inyección sin calificador hacia el mapper de HTTP.
Esta separación tiene una consecuencia que vale la pena señalar para los equipos que trabajan con múltiples desarrolladores en paralelo: es físicamente imposible introducir un bug donde el enmascaramiento afecte la respuesta HTTP, o donde la ausencia de enmascaramiento exponga datos en el log, siempre que se respeten dos reglas. Primera: cualquier inyección del ObjectMapper que no sea en el aspecto debe hacerse sin calificador, recibiendo siempre el bean @Primary. Segunda: cualquier nueva estrategia de enmascaramiento se registra en el sistema de logs a través de @MaskTypeHandler, nunca modificando el SpringJacksonConfig.
Si alguna vez aparece en una revisión de código un @Qualifier("loggingObjectMapper") en una clase que no sea MethodLoggingAspect, es una señal de alerta clara. La arquitectura hace visible la anomalía antes de que llegue a producción.
Privacidad por configuración, privacidad por declaración
El sistema implementa dos mecanismos de enmascaramiento que son complementarios pero que sirven propósitos distintos, y entender cuándo usar cada uno evita duplicaciones innecesarias y configuraciones contradictorias.
Las reglas por nombre de campo en application.properties son el mecanismo de cobertura amplia. Funcionan sobre cualquier objeto que el aspecto serialice, incluso si ese objeto pertenece a una librería externa o a una capa de la aplicación que no tiene acceso al paquete del dominio para añadir anotaciones. Son también el mecanismo de transición: mientras el equipo va añadiendo las anotaciones correctas al modelo, las reglas por nombre garantizan que los campos sensibles no queden expuestos en el proceso de migración. Su limitación es la precisión: una regla que aplica a cualquier campo cuyo nombre contenga email puede afectar campos que no son correos electrónicos pero que tienen esa cadena en su nombre por coincidencia.
Las anotaciones @Masked y @Hidden en el modelo son el mecanismo de precisión quirúrgica. Se aplican campo a campo, con el tipo exacto de transformación que corresponde a cada dato, y viven donde tienen semántica: en la definición del modelo. Su limitación es que requieren acceso al código fuente del modelo para añadirlas, lo cual no siempre es posible para tipos de terceros.
La convivencia de ambos mecanismos está gestionada por el orden de prioridades del introspector: @NoMask gana sobre todo, @Masked gana sobre las reglas por nombre, y las reglas por nombre actúan cuando no hay ninguna anotación. Un patrón de adopción razonable sería comenzar con reglas por nombre para tener cobertura inmediata en las categorías más sensibles —email, password, token, phone, document—, e ir añadiendo anotaciones al modelo progresivamente. Cuando todos los campos sensibles tienen su anotación, las reglas por nombre pasan a ser una segunda línea de defensa para los casos que se hayan podido olvidar.
Añadir una nueva estrategia de enmascaramiento
Uno de los beneficios del diseño basado en @MaskTypeHandler y MaskingStrategyRegistry es que extender el catálogo de estrategias es un proceso completamente autocontenido. Para ilustrarlo, los pasos para añadir una estrategia de enmascaramiento de números de teléfono que preserve los últimos cuatro dígitos son exactamente tres.
Primero, añadir el valor al enumerado si no existe ya:
public enum MaskType {
EMAIL,
PHONE, // ya existe en el catálogo
// ...
}
Segundo, crear la clase de estrategia con la anotación @MaskTypeHandler:
@MaskTypeHandler(MaskType.PHONE)
public class PhoneMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || value.length() < 4) {
return "****";
}
String lastFour = value.substring(value.length() - 4);
return "****" + lastFour;
}
}
Tercero, activar la regla en las propiedades si se quiere que aplique por nombre de campo sin necesidad de anotar cada campo individualmente:
logging.masking.field-name-rules.phone=PHONE
En el siguiente arranque de la aplicación, MaskingStrategyRegistry descubre la nueva clase a través de la inyección de lista y la registra bajo MaskType.PHONE. Ninguna otra clase del sistema necesita ser modificada. El mismo patrón aplica para cualquier necesidad especial: números de identificación nacional, IBANs bancarios, tokens de autenticación con un formato particular. El sistema crece con el proyecto sin acumular complejidad en ningún componente central.
Lo que el sistema no puede hacer solo
Con todo lo documentado hasta aquí, es tentador concluir que el sistema de enmascaramiento cubre la privacidad de los logs de forma completa y automática. Eso sería inexacto, y vale la pena ser preciso sobre los límites.
El sistema protege los datos que el modelo declara como sensibles y los que las reglas por nombre cubren. No puede proteger los datos que nadie declaró como sensibles. Si un desarrollador añade un campo numeroCuentaBancaria al modelo de dominio sin ninguna anotación y sin que ninguna regla por nombre lo cubra, ese campo llegará al log en texto plano.
Tampoco puede actuar sobre el contenido de los mensajes de excepción. Cuando el sistema loguea exception: BusinessException - El email [email protected] ya está registrado, el mensaje de la excepción llega al log tal como fue construido en el código que la lanzó. La única solución es no incluir datos sensibles en el mensaje de la excepción desde el inicio, lo cual es una decisión que ocurre en el momento de escribir el throw, no en el sistema de logs.
El mismo límite aplica para los stacktraces completos. Si en algún punto del sistema se loguea un stacktrace fuera del aspecto, y ese stacktrace incluye representaciones de objetos con datos sensibles en sus métodos toString(), esos datos quedarán expuestos. La regla práctica que complementa al sistema automatizado es la misma que aplica a cualquier mecanismo de privacidad: los datos sensibles no deben aparecer en los mensajes de error, en los métodos toString() de los modelos, ni en ningún otro lugar desde el que puedan filtrarse hacia un log sin pasar por el introspector.
Estas limitaciones no invalidan el diseño, lo contextualizan. El sistema automatizado elimina la categoría más grande y frecuente de exposición accidental. La categoría residual requiere disciplina en el código que construye los mensajes, y esa disciplina es significativamente más fácil de aplicar cuando el desarrollador sabe que el resto del sistema ya está cubierto.
Mirando hacia adelante
El sistema de observabilidad que esta serie ha construido a lo largo de sus tres partes es funcional, extensible y listo para producción. Pero como cualquier sistema bien diseñado, establece una base desde la que hay líneas naturales de evolución.
La integración con OpenTelemetry es la más inmediata. Los registros estructurados que produce el aspecto, con sus campos de capa, duración y firma de método, son compatibles con el modelo de spans de OpenTelemetry. Los mismos puntos de interceptación que hoy emiten registros de texto podrían emitir spans instrumentados que plataformas como Jaeger o Zipkin renderizan como árboles de llamadas con tiempos y metadatos visuales. La transición no requeriría cambios en ninguna clase de negocio: solo en el aspecto, que ya tiene toda la información necesaria para construir esos spans.
La generación de métricas con Micrometer desde los mismos puntos de interceptación eliminaría la duplicación entre el sistema de logs y el sistema de métricas. Hoy, para calcular la latencia promedio de un adapter externo es necesario parsear los registros TIMING. Con Micrometer integrado en el aspecto, ese mismo dato podría alimentar un histograma directamente en el momento de la interceptación, sin ningún procesamiento posterior.
El catálogo de estrategias también puede crecer según las necesidades de cada proyecto. Los tipos CREDIT_CARD, DOCUMENT, IBAN y TOKEN están definidos en el enumerado MaskType pero no tienen implementación en el proyecto de ejemplo. Añadir cada uno es exactamente el proceso de tres pasos que se describió antes. El sistema está diseñado para crecer en esa dirección sin ninguna fricción estructural.
Finalmente, el patrón de aspectos transversales que sostiene todo el sistema de observabilidad puede extenderse a otros dominios de preocupación que comparten la misma naturaleza: la auditoría de cambios de estado, el registro de accesos a datos sensibles para cumplimiento regulatorio, la validación automática de contratos entre capas. La mecánica es idéntica, y el código de negocio permanece completamente ajeno a esas preocupaciones.
Lo que esta serie ha documentado, más allá de los detalles técnicos de cada componente, es un argumento sobre cómo se relacionan la observabilidad y la privacidad cuando se tratan como decisiones arquitectónicas en lugar de como detalles de implementación. Cuando la observabilidad se diseña con los mismos principios que la lógica de negocio —separación de responsabilidades, consistencia y extensibilidad— y cuando la privacidad se declara donde tiene semántica, en el modelo de dominio junto al dato que protege, el resultado no es solo un sistema de logs que funciona. Es un sistema que el equipo puede confiar, extender y razonar, en producción, sin adivinar y sin comprometer la seguridad de los datos de los usuarios.
anexo markdown - Código fuente completo
# Anexo: Código fuente completo
El código completo del sistema de enmascaramiento se organiza en grupos funcionales que reflejan las responsabilidades de cada componente. Todos los artefactos están disponibles para que puedas reproducir el sistema en tu proyecto.
---
## Grupo 1 — Anotaciones de privacidad en el dominio
Las anotaciones que declaran la privacidad de los datos viven en el paquete de dominio y no tienen dependencias de infraestructura.
**Hidden.java** — Omite completamente un campo de los logs
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Hidden {
}
```
**Masked.java** — Enmascara un campo según el tipo especificado
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
MaskType type() default MaskType.FULL;
int visibleStart() default -1;
int visibleEnd() default -1;
char maskChar() default '*';
}
```
**NoMask.java** — Excluye explícitamente un campo del enmascaramiento
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMask {
}
```
**MaskType.java** — Enum con los tipos de enmascaramiento disponibles
```java
package com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo;
public enum MaskType {
EMAIL,
PHONE,
CREDIT_CARD,
DOCUMENT,
PASSWORD,
TOKEN,
IBAN,
FULL,
CUSTOM
}
```
---
## Grupo 2 — Estrategias de enmascaramiento
Cada estrategia implementa una forma específica de ocultar datos sensibles.
**MaskingStrategy.java** — Interfaz que todas las estrategias deben cumplir
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
public interface MaskingStrategy {
String mask(String value, Masked annotation);
}
```
**MaskTypeHandler.java** — Anotación para registrar estrategias automáticamente
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface MaskTypeHandler {
MaskType value();
}
```
**EmailMaskingStrategy.java** — Enmascara emails preservando el dominio
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@MaskTypeHandler(MaskType.EMAIL)
public class EmailMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null || !value.contains("@")) {
return value;
}
String[] parts = value.split("@", 2);
String local = parts[0];
String domain = parts[1];
if (local.length() <= 2) {
return "*@" + domain;
}
return local.substring(0, 2) + "***@" + domain;
}
}
```
**FullMaskingStrategy.java** — Reemplaza todo el valor con asteriscos
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@MaskTypeHandler(MaskType.FULL)
public class FullMaskingStrategy implements MaskingStrategy {
@Override
public String mask(String value, Masked annotation) {
if (value == null) {
return null;
}
return "****";
}
}
```
**CustomMaskingStrategy.java** — Enmascara con control de caracteres visibles
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.strategies;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskTypeHandler;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategy;
import com.app_247.blog.id202603212000art.applications.shared.serialization.util.OffsetMasker;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@MaskTypeHandler(MaskType.CUSTOM)
public class CustomMaskingStrategy implements MaskingStrategy {
private final OffsetMasker offsetMasker;
@Override
public String mask(String value, Masked annotation) {
return offsetMasker.mask(value, annotation);
}
}
```
**OffsetMasker.java** — Utilidad para enmascarar con offsets configurables
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.util;
import java.util.Optional;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
@Component
public class OffsetMasker {
public String mask(String value, Masked annotation) {
return Optional.ofNullable(value)
.filter(v -> !v.isBlank())
.filter(v -> annotation != null)
.map(v -> {
int length = v.length();
int visibleStart = annotation.visibleStart();
int visibleEnd = annotation.visibleEnd();
if (visibleStart + visibleEnd >= length) {
return v;
}
String start = v.substring(0, visibleStart);
String end = v.substring(length - visibleEnd);
String fixedMask = String.valueOf(annotation.maskChar()).repeat(4);
return start + fixedMask + end;
})
.orElse(value);
}
}
```
**MaskingStrategyRegistry.java** — Registro centralizado de estrategias
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
@Component
public class MaskingStrategyRegistry {
private final Map<MaskType, MaskingStrategy> strategies = new EnumMap<>(MaskType.class);
public MaskingStrategyRegistry(List<MaskingStrategy> strategiesList) {
for (MaskingStrategy strategy : strategiesList) {
MaskTypeHandler annotation = strategy.getClass().getAnnotation(MaskTypeHandler.class);
if (annotation != null) {
strategies.put(annotation.value(), strategy);
}
}
}
public MaskingStrategy get(MaskType type) {
return strategies.get(type);
}
}
```
---
## Grupo 3 — Configuración de Jackson para enmascaramiento
Estos componentes configuran Jackson para que aplique el enmascaramiento durante la serialización.
**MaskingProperties.java** — Propiedades de configuración para reglas por nombre
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import java.util.Collections;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Data;
@Data
@ConfigurationProperties(prefix = "logging.masking")
public class MaskingProperties {
private Map<String, String> fieldNameRules = Collections.emptyMap();
}
```
**DomainAnnotationIntrospectorConfig.java** — Introspector personalizado de Jackson
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskedSerializer;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategyRegistry;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Hidden;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.NoMask;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
public class DomainAnnotationIntrospectorConfig extends JacksonAnnotationIntrospector {
private final MaskingStrategyRegistry registry;
private final Map<String, MaskType> resolvedRules;
public DomainAnnotationIntrospectorConfig(
MaskingStrategyRegistry registry,
MaskingProperties maskingProperties) {
this.registry = registry;
this.resolvedRules = buildResolvedRules(maskingProperties.getFieldNameRules());
}
@Override
public boolean hasIgnoreMarker(AnnotatedMember m) {
if (m.hasAnnotation(NoMask.class)) {
return false;
}
return m.hasAnnotation(Hidden.class) || super.hasIgnoreMarker(m);
}
@Override
public Object findSerializer(Annotated am) {
if (am.hasAnnotation(NoMask.class)) {
return super.findSerializer(am);
}
Masked masked = am.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
if (!resolvedRules.isEmpty() && am instanceof AnnotatedMethod && am.getName() != null) {
String fieldName = am.getName();
MaskType resolvedType = resolveByFieldName(fieldName);
if (resolvedType != null) {
Masked syntheticMasked = buildSyntheticMasked(resolvedType);
return new MaskedSerializer(registry, syntheticMasked.type(), syntheticMasked);
}
}
return super.findSerializer(am);
}
private MaskType resolveByFieldName(String fieldName) {
String fieldNameLower = fieldName.toLowerCase();
List<MaskType> matches = resolvedRules.entrySet().stream()
.filter(entry -> fieldNameLower.contains(entry.getKey()))
.map(Map.Entry::getValue)
.collect(Collectors.toList());
if (matches.isEmpty()) {
return null;
}
if (matches.size() > 1) {
return MaskType.FULL;
}
return matches.get(0);
}
private Map<String, MaskType> buildResolvedRules(Map<String, String> rawRules) {
if (rawRules == null || rawRules.isEmpty()) {
return Map.of();
}
return rawRules.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().toLowerCase().trim(),
entry -> resolveMaskType(entry.getValue())));
}
private MaskType resolveMaskType(String value) {
if (value == null || value.isBlank()) {
return MaskType.FULL;
}
try {
MaskType type = MaskType.valueOf(value.toUpperCase().trim());
if (type == MaskType.CUSTOM) {
return MaskType.FULL;
}
return type;
} catch (IllegalArgumentException e) {
return MaskType.FULL;
}
}
private Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
}
```
**MaskedSerializer.java** — Serializador personalizado que aplica el enmascaramiento
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.strategy;
import java.io.IOException;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
public class MaskedSerializer extends JsonSerializer<Object> implements ContextualSerializer {
private final MaskingStrategyRegistry registry;
private final MaskType maskType;
private final Masked maskedAnnotation;
public MaskedSerializer(MaskingStrategyRegistry registry, MaskType maskType, Masked maskedAnnotation) {
this.registry = registry;
this.maskType = maskType;
this.maskedAnnotation = maskedAnnotation;
}
public MaskedSerializer(MaskingStrategyRegistry registry) {
this(registry, null, null);
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
String strValue = value.toString();
if (maskType != null && maskedAnnotation != null) {
MaskingStrategy strategy = registry.get(maskType);
if (strategy != null) {
gen.writeString(strategy.mask(strValue, maskedAnnotation));
return;
}
}
gen.writeString("****");
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Masked masked = property.getAnnotation(Masked.class);
if (masked != null) {
return new MaskedSerializer(registry, masked.type(), masked);
}
}
if (maskType != null && maskedAnnotation != null) {
return this;
}
return new MaskedSerializer(registry);
}
}
```
**JacksonConfig.java** — Configuración del ObjectMapper para logging
```java
package com.app_247.blog.id202603212000art.applications.shared.serialization.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.app_247.blog.id202603212000art.applications.shared.serialization.strategy.MaskingStrategyRegistry;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class JacksonConfig {
@Bean("loggingObjectMapper")
public ObjectMapper objectMapper(MaskingStrategyRegistry registry, MaskingProperties maskingProperties) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setAnnotationIntrospector(new DomainAnnotationIntrospectorConfig(registry, maskingProperties));
return mapper;
}
}
```
**SpringJacksonConfig.java** — ObjectMapper principal sin enmascaramiento
```java
package com.app_247.blog.id202603212000art.applications.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class SpringJacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
```
---
## Grupo 4 — Integración con el aspecto de logging
El aspecto de logging se actualiza para usar el enmascaramiento en valores simples.
**Fragmento relevante de MethodLoggingAspect.java** — Método que enmascara valores simples
```java
private Object maskIfSimpleType(String paramName, Object value) {
if (value == null) {
return null;
}
if (!LoggingUtils.isSimpleType(value)) {
return value;
}
Map<String, String> rules = maskingProperties.getFieldNameRules();
if (rules == null || rules.isEmpty()) {
return value;
}
String paramNameLower = paramName.toLowerCase();
List<MaskType> matches = rules.entrySet().stream()
.filter(entry -> paramNameLower.contains(entry.getKey().toLowerCase().trim()))
.map(entry -> LoggingUtils.resolveMaskType(entry.getValue()))
.collect(Collectors.toList());
if (matches.isEmpty()) {
return value;
}
MaskType maskType = matches.size() > 1 ? MaskType.FULL : matches.get(0);
MaskingStrategy strategy = maskingStrategyRegistry.get(maskType);
if (strategy == null) {
return "****";
}
return strategy.mask(value.toString(), LoggingUtils.buildSyntheticMasked(maskType));
}
```
**Fragmento de LoggingUtils.java** — Métodos auxiliares para enmascaramiento
```java
public static boolean isSimpleType(Object value) {
return value instanceof String
|| value instanceof Number
|| value instanceof Boolean
|| value instanceof Character;
}
public static MaskType resolveMaskType(String value) {
if (value == null || value.isBlank()) {
return MaskType.FULL;
}
try {
MaskType type = MaskType.valueOf(value.toUpperCase().trim());
return type == MaskType.CUSTOM ? MaskType.FULL : type;
} catch (IllegalArgumentException e) {
return MaskType.FULL;
}
}
public static Masked buildSyntheticMasked(MaskType maskType) {
return new Masked() {
@Override
public Class<? extends java.lang.annotation.Annotation> annotationType() {
return Masked.class;
}
@Override
public MaskType type() {
return maskType;
}
@Override
public int visibleStart() {
return -1;
}
@Override
public int visibleEnd() {
return -1;
}
@Override
public char maskChar() {
return '*';
}
};
}
```
---
## Grupo 5 — Ejemplo de uso en el modelo de dominio
**Usuario.java** — Entidad de dominio con campos anotados
```java
package com.app_247.blog.id202603212000art.domain.model.usuario;
import java.time.LocalDateTime;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Hidden;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.annotation.Masked;
import com.app_247.blog.id202603212000art.domain.shared.serialization.masking.vo.MaskType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Usuario {
private Long id;
private String nombre;
@Masked(type = MaskType.EMAIL)
private String email;
@Masked
private Integer edad;
@Masked(type = MaskType.CUSTOM, visibleStart = 2, visibleEnd = 2, maskChar = '*')
private String username;
@Hidden
private LocalDateTime fechaRegistro;
}
```
---
## Grupo 6 — Configuración de la aplicación
**application.properties** — Configuración de reglas de enmascaramiento
```properties
# ================================
# AOP LOGGING
# ================================
logging.aop.enabled=true
logging.aop.base-package=com.app_247.blog.id202603212000art
# UseCase
logging.aop.patterns[0].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.domain\\.usecase.*
logging.aop.patterns[0].class-regex=.*UseCase
logging.aop.patterns[0].method-regex=.*
logging.aop.patterns[0].log-level=INFO
logging.aop.patterns[0].warn-threshold-ms=300
# Adapter de persistencia
logging.aop.patterns[1].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.drivenadapters.*
logging.aop.patterns[1].class-regex=.*Adapter
logging.aop.patterns[1].method-regex=.*
logging.aop.patterns[1].log-level=DEBUG
logging.aop.patterns[1].warn-threshold-ms=100
# Controller
logging.aop.patterns[2].package-regex=com\\.app_247\\.blog\\.id202603212000art\\.infrastructure\\.entrypoints.*
logging.aop.patterns[2].class-regex=.*Controller
logging.aop.patterns[2].method-regex=.*
logging.aop.patterns[2].log-level=INFO
logging.aop.patterns[2].warn-threshold-ms=500
# ================================
# MASKING — Reglas por nombre de campo
# ================================
logging.masking.field-name-rules.email=EMAIL
logging.masking.field-name-rules.phone=PHONE
logging.masking.field-name-rules.password=FULL
logging.masking.field-name-rules.token=FULL
logging.masking.field-name-rules.identificacion=FULL
```
**Id202603212000artApplication.java** — Clase principal con habilitación de propiedades
```java
package com.app_247.blog.id202603212000art;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.app_247.blog.id202603212000art.applications.shared.log.config.LoggingAopProperties;
import com.app_247.blog.id202603212000art.applications.shared.serialization.config.MaskingProperties;
@SpringBootApplication
@EnableConfigurationProperties({
LoggingAopProperties.class,
MaskingProperties.class
})
public class Id202603212000artApplication {
public static void main(String[] args) {
SpringApplication.run(Id202603212000artApplication.class, args);
}
}
```
---
Con estos componentes tienes todo lo necesario para implementar el sistema completo de enmascaramiento de datos sensibles en logs. El código está organizado siguiendo los principios de arquitectura limpia: las anotaciones viven en el dominio sin dependencias de infraestructura, las estrategias son componentes aislados y extensibles, y la configuración de Jackson se mantiene separada del ObjectMapper principal que usa Spring MVC para las respuestas HTTP.