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.
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
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.
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.
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.
Recibe un nuevo método convert(Page<DomainFilm>): FilmListResponseAllOfData que produce el DTO generado con content, pageable, totalElements, y totalPages populados.
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.
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.
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.
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.
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.
// ... trait MockMvcResultMatchersTrait { // ... ResultMatcher hasPageFields(){ { result -> jsonPath('$.data.content').isArray().match(result as MvcResult) jsonPath('$.data.pageable.pageNumber').isNumber().match(result as MvcResult) jsonPath('$.data.pageable.pageSize').isNumber().match(result as MvcResult) jsonPath('$.data.totalElements').isNumber().match(result as MvcResult) jsonPath('$.data.totalPages').isNumber().match(result as MvcResult) } } }
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 } }