Saltar al contenido principal

Paginación

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, bajo el tag pagination.

Ahora mismo el endpoint GET /films devuelve todas las películas en una sola respuesta. Eso funciona bien con un dataset pequeño, pero en producción una tabla puede tener miles o millones de filas. Enviarlas todas de una sola vez desperdicia ancho de banda, aumenta los tiempos de respuesta, y pone carga innecesaria tanto en el servidor como en el cliente.

La paginación resuelve esto dividiendo los resultados en páginas de tamaño fijo. El cliente pide una página a la vez (?page=0&size=10) y recibe solo ese slice, más metadata como el número total de elementos y páginas. Esto mantiene las respuestas rápidas y predecibles independientemente de cuántos datos existan.

Spring Data provee una abstracción Pageable que maneja esto out of the box. Este documento te guía para agregar paginación al endpoint GET /films en los tres proyectos.

Archivos involucrados

Archivos a Crear/Modificar
File Tree
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ └── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ ├── in
│ │ │ │ └── rest
│ │ │ │ ├── FilmRestController.java
│ │ │ │ └── FilmRestMapper.java
│ │ │ └── out
│ │ │ └── jpa
│ │ │ └── FilmJpaMapper.java
│ │ └── domain
│ │ └── port
│ │ ├── in
│ │ │ ├── FindAllPortIn.java
│ │ │ └── FindAllPortInImpl.java
│ │ └── out
│ │ ├── FindAllPortOut.java
│ │ └── FindAllPortOutImpl.java
│ └── resources
│ ├── application.yaml
│ ├── application-dev.yaml
│ └── openapi.yaml
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── SanityCheckSpringBootTest.java
│ ├── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ └── in
│ │ │ └── rest
│ │ │ └── FilmRestControllerMockMvcTest.java
│ │ └── domain
│ │ └── port
│ │ ├── in
│ │ │ └── FindAllPortInImplTest.java
│ │ └── out
│ │ └── FindAllPortOutImplDataJpaTest.java
│ └── test
│ └── util
│ └── MockMvcResultMatchers.java
└── resources
└── application-test.yaml

Actualización del contrato OpenAPI

Actualizás los tres archivos openapi.yaml con los mismos cambios:

  • x-spring-paginated: true en la operación GET /films. Esto le indica al generador de OpenAPI que inyecte un parámetro Pageable en el método de interfaz generado en lugar de query params individuales.
  • Tres query parameters opcionales: page (índice base 0, default 0), size (items por página, default 10, máximo 100), y sort (una o más strings propiedad,asc|desc).
  • Dos nuevos componentes de schema: Page (con campos content, pageable, totalElements, y totalPages) y Pageable (con campos pageNumber y pageSize).
  • El campo data de FilmListResponse cambia de un array plano de películas a un objeto Page cuyo content está tipado como un array de Film.
resources/openapi.yaml
# ...
paths:
/films:
get:
x-spring-paginated: true
tags:
- Films
operationId: findAll
summary: List all films
parameters:
- name: page
in: query
description: Page number (0-based index)
schema:
type: integer
default: 0
minimum: 0
maximum: 2147483647
required: false
- name: size
in: query
description: Number of items per page
schema:
type: integer
default: 10
minimum: 1
maximum: 100
required: false
- name: sort
in: query
description:
Sort criteria format `property,asc|desc`. Multiple sort parameters allowed.
schema:
type: array
items:
type: string
example: "name,asc"
style: form
explode: true
required: false
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/FilmListResponse'
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# ...
components:
schemas:
# ...
Page:
type: object
description: Sublist of a list of objects. It allows to gain information about the position of it in the containing entire list
properties:
content:
default: []

items: {}
type: array
pageable:
$ref: "#/components/schemas/Pageable"
totalElements:
default: 0
description: Total number of items that meet the criteria
example: 10
type: integer
totalPages:
default: 0
description: Total pages of items that meet the criteria
example: 10
type: integer
required:
- content
- pageable
- totalElements
- totalPages
Pageable:
type: object
description: Pagination information
properties:
pageNumber:
description: Current page number (starts from 0)
example: 0
minimum: 0
maximum: 2147483647
type: integer
pageSize:
description: Number of items retrieved on this page
example: 10
minimum: 0
maximum: 2147483647
type: integer
required:
- pageNumber
- pageSize
FilmListResponse:
allOf:
- $ref: '#/components/schemas/ResponseMetadata'
- type: object
properties:
data:
allOf:
- $ref: "#/components/schemas/Page"
- type: object
properties:
content:
default: []
items:
$ref: "#/components/schemas/Film"
type: array
required:
- data

