CodeGym /Cursos /Spring REST & MVC /Paginación manual y PagedResponse

Paginación manual y PagedResponse

Spring REST & MVC
Nivel 22, Lección 3
Disponible

1. Paginación manual: servicio vs. controlador

Cuando implementas por primera vez el GET /tasks, el controlador pide a gritos ser el “protagonista”: él ve los parámetros de consulta, él devuelve la respuesta, entonces que sea él quien divida la lista en páginas. Pero si hacemos eso, el controlador se convertirá rápidamente en una cocina con diez cazuelas donde se cuece todo a la vez — la ordenación, la paginación, el mapeo, y “oye, ¿dónde está mi lógica de negocio?”.

En nuestro Task Tracker API, el controlador debe mantenerse delgado por una razón simple: es responsable de la parte web del contrato (recibir entrada, devolver salida), no de calcular “qué trozo de la lista devolver”. Ese trozo es lógica aplicada de procesamiento de colecciones, y su lugar está en la capa de servicio. Además, ya hemos establecido un flujo de error unificado: si algo va mal en el servicio (por ejemplo, un sort no válido), el servicio lanza una excepción clara, y @ControllerAdvice la convierte en una respuesta adecuada de Problem Details.

En términos arquitectónicos, esto se ve muy tranquilo: el controlador recibe TaskSearchCriteria, llama a taskService.findPage(criteria) y devuelve PagedResponse<TaskSummaryResponse>. Todo lo “inteligente” ocurre en el servicio, y esto nos ayuda a no dispersar algoritmos idénticos por diferentes endpoints.

Un pequeño fragmento de “control” del controlador para que sientas lo simple que debe ser:

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    @GetMapping
    public PagedResponse<TaskSummaryResponse> list(
            @Valid @ModelAttribute TaskSearchCriteria criteria) {
        // criteria se ensambla automáticamente a partir de los parámetros de consulta y pasa la validación
        // el controlador no divide ni ordena nada — solo delega en el servicio
        return taskService.findPage(criteria);
    }
}

Observa que, idealmente, el controlador no sabe cómo funcionan from y to, cómo se calcula totalPages, ni siquiera cómo se implementa el stable sorting. Simplemente delega — como un adulto que sabe decir “eso no es mi trabajo” sin sentirse culpable.

2. Pipeline del endpoint list

La paginación manual tiene la ventaja de ser honesta: literalmente escribes “primero haz A, luego B, luego C”. La mala noticia: si te equivocas en el orden, todo se rompe igual de honestamente. Por eso es importante fijar el pipeline — la secuencia de pasos que transforma una “colección cruda de tareas” en una “página de tareas con metadatos”.

Para no convertirlo en una filosofía abstracta, es más cómodo ver el algoritmo como una cadena de montaje. En nuestro servicio, de forma simplificada, se ve así:

flowchart TD
    %% Esquema del pipeline: primero preparación de parámetros, luego ordenación, luego segmentación
    A[TaskSearchCriteria] --> B["Normalizar page/size"]
    B --> C["Analizar sort + verificar whitelist"]
    C --> D[Obtener todas las tareas del repositorio]
    D --> E[Ordenar]
    E --> F["Segmentar página: from/to"]
    F --> G[Mapear a TaskSummaryResponse]
    G --> H[Ensamblar PagedResponse]

Un punto psicológico importante para el principiante: la ordenación debe ocurrir antes que la paginación. Si primero “cortas” 20 elementos y luego ordenas esos 20, estarás ordenando no “la lista de tareas”, sino “un fragmento aleatorio de la lista de tareas”. El cliente cambiará page y obtendrá solapamientos impredecibles de elementos entre páginas. Este es un caso en el que el error parece “como que funciona, pero no me gusta”, y esos errores son especialmente desagradables.

