The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag pagination.
Right now the GET /films endpoint returns every film in a single response. That works fine with a small dataset, but in production a table can hold thousands or millions of rows. Sending all of them at once wastes bandwidth, increases response times, and puts unnecessary load on both the server and the client.
Pagination solves this by splitting results into fixed-size pages. The client requests one page at a time (?page=0&size=10) and gets back only that slice, plus metadata like the total number of elements and pages. This keeps responses fast and predictable regardless of how much data exists.
Spring Data provides a Pageable abstraction that handles this out of the box. This document walks you through adding pagination to the GET /films endpoint across all three projects.
You update all three openapi.yaml files with the same changes:
x-spring-paginated: true on the GET /films operation. This signals the OpenAPI generator to inject a Pageable parameter into the generated interface method instead of individual query params.
Three optional query parameters: page (0-based index, default 0), size (items per page, default 10, max 100), and sort (one or more property,asc|desc strings).
Two new schema components: Page (with content, pageable, totalElements, and totalPages fields) and Pageable (with pageNumber and pageSize fields).
The FilmListResponse data field changes from a plain array of films to a Page object whose content is typed as an array of 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
The springdoc-openapi-starter-webmvc-ui dependency, so the generated Pageable parameter is resolved and documented correctly by SpringDoc.
The useSpringDataPageable: "true" option in the openApiGenerate task configuration. This tells the generator to use org.springframework.data.domain.Pageable as the parameter type for paginated endpoints.
You set spring.data.web.pageable.default-page-size to 10 and spring.data.web.pageable.max-page-size to 100. This goes into all three YAML files in each project (application.yaml, application-dev.yaml, and application-test.yaml) so pagination limits stay consistent across every environment.
Gets a new convert(Page<EntityFilm>): Page<DomainFilm> method that maps the page by transforming each entity element into a domain model using the existing single-item convert method.
Gets a new convert(Page<DomainFilm>): FilmListResponseAllOfData method that produces the generated DTO with content, pageable, totalElements, and totalPages populated.
In Java and Kotlin this is done via MapStruct. In Groovy, where ModelMapper is used instead, you implement the mapping manually as a plain method.
Kotlin MapStruct Caveat
The Kotlin FilmRestMapper requires an explicit filmListToFilmList helper method and a @Mapping annotation with a Java expression for the content field. Without this, the default MapStruct-generated implementation includes a hasContent check that assigns null when the page is empty. Since the OpenAPI-generated DTO's content field is non-nullable, this leads to a runtime exception on empty pages. The explicit mapping ensures an empty list is returned instead of null.
You update FilmRestController in each project so that findAll now accepts a Pageable parameter (injected by Spring MVC's HandlerMethodArgumentResolver) and returns a properly populated FilmListResponse instead of throwing UnsupportedOperationException.
A @DataJpaTest integration test that loads the schema and seed data, calls the outbound port with a PageRequest, and asserts the page isn't empty and has the expected number of elements.
The existing findAll test changes from expecting 500 Internal Server Error to expecting 200 OK with a valid paged response body. You add FindAllPortIn as a mock bean.
A new hasPageFields() matcher asserts that the response body contains data.content (array), data.pageable.pageNumber, data.pageable.pageSize, data.totalElements, and 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) } } }
With everything wired up, here's what happens when a paginated request hits your application. The flow follows the same hexagonal architecture as findById, but now the repository returns a Page instead of a single entity, and the REST mapper converts the page metadata alongside the content.
You'll notice that org.springframework.data.domain.Page and Pageable appear in every layer: the controller, the domain ports, and the persistence adapter. Strictly speaking, this violates hexagonal architecture: the domain layer shouldn't depend on a Spring Data interface. The pure approach would be to define your own Page-like abstraction in the domain and map to/from it at each boundary.
In practice, redefining what Page already provides is reinventing the wheel for no real gain. The interface is stable, well-understood, and every Spring project I've come across uses it the same way. It's a pragmatic trade-off I'm comfortable making.
Scroll to zoom • Drag corner to resize
Build and run the application with the dev profile, then hit the 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 } }