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
(suplentesASortear → suplentes_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.
[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.