Otro detalle: la normalización (null -> valor por defecto) es un pequeño paso independiente que conviene hacer con cuidado una sola vez. No consideramos null como un error; null significa “el cliente no especificó el parámetro”. Pero los valores que violan el rango deben ser filtrados por la capa de validación. Por eso, en el servicio no solemos hacer “cien comprobaciones por si acaso”, sino que simplemente aplicamos valores por defecto.

3. Segmentación de la página: from/to y subList

Cuando implementas la paginación por primera vez, el cerebro suele intentar complicarlo: “¿y si size es el último índice?”, “¿y si page empieza en 1?”, “¿y si to es inclusivo?”. La buena noticia: no necesitamos inventar matemáticas. Tenemos un acuerdo simple: page es el número de página (base cero), size es la cantidad de elementos. Por lo tanto, el primer elemento de la página es page * size.

En el código del servicio, es conveniente tenerlo en un método pequeño separado slice(...) que haga exactamente una cosa: tomar la lista ordenada y devolver el fragmento deseado. Y es muy importante: si from ya está fuera de la lista, eso no es una excepción. Simplemente es un items vacío (no un “error” ni un “404”). Se ve más o menos así:

import java.util.List;

private <T> List<T> slice(List<T> source, int page, int size) {
    // from — primer índice de la página (inclusive)
    int from = page * size;

    // Si la página está "lejos", devolvemos lista vacía — es un escenario normal de un endpoint list
    if (from >= source.size()) {
        return List.of();
    }

    // to — límite superior (NO inclusivo), por lo tanto usamos Math.min para la cola de la lista
    int to = Math.min(from + size, source.size());
    return source.subList(from, to);
}

Aquí hay varias cosas que es mejor explicar directamente porque luego ahorran horas de depuración. En primer lugar, subList(from, to) funciona con un semiintervalo: from inclusive, to exclusive. Es decir, si quieres 20 elementos, to debe ser from + 20, no from + 19. En segundo lugar, to no puede superar el tamaño de la lista, de lo contrario obtendrás un IndexOutOfBoundsException y te quedarás mirando el stack trace como si fuera una carta del pasado con amenazas.

Y el tercer punto: el método slice(...) solo funciona si page y size ya están normalizados y son válidos. Por eso, en nuestro pipeline, la normalización y la validación van antes que él, de lo contrario corres el riesgo de obtener, por ejemplo, size=0 y división por cero en otros lugares.

4. Metadatos: totalElements y totalPages

En una respuesta paginada, no solo son importantes los items, sino también los metadatos. Si solo devuelves los elementos actuales, el cliente no sabe cuántas tareas existen en total ni cuántas páginas tiene por delante. Esto convierte la API en un juego de “adivina si hay una página siguiente”, y tratamos de no jugar a esos juegos, aunque tengamos muchas ganas.

La regla principal aquí: totalElements es el tamaño de toda la selección (después de ordenar, pero antes de segmentar la página). Es decir, calcular totalElements = items.size() es casi siempre un error, porque en la primera página items.size() puede ser 20, en la segunda también 20, pero el total de tareas es 57. El cliente necesita el número 57, de lo contrario no podrá construir correctamente la paginación en la interfaz de usuario.

totalPages también debe calcularse a partir de toda la selección y de size. En nuestro acuerdo, es conveniente fijar la regla: si hay 0 elementos, entonces 0 páginas. De este modo, una colección vacía se ve “honestamente vacía”, sin una extraña “una página vacía”. La implementación puede ser matemáticamente simple:

private int totalPages(long totalElements, int size) {
    // Selección vacía => 0 páginas (no "1 página vacía")
    if (totalElements == 0) {
        return 0;
    }

    // Redondeo hacia arriba para enteros: (total + size - 1) / size
    return (int) ((totalElements + size - 1) / size);
}

Aquí se usa la fórmula clásica de “redondeo hacia arriba” para números enteros. Para los principiantes suena a magia, pero en la práctica es sencillo: sumamos size - 1 para que cualquier cola incompleta también dé una página adicional. Por ejemplo, 57 elementos con size=20 dan (57 + 19) / 20 = 3.

Y un pequeño detalle de ingeniería: totalElements es mejor almacenarlo como long, no como int. Incluso si ahora tenemos un proyecto en memoria y de aprendizaje, el hábito de “la cantidad de elementos puede ser grande” es útil y casi gratuito.

5. Ensamblaje de PagedResponse<TaskSummaryResponse>

Ahora tenemos todos los “ladrillos”: normalización de page/size, análisis de la ordenación, ordenación de la lista, segmentación de la página, cálculo de metadatos. Solo queda ensamblarlo todo en un único método del servicio de manera que sea legible, y no parezca un “rollo de 200 líneas escrito a las 3 de la madrugada”.

En este paso no necesitamos nuevas entidades. Pagination.of(...) ya convierte null en valores por defecto, parseSort(...) transforma la cadena de consulta en un SortSpec, y buildComparator(...) construye un orden determinista. El servicio simplemente conecta estas partes en un único escenario.

import java.util.List;

public PagedResponse<TaskSummaryResponse> findPage(TaskSearchCriteria criteria) {
    // Pagination.of(...) ya resuelve el problema de los valores por defecto: null -> valores contractuales
    Pagination pagination = Pagination.of(criteria.page(), criteria.size());

    // Analizamos y validamos sort según las mismas reglas que ya establecimos para el contrato público
    SortSpec sort = parseSort(criteria.sort());

    // La ordenación va ANTES de slice, de lo contrario las páginas "saltarán"
    List<Task> sorted = taskRepository.findAll().stream()
        .sorted(buildComparator(sort))
        .toList();

    // El mapeo se hace DESPUÉS de slice, para no hacer trabajo innecesario
    List<TaskSummaryResponse> items = slice(sorted, pagination.page(), pagination.size()).stream()
        .map(taskMapper::toSummaryResponse)
        .toList();

    long totalElements = sorted.size();

    return new PagedResponse<>(
        items,
        pagination.page(),
        pagination.size(),
        totalElements,
        totalPages(totalElements, pagination.size()),
        sort.toRaw()
    );
}

Aquí es conveniente notar dos cosas. En primer lugar, Pagination.of(...) ya se ha encargado de todo el lío con null -> valor por defecto, por lo que el servicio trabaja con números normales. En segundo lugar, SortSpec no se recrea en el servicio: ya conoce el campo, la dirección y puede devolver el sort aplicado de vuelta a una cadena a través de toRaw().

Gracias a esto, findPage(...) comienza a leerse como un escenario, no como un conjunto de trucos. Y eso es exactamente lo que necesitamos en un proyecto de aprendizaje: que puedas abrir el código dentro de un mes y entender qué hace, sin tener que hacer una danza chamánica alrededor del depurador.

6. Ejemplo de respuesta JSON de PagedResponse

Cuando creas un PagedResponse, es útil ver con tus propios ojos qué es exactamente lo que recibe el cliente. Esto elimina muchas preguntas del tipo “¿y por qué añadimos totalPages?”. Supongamos que el cliente hizo una solicitud sin parámetros, por lo que se aplicaron los valores por defecto: page=0, size=20, sort=updatedAt,desc.

La respuesta (simplificada) podría verse así:

// Ejemplo simplificado: items acortados para compacidad
{
  "items": [
    {
      "id": "c2e2c437-0f6b-4c08-9e5b-8db4bfc2f2df",
      "title": "Fix pagination bug",
      "status": "IN_PROGRESS",
      "priority": "HIGH",
      "updatedAt": "2026-03-21T10:15:30Z"
    }
  ],
  "page": 0,
  "size": 20,
  "totalElements": 57,
  "totalPages": 3,
  "sort": "updatedAt,desc"
}

