Query paginated results
In the previous document we wired a single-film lookup through the hexagonal architecture, from controller to JPA repository and back. This document adds pagination to the /films endpoint so clients can request pages of results instead of loading everything at once.
Files Overview
- Java
- Kotlin
- Groovy
Every layer in the architecture changes:
- The OpenAPI spec gets
x-spring-paginated: trueon the/filmsendpoint. FilmUseCasesandFilmRepositorygrow agetFilms(Pageable)method.- The JPA repository overrides
findAllwith an@EntityGraph. - The REST mapper gains a
Page→FilmListResponseAllOfDatamapping. - The
application.yamlfiles pick up Spring Data's pagination defaults. - A shared
hasPageFields()test assertion is added toMockMvcResultMatchers.
The OpenAPI spec and x-spring-paginated
The /films endpoint already existed in the OpenAPI spec, but before these commits it threw RuntimeException("Not implemented"). The key addition is x-spring-paginated: true:
paths:
/films:
get:
x-spring-paginated: true
tags:
- Films
operationId: getFilms
This custom extension tells the OpenAPI generator to resolve the page, size, and sort query parameters into a Spring Pageable object rather than three separate method parameters. Without it, the generated controller interface would look like getFilms(Integer page, Integer size, List<String> sort). With it, the generator produces getFilms(Pageable pageable), and Spring Data's PageableHandlerMethodArgumentResolver automatically parses the query string into a Pageable instance.
springdoc-openapi-starter-webmvc-ui
The build files also add the springdoc-openapi-starter-webmvc-ui:3.0.2 dependency. This serves the OpenAPI spec as a live Swagger UI at /swagger-ui.html, which is useful for ad-hoc exploration. It does not affect the pagination logic itself.
- 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'
Configure pagination defaults
Spring Data lets you set global defaults for pagination so every request that omits page or size gets predictable values. The three YAML files (application.yaml, application-dev.yaml, application-test.yaml) all add the same block:
spring:
data:
web:
pageable:
default-page-size: 10
max-page-size: 100
default-page-size: 10— when the client doesn't includesize, return 10 results per page.max-page-size: 100— cap any request at 100 results per page, so a client can't ask for all 1,000 films in one shot.
Add pagination through the layers
Domain port interfaces
Both the inbound port (FilmUseCases) and the outbound port (FilmRepository) gain a getFilms(Pageable) method that returns 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)
}
The domain layer only knows about Page<Film> and Pageable. It has zero knowledge of JPA or HTTP.
Use case implementation
FilmUseCasesImpl delegates straight through to the repository — same pattern as getFilm:
- Java
- Kotlin
- Groovy
JPA repository with @EntityGraph
FilmJpaRepository now overrides findAll(Pageable) with an @EntityGraph that eagerly fetches the language relationships. Without it, accessing film.languageByLanguageId would trigger an N+1 query on every row in the page.
- Java
- Kotlin
- Groovy
The @EntityGraph annotation tells Hibernate to fetch those relationships in the same query using a JOIN, instead of issuing a separate SELECT for each film's language.
Map Page<Entity> to Page<Film>
MapStruct (Java and Kotlin) and ModelMapper (Groovy) each handle the Page mapping differently.
- Java
- Kotlin
- Groovy
In Java, a default method on the MapStruct interface delegates to source.map(this::map), which applies the existing single-entity mapper to every element and preserves pagination metadata:
In Kotlin, the same idea is expressed as a single-expression function:
In Groovy, ModelMapper's auto-mapping conflicts with Groovy's metaclass — it auto-discovers language and language.metaClass as properties, causing mapping failures. Selective approaches like addMapping with closures or PropertyMap both fail: the former triggers language.metaClass conflicts, and the latter still auto-discovers property paths from expressions like source.releaseYear?.year, which crashes on final types such as java.time.LocalDate. The only reliable pattern is setConverter with a closure that explicitly maps every field — verbose, but correct. The Page mapping uses the same manual approach with source.map { it -> map(it) }:
Repository implementation
FilmRepositoryImpl wires the JPA repository and mapper together:
- Java
- Kotlin
- Groovy
The getFilms(Pageable) method passes the Pageable straight to repository.findAll(...), gets back a Page<FilmEntity>, and converts it to Page<Film> via the mapper. The pagination metadata (total elements, total pages, current page info) travels along automatically because Page.map() preserves it.
Map the REST response
The OpenAPI spec defines FilmListResponse as an allOf combining ResponseMetadata (instance, status, timestamp, trace) with a data property. The data property itself is an allOf of the Spring Page schema and an object with a content array of Film items.
The REST-side mapper converts Page<Film> (domain) to FilmListResponseAllOfData (generated DTO):
- Java
- Kotlin
- Groovy
MapStruct maps Page<Film> directly to FilmListResponseAllOfData, which mirrors the Spring Page structure (content, pageable, totalElements, totalPages):
In Kotlin, MapStruct needs a @Mapping annotation to handle the content field because Page.getContent() returns an immutable list that the generator can't assign directly. The expression builds the list from source.getContent():
In Groovy, ModelMapper doesn't handle Page, so the mapper constructs a FilmListResponseAllOfData by hand — collecting source.content, creating a GeneratedPageable, and passing totalElements and totalPages directly:
The controller method
The generated FilmsApi interface now declares getFilms(Pageable pageable) thanks to x-spring-paginated. The controller builds the response using the same pattern as the single-film endpoint:
- Java
- Kotlin
- Groovy
The Pageable parameter is resolved automatically by Spring MVC from the page, size, and sort query parameters, bounded by the defaults and max-page-size configured in application.yaml.
Test the pagination
Integration test — @DataJpaTest
The JPA repository test verifies that getFilms(PageRequest.of(0, 10)) returns a page with 10 films whose IDs start at 1:
- Java
- Kotlin
- Groovy
Unit test — mocked repository
The use case test checks that getFilms delegates to the repository and returns the page:
- Java
- Kotlin
- Groovy
Reusable pagination assertions
The controller test needs to verify that the JSON response contains the pagination fields (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages). Instead of repeating those jsonPath assertions in every test, a shared hasPageFields() method is added to each language's MockMvcResultMatchers utility:
- Java
- Kotlin
- Groovy
hasPageFields() asserts that $.data.content is an array and that $.data.pageable.pageNumber, $.data.pageable.pageSize, $.data.totalElements, and $.data.totalPages are all numbers. The controller tests call it alongside hasStandardApiResponseFields().
Controller test — MockMvc
The controller test was previously asserting 500 INTERNAL_SERVER_ERROR (because getFilms threw RuntimeException("Not implemented")). Now it mocks useCases.getFilms(any(Pageable.class)) to return an empty page and asserts the response includes the standard API fields plus pagination fields (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages):
- Java
- Kotlin
- Groovy
The complete flow
With pagination wired through every layer, a request to GET /api/films?page=0&size=5 flows like this:
Build and run the application with the dev profile, then hit the endpoint: