Consulta de resultados paginados
En el documento anterior conectamos la búsqueda de una sola película a través de la arquitectura hexagonal, desde el controlador hasta el repositorio JPA y de vuelta. Este documento agrega paginación al endpoint /films para que los clientes puedan pedir páginas de resultados en lugar de cargar todo de una vez.
Files Overview
- Java
- Kotlin
- Groovy
Cambia cada capa de la arquitectura:
- La especificación OpenAPI recibe
x-spring-paginated: trueen el endpoint/films. FilmUseCasesyFilmRepositoryganan un métodogetFilms(Pageable).- El repositorio JPA sobrescribe
findAllcon un@EntityGraph. - El mapper REST gana un mapeo de
PageaFilmListResponseAllOfData. - Los archivos
application.yamlincorporan los valores por defecto de paginación de Spring Data. - Se agrega una aserción compartida
hasPageFields()enMockMvcResultMatchers.
La especificación OpenAPI y x-spring-paginated
El endpoint /films ya existía en la especificación OpenAPI, pero antes de estos commits lanzaba RuntimeException("Not implemented"). La adición clave es x-spring-paginated: true:
paths:
/films:
get:
x-spring-paginated: true
tags:
- Films
operationId: getFilms
Esta extensión personalizada le dice al generador de OpenAPI que resuelva los parámetros de consulta page, size y sort en un objeto Pageable de Spring, en lugar de tres parámetros de método separados. Sin esto, la interfaz del controlador generado se vería como getFilms(Integer page, Integer size, List<String> sort). Con esto, el generador produce getFilms(Pageable pageable), y PageableHandlerMethodArgumentResolver de Spring Data parsea automáticamente la query string en una instancia de Pageable.
springdoc-openapi-starter-webmvc-ui
Los archivos de build también agregan la dependencia springdoc-openapi-starter-webmvc-ui:3.0.2. Esto sirve la especificación OpenAPI como una Swagger UI en vivo en /swagger-ui.html, lo que viene bien para exploración ad-hoc. No afecta la lógica de paginación en sí.
- Java
- Kotlin
- Groovy
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2")
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
Configurar los valores por defecto de paginación
Spring Data te permite configurar valores globales por defecto para paginación, así cada request que omite page o size recibe valores predecibles. Los tres archivos YAML (application.yaml, application-dev.yaml, application-test.yaml) agregan el mismo bloque:
spring:
data:
web:
pageable:
default-page-size: 10
max-page-size: 100
default-page-size: 10— cuando el cliente no incluyesize, devolver 10 resultados por página.max-page-size: 100— limitar cualquier pedido a 100 resultados por página, así un cliente no puede pedir las 1000 películas de una sola vez.
Agregar paginación a través de las capas
Interfaces de puerto del dominio
Tanto el puerto de entrada (FilmUseCases) como el de salida (FilmRepository) ganan un método getFilms(Pageable) que devuelve Page<Film>:
- Java
- Kotlin
- Groovy
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 FilmUseCases {
Film getFilm(Integer id);
Page<Film> getFilms(Pageable pageable);
}
package dev.pollito.spring_kotlin.sakila.film.domain.port.`in`
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface FilmUseCases {
fun getFilm(id: Int): Film
fun getFilms(pageable: Pageable): Page<Film>
}
package dev.pollito.spring_groovy.sakila.film.domain.port.in
import dev.pollito.spring_groovy.sakila.film.domain.model.Film
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface FilmUseCases {
Film getFilm(Integer id)
Page<Film> getFilms(Pageable pageable)
}
- Java
- Kotlin
- Groovy
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 FilmRepository {
Film getFilm(Integer id);
Page<Film> getFilms(Pageable pageable);
}
package dev.pollito.spring_kotlin.sakila.film.domain.port.out
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface FilmRepository {
fun getFilm(id: Int): Film
fun getFilms(pageable: Pageable): Page<Film>
}
package dev.pollito.spring_groovy.sakila.film.domain.port.out
import dev.pollito.spring_groovy.sakila.film.domain.model.Film
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface FilmRepository {
Film getFilm(Integer id)
Page<Film> getFilms(Pageable pageable)
}
La capa de dominio solo conoce Page<Film> y Pageable. No sabe nada de JPA ni de HTTP.
Implementación del caso de uso
FilmUseCasesImpl delega directamente al repositorio — mismo patrón que getFilm:
- Java
- Kotlin
- Groovy
Repositorio JPA con @EntityGraph
FilmJpaRepository ahora sobrescribe findAll(Pageable) con un @EntityGraph que trae con eager fetch las relaciones de language. Sin esto, acceder a film.languageByLanguageId dispararía una query N+1 por cada fila de la página.
- Java
- Kotlin
- Groovy
La anotación @EntityGraph le dice a Hibernate que busque esas relaciones en la misma query usando un JOIN, en lugar de emitir un SELECT separado por el language de cada película.
Mapear Page<Entity> a Page<Film>
MapStruct (Java y Kotlin) y ModelMapper (Groovy) manejan el mapeo de Page de forma distinta.
- Java
- Kotlin
- Groovy
En Java, un método default en la interfaz de MapStruct delega a source.map(this::map), que aplica el mapper de entidad individual existente a cada elemento y preserva los metadatos de paginación:
En Kotlin, la misma idea se expresa como una función de una sola expresión:
En Groovy, el auto-mapeo de ModelMapper choca con la metaclass de Groovy — auto-descubre language y language.metaClass como propiedades, lo que causa fallos en el mapeo. Los enfoques selectivos como addMapping con closures o PropertyMap fallan: el primero dispara conflictos con language.metaClass, y el segundo sigue auto-descubriendo paths de propiedades desde expresiones como source.releaseYear?.year, lo que crashea en tipos final como java.time.LocalDate. El único patrón confiable es setConverter con un closure que mapea explícitamente cada campo — verboso, pero correcto. El mapeo de Page usa el mismo enfoque manual con source.map { it -> map(it) }:
Implementación del repositorio
FilmRepositoryImpl conecta el repositorio JPA y el mapper:
- Java
- Kotlin
- Groovy
El método getFilms(Pageable) pasa el Pageable directo a repository.findAll(...), recibe un Page<FilmEntity>, y lo convierte a Page<Film> a través del mapper. Los metadatos de paginación (total de elementos, total de páginas, info de página actual) viajan solos porque Page.map() los preserva.
Mapear la respuesta REST
La especificación OpenAPI define FilmListResponse como un allOf que combina ResponseMetadata (instance, status, timestamp, trace) con una propiedad data. La propiedad data es a su vez un allOf del schema Page de Spring y un objeto con un array content de items Film.
El mapper del lado REST convierte Page<Film> (dominio) a FilmListResponseAllOfData (DTO generado):
- Java
- Kotlin
- Groovy
MapStruct mapea Page<Film> directamente a FilmListResponseAllOfData, que refleja la estructura Page de Spring (content, pageable, totalElements, totalPages):
En Kotlin, MapStruct necesita una anotación @Mapping para manejar el campo content porque Page.getContent() devuelve una lista inmutable que el generador no puede asignar directamente. La expression construye la lista a partir de source.getContent():
En Groovy, ModelMapper no maneja Page, así que el mapper construye un FilmListResponseAllOfData a mano — recolectando source.content, creando un GeneratedPageable, y pasando totalElements y totalPages directamente:
El método del controlador
La interfaz generada FilmsApi ahora declara getFilms(Pageable pageable) gracias a x-spring-paginated. El controlador construye la respuesta usando el mismo patrón que el endpoint de una sola película:
- Java
- Kotlin
- Groovy
El parámetro Pageable se resuelve automáticamente por Spring MVC a partir de los parámetros de consulta page, size y sort, acotado por los valores por defecto y max-page-size configurados en application.yaml.
Probar la paginación
Test de integración — @DataJpaTest
El test del repositorio JPA verifica que getFilms(PageRequest.of(0, 10)) devuelva una página con 10 películas cuyos IDs empiezan en 1:
- Java
- Kotlin
- Groovy
Test unitario — repositorio mockeado
El test del caso de uso verifica que getFilms delegue al repositorio y devuelva la página:
- Java
- Kotlin
- Groovy
Aserciones reutilizables de paginación
El test del controlador necesita verificar que la respuesta JSON contenga los campos de paginación (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages). En lugar de repetir esas aserciones jsonPath en cada test, se agrega un método compartido hasPageFields() al utilitario MockMvcResultMatchers de cada lenguaje:
- Java
- Kotlin
- Groovy
hasPageFields() aserta que $.data.content es un array y que $.data.pageable.pageNumber, $.data.pageable.pageSize, $.data.totalElements y $.data.totalPages son todos números. Los tests del controlador lo llaman junto a hasStandardApiResponseFields().
Test del controlador — MockMvc
El test del controlador antes estaba asertando 500 INTERNAL_SERVER_ERROR (porque getFilms lanzaba RuntimeException("Not implemented")). Ahora mockea useCases.getFilms(any(Pageable.class)) para devolver una página vacía y aserta que la respuesta incluye los campos estándar de la API más los campos de paginación (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages):
- Java
- Kotlin
- Groovy
El flujo completo
Con la paginación conectada en cada capa, un request a GET /api/films?page=0&size=5 fluye así:
Buildeá y ejecutá la aplicación con el perfil dev, y después golpeá el endpoint: