← APIs REST avanzadas

Sesión 30 · Semana 15

Paginación y ordenación

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 tareas alcanza 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

  1. 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.
  2. 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.
  3. 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:

  1. 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).
  2. 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).
  3. 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": 1 y "first": false.
  4. Ordenar por nombre ascendente: GET http://localhost:8080/proyectos?sort=nombre,asc
    • Los proyectos aparecen en estricto orden alfabético.
  5. Comprobar la consola SQL:
    • Verifica que Hibernate emite la cláusula LIMIT ? OFFSET ? en PostgreSQL junto con el SELECT count(...).

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. El controlador también recibe y pasa Pageable y devuelve esa Page. El compilador te señalará los consumidores pendientes de actualizar; corrígelos antes de probar la colección.

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.

  1. Modifica TareaRepository.buscarConFiltros para que reciba como último argumento Pageable pageable y devuelva Page<Tarea>.
  2. Actualiza TareaService para que devuelva Page<TareaResumenResponse> usando .map().
  3. Configura en TareaController: @PageableDefault(page = 0, size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable
  4. Prueba en Bruno la combinación de filtros y paginación: GET /tareas?prioridad=alta&completada=false&page=0&size=5&sort=titulo,asc
  5. Verifica que los metadatos totalElements reflejan el total de tareas filtradas, no el total absoluto de la tabla. Es el error más habitual: si pides ?completada=false&size=5 y totalElements te 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.
  6. Mira el SQL. Con show-sql=true activado, comprueba si la petición paginada genera las sentencias: un SELECT ... LIMIT ? OFFSET ? y un SELECT count(*). Esa segunda es el precio de poder decirle al cliente cuántas páginas hay. Si no la necesitas, devolver Slice en vez de Page la evita.
  7. Comprueba los límites, que es donde se rompe la paginación:
    • ?page=999 sobre una tabla de 20 filas: debe devolver 200 con una lista vacía y los metadatos correctos, nunca un 404 ni 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. Configura spring.data.web.pageable.max-page-size.
    • ?sort=campoQueNoExiste: comprueba qué pasa y decide si un 500 es aceptable como respuesta a un parámetro mal escrito.
  8. 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; totalElements cuenta lo filtrado; una página fuera de rango devuelve 200 con 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

  1. 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.
  2. 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:

  1. ¿Por qué la latencia de una consulta con OFFSET crece de forma lineal a medida que avanzan las páginas?
  2. Investiga qué es la paginación por cursor o por clave de búsqueda (Keyset Pagination o Cursor-based Pagination), que sustituye OFFSET por una cláusula del tipo: WHERE t.id > :ultimoIdVisto ORDER BY t.id ASC LIMIT 10.
  3. ¿Por qué las aplicaciones con scroll infinito (como Twitter, Instagram o feeds de noticias) utilizan siempre paginación por cursor en lugar de Pageable basado 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.

Objetivo mínimoPaginación en GET /proyectos implementada con @PageableDefault y metadatos completos en la respuesta JSON.
Si lo tienesPaginación combinada con los filtros multicriterio de Tarea funcionando con ordenación dinámica.
RetoEstudio del problema de rendimiento de Deep Paging completado y propuesta técnica de paginación por cursor documentada.
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.