ESTADO: Propuesto (Sprint-03)
FECHA: 2026-04-24
AUTOR: Vanessa Luna
Durante el Sprint-2, el equipo Frontend identificó dos deudas técnicas críticas que impactan la escalabilidad y la experiencia de usuario en Nebula:
/get expone campos de auditoría (version, clientId) innecesarios para la visualización.ACTIVO) en lugar de etiquetas localizadas según el idioma del usuario.@ManyToOne, @OneToMany y otras anotaciones de relaciones JPA en las entidades.@Filter de Hibernate activado por interceptor (clientId, companyId).ON tcc.id = cc.id_tipo) no propaga el @Filter a las tablas unidas. Esto puede devolver nombres de otro tenant si los IDs coinciden, rompiendo el aislamiento de datos.Implementar un patrón de Consulta de Solo Lectura Inmutable basado en los siguientes pilares:
Se utilizarán Java Records para las respuestas de consulta.
{Entidad}Response.public record CentroCostosResponse(
Long id,
String codigo,
String nombre,
String estado, // nombre legible del enum
Long tipoCentroCostoId,
String tipoCentroCostoNombre, // nombre resuelto ← no el ID
Long dependenciaId,
String dependenciaNombre // nombre resuelto ← no el ID
) {}
Para garantizar el rendimiento y evitar el N+1:
@Component responsable de obtener los Mapas de Apoyo (Map<Long, String>) mediante consultas en lote (findAllById). Esto garantiza:
findAllById se ejecuta sobre su propia entidad JPA, por lo que el @Filter se aplica tabla por tabla de forma independiente.{Entidad}LightLoader@Component @RequiredArgsConstructor
public class CentroCostosLightLoader {
public Map<Long, String> getMapTipos(List<CentroCostosEntity> entities) {
Set<Long> ids = entities.stream()
.map(CentroCostosEntity::getIdTipoCentroCostos)
.filter(Objects::nonNull).collect(Collectors.toSet());
return tipoCentroCostosRepository.findAllById(ids).stream()
.collect(toMap(TipoCentroCostosEntity::getId,
TipoCentroCostosEntity::getNombre));
}
public Map<Long, String> getMapDependencias(List<CentroCostosEntity> entities) {
Set<Long> ids = entities.stream()
.map(CentroCostosEntity::getIdDependencia)
.filter(Objects::nonNull).collect(Collectors.toSet());
return dependenciaRepository.findAllById(ids).stream()
.collect(toMap(DependenciaEntity::getId,
DependenciaEntity::getNombre));
}
}
Map<String, Map<Long, String>>.@Component que implementa la interface genérica NebulaEnrichedMapper<E, R>.{Entidad}Mapper@Component
@RequiredArgsConstructor
public class CentroCostosMapper implements NebulaEnrichedMapper<CentroCostosEntity, CentroCostosResponse> {
/** Helper para mensajes internacionalizados. */
private final AccountingMessages messages;
@Override
public CentroCostosResponse toResponse(CentroCostosEntity entity, Map<String, Map<Long, String>> context) {
if (entity == null) return null;
// Extraemos los mapas del contexto
var nombresTipos = context.getOrDefault("TIPOS", Collections.emptyMap());
var nombresDeps = context.getOrDefault("DEPENDENCIAS", Collections.emptyMap());
// Traducimos el Enum usando el componente de mensajes
String estadoTraducido = entity.getEstadoCentro() != null
? messages.get(entity.getEstadoCentro().getMessageKey())
: null;
return new CentroCostosResponse(
entity.getId(),
entity.getCodigo(),
entity.getNombre(),
entity.getEstadoCentro() != null ? estadoTraducido : messages.get(AccountingDomainConstants.DESCONOCIDO),
entity.getIdTipoCentroCostos(),
nombresTipos.getOrDefault(entity.getIdTipoCentroCostos(), messages.get(AccountingDomainConstants.DESCONOCIDO)),
entity.getIdDependencia(),
nombresDeps.getOrDefault(entity.getIdDependencia(), messages.get(AccountingDomainConstants.DESCONOCIDO))
);
}
}
Los Enums no deben exponerse como strings técnicos.
AccountingMessages.messages.get(enum.getMessageKey()).GET /get-record/{id}: Retorna un PageResponse inmutable con el Record aplanado, traducido y limpio.POST /page-record: Retorna un PageResponse inmutable.| Capa | Acción Requerida |
|---|---|
| Mapper | Crear {Entidad}Mapper con NebulaEnrichedMapper e inyectar AccountingMessages. |
| LightLoader | Implementar métodos de carga masiva que retornen Map<Long, String>. |
| Component | Orquestar el flujo: Consultar -> Cargar Contexto -> Mapear -> Retornar PageResponse. |
| Controller | Exponer /get-record y /page-record. Marcar /page y /get como @Deprecated. |
getPageRecord (referencia)@Transactional(readOnly = true)
public PageResponse<CentroCostosResponse> getPageRecord(SimappeRequestQuery requestQuery,
HttpServletRequest request) {
// 1. Obtener entidades
PageDto<CentroCostosEntity> page = this.getPageDtoQuery(requestQuery, centroCostosRepository,
CentroCostosEntity.class);
List<CentroCostosEntity> entities = page.getContent();
// 2. Construir el contexto usando el LightLoader (Eficiencia O(1) en lookup)
Map<String, Map<Long, String>> context = new HashMap<>();
context.put("TIPOS", centroCostosLightLoader.getMapTipos(entities));
context.put("DEPENDENCIAS", centroCostosLightLoader.getMapDependencias(entities));
// 3. Mapeamos a Records inmutables cruzando con el contexto
List<CentroCostosResponse> content = entities.stream().map(e -> mapper.toResponse(e, context)).toList();
// 4. Construimos el PageResponse inmutable (usando el constructor del record)
return new PageResponse<>(page.getNumber(), page.getSize(), page.getNumberOfElements(), page.getTotalElements(),
page.getTotalPages(), content);
}
Total de queries por llamada: 3 fijas — sin importar el tamaño de la página.
messages.properties.Nota: Este estándar utiliza obligatoriamente el record com.catcsoft.simappe.commons.api.v1.core.query.PageResponse para todas las respuestas paginadas de solo lectura.