Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado planificar el incremento y sus dependencias. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Tus filtros ya seleccionan resultados, pero el listado puede crecer demasiado. Una página devuelve una parte de la consulta; la ordenación estable permite recorrerla sin repeticiones ni saltos causados por empates. Hoy combinarás esos parámetros con los filtros existentes.
El colapso del findAll() sin límites
Durante las primeras semanas de desarrollo, todas las tablas tienen 10 o 20 filas. En ese escenario, hacer tareaRepository.findAll() parece inofensivo: responde en 3 milisegundos y todo funciona.
El desastre ocurre cuando la aplicación entra en producción:
- La tabla
tareasalcanza 200.000 registros. - Un usuario entra al panel y el controlador ejecuta
findAll(). - Hibernate crea 200.000 objetos Java en la memoria Heap.
- El recolector de basura (Garbage Collector) se satura intentando liberar memoria, congelando la JVM.
- Jackson genera un JSON de 65 Megabytes que satura el ancho de banda y bloquea el navegador del cliente.
La ley de la colección acotada
Ninguna API de producción debe devolver una colección sin paginar.
Todo endpoint que devuelva múltiples registros debe exigir un límite de tamaño por página y declarar el número total de elementos existentes.
Anatomía de una respuesta paginada profesional
Un cliente que consume una API paginada no solo necesita los datos: necesita metadatos de navegación.
Spring Boot proporciona el objeto contenedor Page<T>, que se serializa en un JSON estructurado con esta información:
{
"content": [
{ "id": 1, "nombre": "Portal Web", "activo": true },
{ "id": 2, "nombre": "App Móvil", "activo": true }
],
"page": {
"size": 2,
"number": 0,
"totalElements": 15,
"totalPages": 8
},
"first": true,
"last": false
}
| Metadato | Significado para el cliente |
|---|---|
content |
La lista de elementos correspondiente a la página actual. |
number |
El índice de la página actual (en Spring empieza en 0). |
size |
Cantidad máxima de registros devueltos por página. |
totalElements |
Total absoluto de registros que cumplen los filtros en toda la base de datos. |
totalPages |
Número total de páginas disponibles (ceil(totalElements / size)). |
first / last |
Booleanos que indican si estamos en la primera o en la última página (para deshabilitar botones de anterior/siguiente en la UI). |
Paginación en PostgreSQL: LIMIT y OFFSET
Cuando Spring Data JPA procesa un objeto Pageable, traduce automáticamente la petición a dos consultas SQL nativas en PostgreSQL:
-- 1. Consulta de datos acotada a la página solicitada (page=1, size=10)
SELECT p.id, p.nombre, p.descripcion, p.activo, p.creado_en
FROM proyectos p
ORDER BY p.creado_en DESC
LIMIT 10 OFFSET 10;
-- 2. Consulta de conteo para calcular el total de páginas
SELECT COUNT(p.id) FROM proyectos p;
De esta forma, la memoria de la máquina virtual solo almacena 10 objetos, con independencia de que la tabla contenga millones de filas.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre el listado filtrado, su DTO de respuesta y la consulta de repositorio. Conserva la petición sin paginación para comparar el cambio de contrato.
- Crea más registros que el tamaño de página con el que vas a probar e incluye valores repetidos en el campo de ordenación.
- Anota la numeración inicial de página, el tamaño máximo y los campos por los que permitirás ordenar. Deben coincidir con lo que documenta tu API.
Paso 2 · Implementar paginación y ordenación
Modifica en orden repositorio, servicio y controlador. El servicio transforma los elementos con Page.map(...); conserva el resto de metadatos de página. Sustituye el GET de listado existente y mantén los otros métodos de ProyectoController. En el campo de ordenación utiliza un atributo real de tu entidad: si se llama fechaCreacion, no copies creadoEn. Para una primera comprobación estable puedes ordenar por id. Actualiza el cliente y la colección para leer los resultados dentro de content.
En ProyectoRepository, JpaRepository ya hereda soporte para Pageable. Añadimos además un método derivado para filtrar por estado con paginación:
public interface ProyectoRepository extends JpaRepository<Proyecto, Long> {
// Heredado: Page<Proyecto> findAll(Pageable pageable);
// Consulta filtrada por estado con paginación integrada
Page<Proyecto> findByActivo(boolean activo, Pageable pageable);
}
La interfaz Page<T> de Spring incluye un método funcional .map() que transforma los elementos internos preservando intactos todos los metadatos de paginación:
@Service
@Transactional(readOnly = true)
public class ProyectoService {
private final ProyectoRepository proyectoRepository;
private final ProyectoMapper proyectoMapper;
public ProyectoService(ProyectoRepository proyectoRepository, ProyectoMapper proyectoMapper) {
this.proyectoRepository = proyectoRepository;
this.proyectoMapper = proyectoMapper;
}
public Page<ProyectoResponse> listarProyectos(Boolean activo, Pageable pageable) {
Page<Proyecto> paginaEntidades;
if (activo != null) {
paginaEntidades = proyectoRepository.findByActivo(activo, pageable);
} else {
paginaEntidades = proyectoRepository.findAll(pageable);
}
// El método .map() convierte cada Proyecto a ProyectoResponse manteniendo la estructura de Page
return paginaEntidades.map(proyectoMapper::toResponse);
}
}
En el controlador, utilizamos @PageableDefault para establecer valores por defecto seguros en caso de que el cliente no envíe parámetros de paginación:
@RestController
@RequestMapping("/proyectos")
public class ProyectoController {
private final ProyectoService proyectoService;
public ProyectoController(ProyectoService proyectoService) {
this.proyectoService = proyectoService;
}
@GetMapping
public ResponseEntity<Page<ProyectoResponse>> listar(
@RequestParam(required = false) Boolean activo,
@PageableDefault(page = 0, size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable
) {
Page<ProyectoResponse> pagina = proyectoService.listarProyectos(activo, pageable);
return ResponseEntity.ok(pagina);
}
}
Paso 3 · Navegar por páginas y ordenar en Bruno
Ejecuta estas peticiones en Bruno o Postman y analiza los resultados:
- Petición por defecto:
GET http://localhost:8080/proyectos- Devuelve un máximo de 10 proyectos ordenados por fecha de creación descendente (
page=0, size=10).
- Devuelve un máximo de 10 proyectos ordenados por fecha de creación descendente (
- Cambiar tamaño de página:
GET http://localhost:8080/proyectos?size=2- Devuelve solo 2 proyectos y calcula el total de páginas correspondiente (
totalPages = totalElements / 2).
- Devuelve solo 2 proyectos y calcula el total de páginas correspondiente (
- Navegar a la segunda página:
GET http://localhost:8080/proyectos?page=1&size=2- Devuelve los elementos del 3 al 4. Observa que
"number": 1y"first": false.
- Devuelve los elementos del 3 al 4. Observa que
- Ordenar por nombre ascendente:
GET http://localhost:8080/proyectos?sort=nombre,asc- Los proyectos aparecen en estricto orden alfabético.
- Comprobar la consola SQL:
- Verifica que Hibernate emite la cláusula
LIMIT ? OFFSET ?en PostgreSQL junto con elSELECT count(...).
- Verifica que Hibernate emite la cláusula
Paso 4 · Combinar filtros multicriterio con paginación en Tareas
Sustituye la firma anterior de buscarConFiltros por esta en TareaRepository. Importa Page y Pageable de org.springframework.data.domain; conserva Query y Param de Spring Data JPA. Elimina el método anterior con los mismos parámetros para no mantener dos versiones incoherentes.
@Query(value = """
SELECT t FROM Tarea t JOIN FETCH t.proyecto p
WHERE (:proyectoId IS NULL OR p.id = :proyectoId)
AND (:prioridad IS NULL OR t.prioridad = :prioridad)
AND (:completada IS NULL OR t.completada = :completada)
AND (:q IS NULL OR LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%')))
""", countQuery = """
SELECT COUNT(t) FROM Tarea t JOIN t.proyecto p
WHERE (:proyectoId IS NULL OR p.id = :proyectoId)
AND (:prioridad IS NULL OR t.prioridad = :prioridad)
AND (:completada IS NULL OR t.completada = :completada)
AND (:q IS NULL OR LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%')))
""")
Page<Tarea> buscarConFiltros(@Param("proyectoId") Long proyectoId,
@Param("prioridad") String prioridad, @Param("completada") Boolean completada,
@Param("q") String q, Pageable pageable);
En el servicio añade Pageable como último parámetro, pásalo al repositorio y sustituye .stream().map(...).toList() por .map(tareaMapper::toResumenResponse). Cambia su retorno a Page
En la consulta paginada de tareas añade una countQuery equivalente, con los mismos filtros pero sin FETCH ni ORDER BY. El método principal devuelve Page<Tarea> y recibe Pageable. Comprueba una consulta filtrada con más resultados que el tamaño de página: además del contenido, verifica que totalElements coincide con el número de registros que cumplen esos filtros. Una consulta de recuento distinta produciría páginas incorrectas aunque el primer listado pareciera válido.
- Modifica
TareaRepository.buscarConFiltrospara que reciba como último argumentoPageable pageabley devuelvaPage<Tarea>. - Actualiza
TareaServicepara que devuelvaPage<TareaResumenResponse>usando.map(). - Configura en
TareaController:@PageableDefault(page = 0, size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable - Prueba en Bruno la combinación de filtros y paginación:
GET /tareas?prioridad=alta&completada=false&page=0&size=5&sort=titulo,asc - Verifica que los metadatos
totalElementsreflejan el total de tareas filtradas, no el total absoluto de la tabla. Es el error más habitual: si pides?completada=false&size=5ytotalElementste devuelve el número de filas de toda la tabla, el cliente calculará mal el número de páginas y mostrará páginas vacías al final. - Mira el SQL. Con
show-sql=trueactivado, comprueba si la petición paginada genera las sentencias: unSELECT ... LIMIT ? OFFSET ?y unSELECT count(*). Esa segunda es el precio de poder decirle al cliente cuántas páginas hay. Si no la necesitas, devolverSliceen vez dePagela evita. - Comprueba los límites, que es donde se rompe la paginación:
?page=999sobre una tabla de 20 filas: debe devolver200con una lista vacía y los metadatos correctos, nunca un404ni un error.?size=10000: decide si lo permites. Si no pones techo, un cliente puede pedirte la tabla entera en una sola petición y tirarte la memoria, que es justo lo que la paginación venía a evitar. Configuraspring.data.web.pageable.max-page-size.?sort=campoQueNoExiste: comprueba qué pasa y decide si un500es aceptable como respuesta a un parámetro mal escrito.
- Documenta en tu cuaderno el contrato de paginación que has fijado: nombre de los parámetros, tamaño por defecto, tamaño máximo y orden por defecto. En la sesión 31 esto se convierte en documentación OpenAPI, y en la UD12 en parte del contrato que defiendes.
- Cómo saber que lo has terminado
- Filtros y paginación funcionan combinados;
totalElementscuenta lo filtrado; una página fuera de rango devuelve200con lista vacía; hay un tamaño máximo de página configurado; y has identificado el SELECT paginado y cuándo se ejecuta el recuento.
Paso 5 · Comprobar y registrar el resultado del proyecto
- Recorre al menos dos páginas y comprueba sus contenidos, totales y orden. Añade un criterio de desempate cuando el campo elegido tenga valores iguales.
- Prueba filtro más paginación, página sin resultados y tamaño inválido. Verifica la política documentada para cada caso y adapta el cliente si cambia el formato.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · El problema de la paginación profunda (Deep Paging)
Cuando una base de datos relacional ejecuta OFFSET 1000000 LIMIT 10, PostgreSQL debe leer un millón diez filas de disco y descartar el primer millón en memoria antes de devolver las diez solicitadas. A esto se le conoce como el problema de la paginación profunda (Deep Paging).
Analiza las consecuencias técnicas y diseña una alternativa:
- ¿Por qué la latencia de una consulta con
OFFSETcrece de forma lineal a medida que avanzan las páginas? - Investiga qué es la paginación por cursor o por clave de búsqueda (Keyset Pagination o Cursor-based Pagination), que sustituye
OFFSETpor una cláusula del tipo:WHERE t.id > :ultimoIdVisto ORDER BY t.id ASC LIMIT 10. - ¿Por qué las aplicaciones con scroll infinito (como Twitter, Instagram o feeds de noticias) utilizan siempre paginación por cursor en lugar de
Pageablebasado en desplazamiento (offset)?
Formato de entrega
Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.
GET /proyectos implementada con @PageableDefault y metadatos completos en la respuesta JSON.Tarea funcionando con ordenación dinámica.Ver respuestas
1 · Porque si la tabla crece a decenas o cientos de miles de registros, la aplicación sufrirá consumo desmedido de memoria RAM, saturará el recolector de basura (GC) y enviará payloads gigantes por red que congelarán al cliente.
2 · Spring Data utiliza numeración basada en 0 (0-based indexing): la primera página es la página 0.
3 · Utiliza la cláusula LIMIT para definir la cantidad máxima de filas a retornar y OFFSET para saltar el número de filas correspondiente a las páginas previas (OFFSET = page * size).
4 · Mantiene intactos todos los metadatos de paginación (totalElements, totalPages, number, size, first, last) mientras transforma limpiamente cada elemento individual de entidad a DTO.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
La API informa del contenido y los metadatos previstos y no devuelve todo el catálogo por defecto.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.