Aquí no importa la cantidad de elementos en el ejemplo (solo tenemos uno por compacidad), sino que la respuesta se explica por sí misma. El cliente ve qué página ha recibido, de qué tamaño, cuántos elementos existen en total y según qué ordenación los está viendo. En consecuencia, la interfaz de usuario del cliente puede dibujar tranquilamente la paginación, los botones “siguiente/anterior”, e incluso el texto “mostrando 1–20 de 57”, sin tener que adivinar nada por parte del servidor.

Y ese es precisamente el estado de la API al que aspiramos: el servidor no obliga al cliente a adivinar, el servidor comunica claramente el contexto de la selección. Si la API pudiera hablar, diría: “sí, soy adulto, sé lo que vas a hacer con esto”.

7. Errores típicos de la paginación manual

Con la paginación manual hay una buena noticia: no hay tantos errores. La mala noticia: casi todos parecen una “minucia”, pero rompen el contrato de manera muy desagradable. Por lo tanto, es mejor verbalizarlos una vez que tener que atraparlos tres veces en los registros y fingir que era intencionado.

Error n.º 1: segmentar la página antes de ordenar.
Parece lógico si piensas “necesito una página, así que primero tomo 20 elementos y luego los ordeno”. Pero para el cliente esto se convierte en un caos: los elementos saltarán entre páginas, un mismo objeto puede aparecer inesperadamente tanto en la primera como en la segunda página con el mismo sort. El orden correcto para una lista es casi siempre: primero ordenamos toda la selección, luego hacemos el slice.

Error n.º 2: calcular totalElements a partir del tamaño de items.
En la primera página, items.size() puede ser 20, y parece que “ese es el total”. Pero en cuanto aparezca la segunda página, resultará que el total “salta” y el cliente no sabe cuántos datos hay en total. totalElements es información sobre toda la selección, no sobre el trozo actual. Si quieres comprobarlo, pregúntate: “¿Puedo saber por los metadatos si hay una página siguiente?” Si no, los metadatos están rotos.

Error n.º 3: off-by-one en los índices from/to.
La historia más clásica: alguien decide que to es inclusivo y escribe to = from + size - 1. Luego lo pasa a subList(from, to) y obtiene una página de 19 elementos en lugar de 20. O, por el contrario, se sale con un error en el último elemento. subList(from, to) en Java funciona con el esquema [from, to). Si recuerdas solo una cosa de esta lección, que sea esta.

Error n.º 4: lanzar una excepción cuando la página está “muy lejos del final de la lista”.
A veces el desarrollador ve from >= total y piensa: “esto es claramente un error del cliente”. Pero luego resulta que el cliente simplemente está navegando por las páginas en la interfaz de usuario, y en el momento en que los datos del servidor cambian (en nuestro proyecto de aprendizaje, aunque sea por datos de semilla o acciones de prueba), la interfaz puede solicitar la “página 10”, pero hay menos elementos. En el contexto de un endpoint list, es más correcto devolver un items vacío ante una solicitud válida, que fallar. Nosotros tratamos este caso específicamente en slice(...).

Error n.º 5: dispersar el ensamblaje de PagedResponse entre diferentes capas.
Un trozo calcula totalPages en el controlador, otro trozo segmenta la lista en el repositorio, un tercer trozo ensambla sort como cadena en el servicio. Luego cambias el valor por defecto de size y empiezas a buscar tres lugares donde está hardcodeado. Es mucho más tranquilo mantener el algoritmo del endpoint list en un solo lugar, generalmente en el servicio, y dejar el controlador como “entrada-salida”.

1
Tarea
Spring REST & MVC, nivel 22, lección 3
Bloqueada
Paginación manual para la lista de artículos
Paginación manual para la lista de artículos
1
Tarea
Spring REST & MVC, nivel 22, lección 3
Bloqueada
`from/to` y `PagedResponse` para la lista de lecciones
`from/to` y `PagedResponse` para la lista de lecciones
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION