Saltar a contenido

ADR-021 — DTOs de respuesta en los endpoints del gestor (cierre de RAPI-DTO-001 / ENTITY_LEAK)

  • Status: accepted (operador aprobó la escalación, 2026-08-22)
  • Context: el gate estático (ADR-020) detectó 15 hallazgos ENTITY_LEAK (FindSecBugs/SECURITY, floor a HIGH por el ledger) en 7 controllers del gestor (ActaGestorController, CampaniaGestorController, ChanceGestorController, ClienteGestorController, GanadorGestorController, PremioGestorController, SorteoGestorController): devolvían entidades JPA (Campania, Cliente, Sorteo, Chance, PremioTipo, AsignacionPremio, ResultadoSorteo, ContactoGanador) directo en la respuesta HTTP. El mismo hallazgo ya estaba documentado por los boot-ui advisors del 2026-08-21 como RAPI-DTO-001 y quedó escalado en ISSUES-DEFERRED.md en vez de arreglarse dentro de ADR-020, porque (a) requería un patrón arquitectónico nuevo (qué campos exponer, cómo mapear, quién lo mantiene — CLAUDE.md regla 4), (b) cambiaba la forma de 15 respuestas que consume hoy un frontend real (gestor/) y (c) era deuda ya reconocida y depriorizada (CLAUDE.md regla 6, "legacy baseline"). Este ADR resuelve esa escalación con el operador ya habiendo aprobado el cambio dedicado. La API pública (participacion/padron, consumida por landing/) ya devolvía DTOs desde su diseño original — no tenía este hallazgo y no se toca en este ADR.
  • Alternatives:
  • @JsonIgnore/@JsonIgnoreProperties puntual sobre la entidad — descartada: no resuelve el acoplamiento de fondo (el contrato HTTP sigue atado 1:1 al esquema JPA — cualquier cambio de columna rompe la API), y SpotBugs sigue marcando ENTITY_LEAK porque el tipo devuelto sigue siendo @Entity.
  • Proyecciones de Spring Data (interfaces) — descartada para este caso: la mayoría de los endpoints afectados ya tienen la entidad cargada en memoria (viene de un save/findById previo dentro del mismo método, no de una query nueva); envolverla en una interfaz de proyección hubiese exigido re-consultar o forzar el shape en tiempo de query sin necesidad real.
  • Un DTO genérico (Map<String, Object> armado a mano por endpoint) — descartada: es lo que ya hacían ActaGestorController#ver, ClienteGestorController #detalle y ChanceGestorController#voucher, y es exactamente el patrón que ISSUES-DEFERRED.md (RAPI-ERR-003 y esta misma entrada) señala como frágil: sin tipo, sin autocompletado, sin garantía de que el mapeo cubra todos los campos.
  • record de respuesta por entidad, con mapeo explícito campo-a-campo (elegida) — un record por entidad en api/gestor/dto (p. ej. CampaniaResponse, SorteoResponse, ChanceResponse, ...), con un factory estático de(entidad) que copia cada getter. Compuestos (ActaDetalleResponse, ClienteDetalleResponse, VoucherTrazabilidadResponse) reemplazan los Map<String,Object> ad hoc que ya armaban a mano ActaGestorController/ClienteGestorController/ ChanceGestorController.
  • Decision:
  • Un record de respuesta por entidad tocada, con los MISMOS nombres de componente que los getters del dominio (p. ej. SorteoResponse.suplentesASortear, no suplentesToSortear ni ningún otro nombre "mejorado"). Esto es deliberado: Jackson usa SNAKE_CASE global (application.yml) y colapsa mayúsculas consecutivas (suplentesASortearsuplentes_asortear, no suplentes_a_sortear — ya documentado como gotcha JD-G2 en gestor/src/types/api.ts); mantener el nombre idéntico garantiza el mismo JSON byte a byte sin tener que razonar de nuevo sobre la transformación de Jackson para cada campo.
  • Mapeo vía un factory estático De.de(entidad) en el propio record (mismo patrón que ya usaba SorteoResumenResponse/PremioUnidadResponse, preexistentes), no un mapper externo (MapStruct u otra librería) — cero dependencias nuevas para un mapeo 1:1 sin lógica.
  • Ningún campo se agrega ni se quita respecto de lo que la entidad ya serializaba: se verificó campo a campo contra gestor/src/types/api.ts (que ya reflejaba el JSON real de las entidades) antes de escribir cada DTO, precisamente para no romper nada consumido por gestor/ ni por los specs de e2e/.
  • Los tres endpoints que embebían entidades dentro de un Map<String,Object> (acta + resultados, cliente + chances, chance + rechazos) pasan a un DTO compuesto (ActaDetalleResponse, ClienteDetalleResponse, VoucherTrazabilidadResponse) con las mismas dos claves que ya esperaba el frontend (ActaDetalle, ClienteDetalle, VoucherTrazabilidad en api.ts) — no estaban en los 15 hallazgos de SpotBugs (el tipo de retorno genérico Map<String,Object> no deja que el analisis estatico vea la entidad adentro), pero es el mismo problema de fondo y se corrigen en el mismo cambio por consistencia (misma entidad, mismo controller, cero costo extra de diseño).
  • ClaveFirmaGestorController no se toca: ya devolvía DTOs (ClaveFirmaResponse) desde su diseño original (AC-18) y no tenía hallazgos.
  • Consequences:
  • El contrato HTTP del gestor queda desacoplado del esquema de persistencia: agregar o renombrar una columna JPA ya no cambia el JSON que ve el frontend sin que alguien lo decida explícitamente en el DTO.
  • Los 15 hallazgos ENTITY_LEAK (FindSecBugs/SECURITY) desaparecen del gate estático; ninguno se suprimió con @SuppressFBWarnings (eran hallazgos reales, no falsos positivos).
  • Las entidades de dominio (Campania, Sorteo, Chance, etc.) no tienen asociaciones JPA (@OneToMany/@ManyToOne) — todas modelan relaciones como IDs manuales — así que no había riesgo real de lazy-loading al serializar; el hallazgo era puramente de acoplamiento de contrato, no de N+1 ni de LazyInitializationException.
  • Los tres records compuestos (ActaDetalleResponse, ClienteDetalleResponse, VoucherTrazabilidadResponse) hacen una copia defensiva (List.copyOf) de sus listas en el constructor compacto — sin esto, SpotBugs marca EI_EXPOSE_REP/EI_EXPOSE_REP2 sobre el propio record nuevo (un record no genera copia defensiva sola); se corrigió en el mismo cambio para no dejar hallazgos MEDIUM nuevos.
  • Nueva convención en api/gestor/dto: los DTOs de respuesta de esta ronda son record (no las clases con getters explícitos que ya existían — SorteoResumenResponse, PremioUnidadResponse, etc.). Se deja así (no se migran los DTOs viejos): ambos estilos serializan igual bajo Jackson/SNAKE_CASE, y migrar los preexistentes es un cambio cosmético sin valor de negocio, fuera del alcance de esta escalación.
  • PanelService no tenía entidades embebidas en su respuesta (ya devolvía Map<String, Object> con primitivos/DTOs propios) — no requirió cambios en este ADR.

Implementation Plan

  • backend/src/main/java/.../api/gestor/dto/: 13 records nuevos (CampaniaResponse, SorteoResponse, ChanceResponse, ClienteResponse, VoucherRechazadoResponse, ResultadoSorteoResponse, ContactoGanadorResponse, PremioTipoResponse, AsignacionPremioResponse, ActaResponse, ActaDetalleResponse, ClienteDetalleResponse, VoucherTrazabilidadResponse).
  • 7 controllers actualizados para devolver el DTO en vez de la entidad: ActaGestorController (ver, resultados, exportJson), CampaniaGestorController (vigente, obtener, crear, editar, subirBases, generarSorteos), ChanceGestorController (chances, voucher), ClienteGestorController (buscar, detalle, editar), GanadorGestorController (contacto, entrega, pasarSuplente), PremioGestorController (cargarTipo), SorteoGestorController (editar, asignarPremio).
  • ISSUES-DEFERRED.md: RAPI-DTO-001 y la escalación ENTITY_LEAK marcadas RESUELTO.

Verification

  • [x] mvn -q test verde (suite completa, mismos asserts de forma de respuesta — GestorApiTest cubre AC-18/19/23/26/27/28/29/30/31).
  • [x] mvn -q verify -Pqa: 0 hallazgos ENTITY_LEAK (antes: 15); 0 hallazgos nuevos EI_EXPOSE_REP/EI_EXPOSE_REP2 en los DTOs compuestos (corregido con List.copyOf en el constructor compacto).
  • [x] gestor/: npm test (49 tests) y npm run build (tsc --noEmit && vite build) en verde sin tocar gestor/src/types/api.ts ni ningún componente.
  • [x] E2E completo (e2e/, todos los proyectos): gestor (51 passed, 3 skipped por diseño), landing (95 passed, 6 skipped — WebKit sin CDP throttling), transversal, rate-limit, modo-prueba, cámara — todos en verde.
  • [x] qa_ledger.py resolve-escalation --repo backend — escalación cerrada.