1. Introduction
GraphQL is a powerful query language that lets clients request exactly the data they need. One common challenge when working with APIs is handling large datasets efficiently. Pagination helps by breaking data into smaller chunks, improving performance and user experience.
In this tutorial, we’ll explore how to implement pagination in a Spring Boot application using GraphQL. We’ll cover both page-based and cursor-based pagination.
2. Setting up the Project
To get started, we’ll include the necessary dependencies for GraphQL and JPA in the pom.xml file:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Spring Boot GraphQL provides tools to define a GraphQL schema and bind it to Java code. JPA helps us interact with the database in an object-oriented manner.
3. Creating the Book Entity and Repository
Next, let’s define a simple entity class to represent the data we want to paginate. We’ll use a Book entity as our primary domain object:
@Entity
@Table(name="books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
// Getters and setters
}
This Book entity directly corresponds to a database table structure. Each book has a unique ID, a title, and an author. The @Id annotation marks the primary key, while @GeneratedValue(strategy = GenerationType.IDENTITY) lets JPA auto-generate it.
To facilitate data access with built-in pagination support, we create a repository interface:
public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
}
In this example, we extend PagingAndSortingRepository instead of the more commonly used CrudRepository because it provides built-in support for pagination and sorting. With this setup, we can use methods like findAll(Pageable pageable) to retrieve paginated data without manually writing any SQL or JPQL.
GraphQL uses a schema to define the shape of the data and the queries the client can send. In our case, we want to define a query that lets us fetch books with pagination support. We’ll also need to include some pagination metadata like total pages and the current page number.
Here’s what our schema looks like for page-based pagination:
type Book {
id: ID!
title: String
author: String
}
type BookPage {
content: [Book]
totalPages: Int
totalElements: Int
number: Int
size: Int
}
type Query {
books(page: Int, size: Int): BookPage
}
The Book type defines the structure of individual book items in our GraphQL schema. The BookPage type serves as a wrapper that contains both the list of books for the current page and important pagination metadata. This metadata includes the total pages, total elements, current page number, and page size.
Furthermore, the books query is designed to accept two arguments, page and size. The page parameter specifies which page of results we want to retrieve, while size determines how many books should appear on each page.
5. Implementing the Page-Based GraphQL Query Resolver
Next, we’ll implement the query resolver that connects our GraphQL schema. This resolver class will process incoming requests for the books query and return the properly paginated results:
@Component
public class BookQueryResolver {
private final BookRepository bookRepository;
public BookQueryResolver(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@QueryMapping
public BookPage books(@Argument int page, @Argument int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Book> bookPage = bookRepository.findAll(pageable);
return new BookPage(bookPage);
}
}
This resolver is a Spring component that processes incoming GraphQL queries. The books() method is annotated with @QueryMapping, which directly corresponds to the books query defined in our schema. It accepts two arguments, page and size, which are automatically extracted from the GraphQL request.
To implement pagination, we first create a Pageable instance using PageRequest.of(page, size). This instance is part of Spring Data’s core functionality, specifying which page of results we want and how many items each page should contain. We then pass this Pageable to the repository’s findAll() method.
The repository processes this request and returns a Page<Book> object, which contains the list of books for the current page as well as pagination metadata like total pages, total elements, current page number, and page size.
6. Creating the BookPage DTO
To ensure our GraphQL responses match the schema definition, we need to create a Data Transfer Object (DTO) called BookPage. This DTO acts as the crucial link between our Spring Data pagination results and the GraphQL type:
public class BookPage {
private List<Book> content;
private int totalPages;
private long totalElements;
private int number;
private int size;
public BookPage(Page<Book> page) {
this.content = page.getContent();
this.totalPages = page.getTotalPages();
this.totalElements = page.getTotalElements();
this.number = page.getNumber();
this.size = page.getSize();
}
// Getters
}
This BookPage DTO is designed to accept a Page<Book> object in its constructor, where it extracts and organizes all the required data for our GraphQL response. By returning this DTO from the resolver, we ensure that the response matches our GraphQL schema exactly.
While page-based pagination serves well for typical applications, it faces limitations with extremely large datasets or infinite scrolling interfaces. Cursor-based pagination offers a more efficient alternative in these scenarios by using a different approach to track position.
Instead of relying on numeric page offsets, cursor pagination uses stable reference points – typically:
- Encoded record IDs
- Precise timestamps
- Other unique, sequential identifiers
For our book example, we’ll implement this using book IDs as cursors. The client simply provides the last seen book ID, and the server returns all subsequent records after that point.
Let’s update our GraphQL schema to support cursor-based pagination. We’ll add a new query and supporting types:
type Book {
id: ID!
title: String
author: String
}
type BookEdge {
node: Book
cursor: String
}
type PageInfo {
hasNextPage: Boolean
endCursor: String
}
type BookConnection {
edges: [BookEdge]
pageInfo: PageInfo
}
type Query {
booksByCursor(cursor: ID, limit: Int!): BookConnection
}
The BookEdge type structure allows us to maintain both the book data and its position in the sequence.
The BookConnection type encapsulates a list of edges along with a pageInfo object. The pageInfo provides useful metadata, such as whether more pages are available and the cursor needed to retrieve the next set of results.
7.2. Implementing the Cursor-Based GraphQL Query Resolver
Let’s now implement the resolver method that will handle our booksByCursor query. This resolver will process cursor-based pagination requests and return properly structured connection objects:
@QueryMapping
public BookConnection booksByCursor(@Argument Optional<Long> cursor, @Argument int limit) {
List<Book> books;
if (cursor.isPresent()) {
books = bookRepository.findByIdGreaterThanOrderByIdAsc(cursor.get(), PageRequest.of(0, limit));
} else {
books = bookRepository.findAllByOrderByIdAsc(PageRequest.of(0, limit));
}
List<BookEdge> edges = books.stream()
.map(book -> new BookEdge(book, book.getId().toString()))
.collect(Collectors.toList());
String endCursor = books.isEmpty() ? null : books.get(books.size() - 1).getId().toString();
boolean hasNextPage = !books.isEmpty() && bookRepository.existsByIdGreaterThan(books.get(books.size() - 1).getId());
PageInfo pageInfo = new PageInfo(hasNextPage, endCursor);
return new BookConnection(edges, pageInfo);
}
In this method, we first examine if the client provided a cursor. When present, it queries for books with IDs greater than the decoded cursor value, maintaining ascending ID order. For initial requests without a cursor, it defaults to fetching the first set of records from the beginning of the collection, preserving the same ordering.
After retrieving the book records, the method transforms each one into a BookEdge object and combines the book data with its cursor. Next, we determine the endCursor by extracting the ID from the last book in the current result set.
To determine if additional pages exist, we perform a check for any books with IDs greater than the last record in our current results. The query existsByIdGreaterThan() avoids loading unnecessary data while providing the crucial boolean result we need.
7.3. Implementing the Supporting DTOs and Repository
To complete, we need to implement the supporting DTOs and enhance our repository with cursor-specific methods:
public class BookEdge {
private Book node;
private String cursor;
public BookEdge(Book node, String cursor) {
this.node = node;
this.cursor = cursor;
}
// Getters
}
public class PageInfo {
private boolean hasNextPage;
private String endCursor;
public PageInfo(boolean hasNextPage, String endCursor) {
this.hasNextPage = hasNextPage;
this.endCursor = endCursor;
}
// Getters
}
public class BookConnection {
private List edges;
private PageInfo pageInfo;
public BookConnection(List edges, PageInfo pageInfo) {
this.edges = edges;
this.pageInfo = pageInfo;
}
// Getters
}
Finally, let’s extend our repository with cursor-specific query methods:
public interface BookRepository extends PagingAndSortingRepository<Book, Long> {
List<Book> findByIdGreaterThanOrderByIdAsc(Long cursor, Pageable pageable);
List<Book> findAllByOrderByIdAsc(Pageable pageable);
boolean existsByIdGreaterThan(Long id);
}
To ensure the pagination implementation is working correctly, we’ll create integration tests using JUnit.
8.1. Preparing Test Data
First, we initialize a consistent dataset before each test using the @BeforeEach setup method:
@BeforeEach
void setup() {
bookRepository.deleteAll();
for (int i = 1; i <= 50; i++) {
Book book = new Book();
book.setTitle("Test Book " + i);
book.setAuthor("Test Author " + i);
bookRepository.save(book);
}
}
This guarantees that each test execution begins with a 50-dataset formatted book of records.
To verify our page-based pagination, we’ll test the GraphQL endpoint using GraphQlTester. This instance is a testing utility that simplifies the process of executing GraphQL queries and validating responses in our integration tests.
Let’s write a method to test the /graphql endpoint and define a GraphQL query that asks for page 0 with 5 items per page:
@Test
void givenPageAndSize_whenQueryBooks_thenShouldReturnCorrectPage() {
String query = "{ books(page: 0, size: 5) { content { id title author } totalPages totalElements number size } }";
graphQlTester.document(query)
.execute()
.path("data.books")
.entity(BookPageResponse.class)
.satisfies(bookPage -> {
assertEquals(5, bookPage.getContent().size());
assertEquals(0, bookPage.getNumber());
assertEquals(5, bookPage.getSize());
assertEquals(50, bookPage.getTotalElements());
assertEquals(10, bookPage.getTotalPages());
});
}
This test checks if the first page returns 5 books and confirms that metadata property for total pages matches the 50-test-record dataset.
To test the cursor-based pagination, we begin by executing a query for the first page of results without providing a cursor value. After receiving the first page response, we use the cursor obtained from the first page to request the next set of results.
This verifies that the cursor mechanism correctly maintains our position in the dataset and returns the subsequent items:
@Test
void givenCursorAndLimit_whenQueryBooksByCursor_thenShouldReturnNextBatch() {
// First page
String queryPage1 = "{ booksByCursor(limit: 5) { edges { node { id } cursor } pageInfo { endCursor hasNextPage } } }";
BookConnectionResponse firstPage = graphQlTester.document(firstPageQuery)
.execute()
.path("data.booksByCursor")
.entity(BookConnectionResponse.class)
.get();
assertEquals(5, firstPage.getEdges().size());
assertTrue(firstPage.getPageInfo().isHasNextPage());
assertNotNull(firstPage.getPageInfo().getEndCursor());
// Second page using cursor
String queryPage2 = "{ booksByCursor(cursor: \"" + firstPage.getPageInfo().getEndCursor() + "\", limit: 5) { edges { node { id } } pageInfo { hasNextPage } } }";
graphQlTester.document(secondPageQuery)
.execute()
.path("data.booksByCursor")
.entity(BookConnectionResponse.class)
.satisfies(secondPage -> {
assertEquals(5, secondPage.getEdges().size());
assertTrue(secondPage.getPageInfo().isHasNextPage());
});
}
9. Conclusion
In this article, we explored two approaches for implementing pagination in Spring Boot GraphQL APIs. Page-based pagination works well when dealing with smaller or finite datasets where the total number of items is known and doesn’t change frequently. On the other hand, cursor-based pagination is ideal for large datasets, infinite scrolling interfaces, and situations where data is frequently added or removed.
As always, the source code is available over on GitHub.