Configuración del generador OpenAPI

Cada archivo de build recibe dos adiciones:

  • La dependencia springdoc-openapi-starter-webmvc-ui, para que el parámetro Pageable generado sea resuelto y documentado correctamente por SpringDoc.
  • La opción useSpringDataPageable: "true" en la configuración de la tarea openApiGenerate. Esto le dice al generador que use org.springframework.data.domain.Pageable como tipo de parámetro para los endpoints paginados.
build.gradle
// ...
dependencies {
//...
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
}
// ...
openApiGenerate {
// ...
configOptions = [
// ...
useSpringDataPageable : "true",
]
}
// ...

Configuración de Spring Pageable

Seteás spring.data.web.pageable.default-page-size a 10 y spring.data.web.pageable.max-page-size a 100. Esto va en los tres archivos YAML de cada proyecto (application.yaml, application-dev.yaml, y application-test.yaml) para que los límites de paginación sean consistentes en todos los entornos.

resources/application-*.yaml
spring:
data:
web:
pageable:
default-page-size: 10
max-page-size: 100
# ...

Nuevas interfaces de puerto e implementaciones

Creás dos nuevos pares interfaz/implementación por proyecto, siguiendo la misma convención de naming de puertos hexagonales ya usada para FindById.

FindAllPortIn

El puerto de entrada (caso de uso). La interfaz declara findAll(Pageable): Page<Film>.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FindAllPortIn.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FindAllPortIn {
Page<Film> findAll(Pageable pageable);
}

FindAllPortInImpl

La implementación delega al puerto de salida.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FindAllPortInImpl.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FindAllPortOut;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FindAllPortInImpl implements FindAllPortIn {
private final FindAllPortOut findAllPortOut;

@Override
public Page<Film> findAll(Pageable pageable) {
return findAllPortOut.findAll(pageable);
}
}

FindAllPortOut

El puerto de salida (adaptador de persistencia). La interfaz declara findAll(Pageable): Page<Film>.

java/dev/pollito/spring_java/sakila/film/domain/port/out/FindAllPortOut.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FindAllPortOut {
Page<Film> findAll(Pageable pageable);
}

FindAllPortOutImpl

Llama a repository.findAll(pageable) y mapea el resultado a través de FilmJpaMapper.

java/dev/pollito/spring_java/sakila/film/domain/port/out/FindAllPortOutImpl.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaMapper;
import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaRepository;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FindAllPortOutImpl implements FindAllPortOut {
private final FilmJpaRepository repository;
private final FilmJpaMapper mapper;

@Override
public Page<Film> findAll(Pageable pageable) {
return mapper.convert(repository.findAll(pageable));
}
}

Extensiones de los mappers

Extendés ambos mappers en cada proyecto para manejar conversiones de tipo Page.

FilmJpaMapper

Recibe un nuevo método convert(Page<EntityFilm>): Page<DomainFilm> que mapea la página transformando cada elemento de entidad en un modelo de dominio usando el método convert de ítem individual ya existente.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmJpaMapper.java
// ...
import static java.util.Objects.requireNonNull;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
// ...
public interface FilmJpaMapper
extends Converter<dev.pollito.spring_java.generated.entity.Film, Film> {
// ...
default Page<Film> convert(@NonNull Page<dev.pollito.spring_java.generated.entity.Film> source) {
return source.map(it -> requireNonNull(this.convert(it)));
}
}

FilmRestMapper

Recibe un nuevo método convert(Page<DomainFilm>): FilmListResponseAllOfData que produce el DTO generado con content, pageable, totalElements, y totalPages populados.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
// ...
import dev.pollito.spring_java.generated.model.FilmListResponseAllOfData;
import org.springframework.data.domain.Page;
// ...
public interface FilmRestMapper
extends Converter<Film, dev.pollito.spring_java.generated.model.Film> {
// ...
FilmListResponseAllOfData convert(Page<Film> filmPageable);
}

En Java y Kotlin esto se hace mediante MapStruct. En Groovy, donde se usa ModelMapper en su lugar, implementás el mapeo manualmente como un método simple.

