Skip to main content

Pagination

Complete Code
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.

Files Overview

Files to Create/Modify
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

OpenAPI Contract Update

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

OpenAPI Generator Configuration

Each build file gets two additions:

  • 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.
build.gradle
// ...
dependencies {
//...
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
}
// ...
openApiGenerate {
// ...
configOptions = [
// ...
useSpringDataPageable : "true",
]
}
// ...

Spring Pageable Configuration

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.

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

New Port Interfaces and Implementations

You create two new interface/implementation pairs per project, following the same hexagonal port naming convention already used for FindById.

FindAllPortIn

The inbound port (use case). The interface declares 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

The implementation delegates to the outbound port.

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

The outbound port (persistence adapter). The interface declares 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

Calls repository.findAll(pageable) and maps the result through 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));
}
}

Mapper Extensions

You extend both mappers in each project to handle Page-typed conversions.

FilmJpaMapper

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.

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

Gets a new convert(Page<DomainFilm>): FilmListResponseAllOfData method that produces the generated DTO with content, pageable, totalElements, and totalPages populated.

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);
}

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.

Controller Update

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.

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

Unit test that verifies the inbound port delegates to the outbound port and returns the result.

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

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.

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

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.

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

A new hasPageFields() matcher asserts that the response body contains data.content (array), data.pageable.pageNumber, data.pageable.pageSize, data.totalElements, and 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

You update the sanity check test to include the GET /api/films endpoint.

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)
);
}
}

The Complete Flow

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
}
}