Advertencia sobre MapStruct en Kotlin

El FilmRestMapper de Kotlin requiere un método helper explícito filmListToFilmList y una anotación @Mapping con una expresión Java para el campo content. Sin esto, la implementación generada por MapStruct por defecto incluye un chequeo hasContent que asigna null cuando la página está vacía. Como el campo content del DTO generado por OpenAPI no es nullable, esto lleva a una excepción en runtime con páginas vacías. El mapeo explícito asegura que se devuelva una lista vacía en lugar de null.

Actualización del controller

Actualizás FilmRestController en cada proyecto para que findAll ahora acepte un parámetro Pageable (inyectado por el HandlerMethodArgumentResolver de Spring MVC) y devuelva un FilmListResponse correctamente populado en lugar de lanzar UnsupportedOperationException.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
// ...
import dev.pollito.spring_java.sakila.film.domain.port.in.FindAllPortIn;
import org.springframework.data.domain.Pageable;
// ...
public class FilmRestController implements FilmsApi {
private final FindAllPortIn findAllPortIn;
// ...
@Override
public ResponseEntity<FilmListResponse> findAll(Pageable pageable) {
return ok(
new FilmListResponse()
.data(mapper.convert(findAllPortIn.findAll(pageable)))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}
// ...
}

Tests

FindAllPortInImplTest

Test unitario que verifica que el puerto de entrada delega al puerto de salida y devuelve el resultado.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FindAllPortInImplTest.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import dev.pollito.spring_java.sakila.film.domain.port.out.FindAllPortOut;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

@ExtendWith(MockitoExtension.class)
class FindAllPortInImplTest {
@InjectMocks private FindAllPortInImpl findAllPortIn;
@Mock private FindAllPortOut findAllPortOut;

@Test
void findAllReturnsAPage() {
when(findAllPortOut.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
assertNotNull(findAllPortIn.findAll(PageRequest.of(0, 20)));
}
}

FindAllPortOutImplDataJpaTest

Un test de integración @DataJpaTest que carga el esquema y los datos seed, llama al puerto de salida con un PageRequest, y verifica que la página no esté vacía y tenga el número esperado de elementos.

java/dev/pollito/spring_java/sakila/film/domain/port/out/FindAllPortOutImplDataJpaTest.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;

import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaMapperImpl;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

@DataJpaTest
@ActiveProfiles("test")
@Import({FindAllPortOutImpl.class, FilmJpaMapperImpl.class})
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class FindAllPortOutImplDataJpaTest {

@SuppressWarnings("unused")
@Autowired
private FindAllPortOut findAllPortOut;

@Test
void findAll_shouldReturnPagedResults() {
var result = findAllPortOut.findAll(PageRequest.of(0, 10));

assertNotNull(result);
assertFalse(result.isEmpty());
assertEquals(10, result.getNumberOfElements());
}
}

FilmRestControllerMockMvcTest

El test findAll existente cambia de esperar 500 Internal Server Error a esperar 200 OK con un cuerpo de respuesta paginada válido. Agregás FindAllPortIn como mock bean.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestControllerMockMvcTest.java
// ...
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
// ...
class FilmRestControllerMockMvcTest {
// ...
@Test
void findAllReturnsOK() throws Exception {
when(findAllPortIn.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));

mockMvc
.perform(get(FILMS_PATH).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(FILMS_PATH, OK))
.andExpect(hasPageFields());
}
}

MockMvcResultMatchers

Un nuevo matcher hasPageFields() verifica que el cuerpo de la respuesta contenga data.content (array), data.pageable.pageNumber, data.pageable.pageSize, data.totalElements, y data.totalPages.

java/dev/pollito/spring_java/test/util/MockMvcResultMatchers.java
// ...
public final class MockMvcResultMatchers {
// ...
public static ResultMatcher hasPageFields() {
return result -> {
jsonPath("$.data.content").isArray().match(result);
jsonPath("$.data.pageable.pageNumber").isNumber().match(result);
jsonPath("$.data.pageable.pageSize").isNumber().match(result);
jsonPath("$.data.totalElements").isNumber().match(result);
jsonPath("$.data.totalPages").isNumber().match(result);
};
}
}

SanityCheckSpringBootTest

Actualizás el test de sanity check para incluir el endpoint GET /api/films.

java/dev/pollito/spring_java/SanityCheckSpringBootTest.java
// ...
class SanityCheckSpringBootTest {
// ...
static @NonNull Stream<TestCase> sanityCheckTestCases() {
return Stream.of(
// ...
new TestCase(
HttpMethod.GET,
"/api/films",
Collections.emptyList(),
Collections.emptyMap(),
Collections.emptyMap(),
null)
);
}
}

El flujo completo

Con todo conectado, esto es lo que pasa cuando un request paginado le pega a tu aplicación. El flujo sigue la misma arquitectura hexagonal que findById, pero ahora el repository devuelve una Page en lugar de una sola entidad, y el REST mapper convierte la metadata de la página junto con el contenido.

Vas a notar que org.springframework.data.domain.Page y Pageable aparecen en cada capa: el controller, los puertos de dominio, y el adaptador de persistencia. Estrictamente hablando, esto rompe la arquitectura hexagonal: la capa de dominio no debería depender de una interfaz de Spring Data. El enfoque puro sería definir tu propia abstracción similar a Page en el dominio y mapear hacia/desde ella en cada frontera.

En la práctica, redefinir lo que Page ya provee es reinventar la rueda sin ganancia real. La interfaz es estable, bien entendida, y todos los proyectos Spring que he visto la usan de la misma manera. Es un trade-off con el que me siento cómodo.

Scroll to zoom • Drag corner to resize

Buildeá y corré la aplicación con el perfil dev, después le pegás al endpoint:

Terminal
curl -s http://localhost:8080/api/films | jq

{
"instance": "/api/films",
"status": 200,
"timestamp": "2026-03-19T23:54:21.27581481Z",
"trace": "8eec221d5225be2bbf461937313a95a9",
"data": {
"content": [
{
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 1,
"language": "English",
"length": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
},
{
"description": "A Astounding Epistle of a Database Administrator And a Explorer who must Find a Car in Ancient China",
"id": 2,
"language": "English",
"length": 48,
"rating": "G",
"releaseYear": 2006,
"title": "ACE GOLDFINGER"
},
{
"description": "A Astounding Reflection of a Lumberjack And a Car who must Sink a Lumberjack in A Baloon Factory",
"id": 3,
"language": "English",
"length": 50,
"rating": "NC-17",
"releaseYear": 2006,
"title": "ADAPTATION HOLES"
},
{
"description": "A Fanciful Documentary of a Frisbee And a Lumberjack who must Chase a Monkey in A Shark Tank",
"id": 4,
"language": "English",
"length": 117,
"rating": "G",
"releaseYear": 2006,
"title": "AFFAIR PREJUDICE"
},
{
"description": "A Fast-Paced Documentary of a Pastry Chef And a Dentist who must Pursue a Forensic Psychologist in The Gulf of Mexico",
"id": 5,
"language": "English",
"length": 130,
"rating": "G",
"releaseYear": 2006,
"title": "AFRICAN EGG"
},
{
"description": "A Intrepid Panorama of a Robot And a Boy who must Escape a Sumo Wrestler in Ancient China",
"id": 6,
"language": "English",
"length": 169,
"rating": "PG",
"releaseYear": 2006,
"title": "AGENT TRUMAN"
},
{
"description": "A Touching Saga of a Hunter And a Butler who must Discover a Butler in A Jet Boat",
"id": 7,
"language": "English",
"length": 62,
"rating": "PG-13",
"releaseYear": 2006,
"title": "AIRPLANE SIERRA"
},
{
"description": "A Epic Tale of a Moose And a Girl who must Confront a Monkey in Ancient India",
"id": 8,
"language": "English",
"length": 54,
"rating": "R",
"releaseYear": 2006,
"title": "AIRPORT POLLOCK"
},
{
"description": "A Thoughtful Panorama of a Database Administrator And a Mad Scientist who must Outgun a Mad Scientist in A Jet Boat",
"id": 9,
"language": "English",
"length": 114,
"rating": "PG-13",
"releaseYear": 2006,
"title": "ALABAMA DEVIL"
},
{
"description": "A Action-Packed Tale of a Man And a Lumberjack who must Reach a Feminist in Ancient China",
"id": 10,
"language": "English",
"length": 63,
"rating": "NC-17",
"releaseYear": 2006,
"title": "ALADDIN CALENDAR"
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 10
},
"totalElements": 1000,
"totalPages": 100
}
}