Class 11: Pagination & Filtering
Duration: 70 minutes | Difficulty: Intermediate | Prerequisites: Class 10 completed
What You'll Learn
By the end of this class, you will:
- Understand why pagination is essential for any real API
- Implement offset-based pagination with
MoviePage - Implement cursor-based pagination with Spring for GraphQL's built-in
Window/ScrollSubrangeconnection support, and understand the Relay Connection machinery it generates for you - Add flexible filtering with a
MovieFilterinput type - Add sorting with
MovieSort - Know when to use each pagination approach
Why Pagination Matters
Without pagination, a simple query can bring your API to its knees:
query {
movies { # Returns 50,000 movies = crashed browser, overwhelmed server
title
directors { name }
cast { characterName person { name } }
}
}
Even if your database handles 50,000 rows fine, serializing them all into JSON, transmitting them over the network, and parsing them in the client is wasteful when the user can only see 20 movies on screen at a time. Pagination is not an optimization; it is a requirement for any production API.
There are two fundamental approaches, and each has tradeoffs that determine when to use it:
| Offset Pagination | Cursor Pagination | |
|---|---|---|
| How it works | OFFSET 20 LIMIT 10 (skip N, take M) | WHERE id > last_seen_id LIMIT 10 |
| Mental model | Page numbers: "show me page 3" | Bookmarks: "show me what comes after this item" |
| Pros | Simple, supports random page access ("jump to page 7"), familiar to users | Stable with inserts/deletes (keyset cursors), efficient for large datasets, works with real-time data |
| Cons | Slow for large offsets (OFFSET 100000), page drift when data changes | No random page access, more complex implementation, cursor must be opaque |
| Best for | Admin dashboards, small datasets, traditional page-number UIs | Infinite scroll, mobile apps, large or frequently-changing datasets |
We will implement both approaches so you can choose the right one for each use case.
Add a Field Worth Filtering On
Before we can filter movies by whether they are currently in theaters, we need that field on the Movie. We deferred adding this in Class 4 because the field did not earn its keep there. Now it does: filtering is a natural reason to track which movies are showing.
src/main/resources/graphql/schema.graphqls
Add to the Movie type:
inTheaters: Boolean!
src/main/java/com/graphqlguy/moviedb/movie/Movie.java
Add after rating:
private boolean inTheaters;
src/main/java/com/graphqlguy/moviedb/config/DataInitializer.java
One more edit, and it is the one that is easy to forget: seed the field. A primitive boolean the builder never sets defaults to false, so without this step every movie in the database is out of theaters and filter: { inTheaters: true } returns zero results no matter what else you build in this class. At the end of run(), mark a current cinema run using the movie variables Class 3 already captures:
inception.setInTheaters(true);
interstellar.setInTheaters(true);
darkKnight.setInTheaters(true);
movieRepository.saveAll(List.of(inception, interstellar, darkKnight));
Step 1: Define the Supporting Types
Before we build the pagination queries, we need Java records for the filter, sort, and page types. These are simple data carriers, and records are perfect here.
Enums
package com.graphqlguy.moviedb.movie;
public enum MovieSortField {
TITLE, RELEASE_YEAR, RATING, RUNTIME
}
package com.graphqlguy.moviedb.shared;
public enum SortOrder {
ASC, DESC
}
Records
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.shared.Genre;
public record MovieFilter(
Genre genre,
Double minRating,
Double maxRating,
Integer minYear,
Integer maxYear,
String titleContains,
Boolean inTheaters
) {}
Every field is nullable. A null field means "don't filter on this criterion." This lets clients combine filters freely: filter by genre alone, by genre and rating, or by nothing at all. The same record serves both pagination approaches.
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.shared.SortOrder;
public record MovieSort(MovieSortField field, SortOrder order) {}
package com.graphqlguy.moviedb.movie;
import java.util.List;
public record MoviePage(
List<Movie> content,
long totalElements,
int totalPages,
int currentPage,
int size,
boolean isFirst,
boolean isLast,
boolean hasNext,
boolean hasPrevious
) {}
The MoviePage record wraps a Spring Data Page<Movie> result into a shape that maps directly to the GraphQL schema. We include convenience booleans like isFirst, hasNext, and hasPrevious because these save the client from doing arithmetic on page numbers and total counts.
Know that totalElements and totalPages are not free - Spring Data issues a second SELECT COUNT query to compute them whenever the returned page is full, which the Spring Data reference notes can be expensive. When the client only needs to know whether a next page exists, returning a Slice<Movie> instead of Page<Movie> skips the count query entirely; the Window you will meet in Step 7 makes the same trade, which is why the generated PageInfo carries no totals.
Step 2: Update the Schema
Add the pagination types, filter input, and sort input to your schema. The enums, inputs, and MoviePage are brand-new types. The movies field is different: Class 3 promised we would replace the unbounded movies: [Movie!]! with a paginated version, and this is where we do it. Replace the field inside your existing Query block rather than pasting a second type Query - graphql-java refuses to build a schema with two Query definitions and fails at startup with a SchemaProblem ("tried to redefine existing 'Query' type").
enum MovieSortField {
TITLE
RELEASE_YEAR
RATING
RUNTIME
}
enum SortOrder {
ASC
DESC
}
input MovieFilter {
genre: Genre
minRating: Float
maxRating: Float
minYear: Int
maxYear: Int
titleContains: String
inTheaters: Boolean
}
input MovieSort {
field: MovieSortField!
order: SortOrder!
}
type MoviePage {
content: [Movie!]!
totalElements: Int!
totalPages: Int!
currentPage: Int!
size: Int!
isFirst: Boolean!
isLast: Boolean!
hasNext: Boolean!
hasPrevious: Boolean!
}
type Query {
# ... existing queries
# Replaces the movies: [Movie!]! field from Class 3
movies(filter: MovieFilter, page: Int = 0, size: Int = 10, sort: MovieSort = { field: RELEASE_YEAR, order: DESC }): MoviePage!
}
Notice that page, size, and sort all have default values, so clients can call movies with no arguments and get a sensible default response. The sort default follows the convention GitHub's public schema uses (an external convention we borrow only for the shape): the order input's fields are non-null, and the default value lives on the argument itself, where clients can discover it through introspection instead of reading our Java code. It also removes an ambiguity - with nullable fields, a half-specified sort: { order: ASC } would silently fall back to the Java default; now the schema rejects it and asks for the missing field.
Also notice what this replacement does to existing clients: it is a breaking schema change. Queries from earlier classes such as movies { title } now return a MoviePage, so the movie fields move under content, as in movies { content { title } }. The integration tests from Class 9 already use this shape.
One honest caveat on totalElements: Int!: GraphQL's Int is a signed 32-bit integer, while Spring Data's getTotalElements() returns a Java long. For a movie table that will never matter, but for a count that could realistically exceed about 2.1 billion, graphql-java raises a serialization error, and since the field is non-null that error nulls out the whole response. In that case register a Long custom scalar (the extended-scalars library we add in Class 12 ships one) or drop the total.
Step 3: Build the Repository Query
The repository needs a single JPQL query that handles all filter combinations. The trick is using IS NULL checks to make each parameter optional:
public interface MovieRepository extends JpaRepository<Movie, Long> {
// ... existing methods from Classes 3, 4, and 8
@Query("SELECT m FROM Movie m WHERE " +
"(:genre IS NULL OR m.genre = :genre) AND " +
"(:minRating IS NULL OR m.rating >= :minRating) AND " +
"(:maxRating IS NULL OR m.rating <= :maxRating) AND " +
"(:minYear IS NULL OR m.releaseYear >= :minYear) AND " +
"(:maxYear IS NULL OR m.releaseYear <= :maxYear) AND " +
"(:titleContains IS NULL OR LOWER(m.title) LIKE LOWER(CONCAT('%', :titleContains, '%'))) AND " +
"(:inTheaters IS NULL OR m.inTheaters = :inTheaters)")
Page<Movie> findWithFilters(
@Param("genre") Genre genre,
@Param("minRating") Double minRating,
@Param("maxRating") Double maxRating,
@Param("minYear") Integer minYear,
@Param("maxYear") Integer maxYear,
@Param("titleContains") String titleContains,
@Param("inTheaters") Boolean inTheaters,
Pageable pageable
);
}
Each line in the WHERE clause follows the same pattern: (:param IS NULL OR condition). When the parameter is null, the condition is skipped entirely. When it has a value, the filter is applied. This single query replaces what would otherwise be dozens of repository methods or a complex Specification builder.
The single-query approach is ideal at teaching scale on H2, but two caveats apply as data and databases get real. PostgreSQL has historically rejected or mishandled null-bound typed parameters in patterns like this (enums, converted attributes, and especially native queries) - the usual fixes are an explicit CAST or restructuring the query. And because every predicate is present in every execution, the planner cannot use per-column indexes effectively on large tables. The documented production path is Spring Data Specifications, which build only the predicates the client actually sent - the reference describes them as removing "the need to declare a query (method) for every needed combination". We stay with the single query here because it keeps the focus on the GraphQL layer.
The Pageable parameter handles both pagination and sorting; Spring Data translates it into the appropriate OFFSET, LIMIT, and ORDER BY clauses.
% and _ are wildcards inside a SQL LIKE pattern, and this query concatenates :titleContains straight into one. A user searching for 100% matches every title containing "100", and _ alone matches any title with at least one character. The Spring Data reference flags unsanitized like-conditions as a security concern, because clients can select more data than you intended. Derived queries such as Class 3's findByTitleContainingIgnoreCase escape their arguments automatically; a hand-written @Query does not. The fix is two small changes: escape the value in the service before binding it, and declare the escape character in the JPQL:
String safe = title == null ? null
: title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
"(:titleContains IS NULL OR LOWER(m.title) LIKE LOWER(CONCAT('%', :titleContains, '%')) ESCAPE '\\') AND " +
We keep the unescaped query in the walkthrough to stay focused on the filtering pattern, but production code should ship the escaped version.
Step 4: Implement the Service
public MoviePage findMovies(MovieFilter filter, int page, int size, MovieSort sort) {
Pageable pageable = PageRequest.of(page, size, buildSort(sort));
Page<Movie> result = movieRepository.findWithFilters(
filter != null ? filter.genre() : null,
filter != null ? filter.minRating() : null,
filter != null ? filter.maxRating() : null,
filter != null ? filter.minYear() : null,
filter != null ? filter.maxYear() : null,
filter != null ? filter.titleContains() : null,
filter != null ? filter.inTheaters() : null,
pageable
);
return new MoviePage(
result.getContent(), result.getTotalElements(), result.getTotalPages(),
result.getNumber(), result.getSize(), result.isFirst(), result.isLast(),
result.hasNext(), result.hasPrevious()
);
}
private Sort buildSort(MovieSort sort) {
if (sort == null) {
return Sort.by("releaseYear").descending().and(Sort.by("id"));
}
String field = switch (sort.field()) {
case TITLE -> "title";
case RELEASE_YEAR -> "releaseYear";
case RATING -> "rating";
case RUNTIME -> "runtime";
};
Sort base = sort.order() == SortOrder.ASC ? Sort.by(field).ascending() : Sort.by(field).descending();
return base.and(Sort.by("id"));
}
The buildSort helper maps GraphQL enum values to JPA field names. The switch expression ensures we get a compile error if someone adds a new MovieSortField value without handling it here. (One honest wrinkle: RUNTIME is declared ahead of its column - Movie only gains a runtime field in Class 12, so sorting by RUNTIME before then fails with a PropertyReferenceException. The other three fields work today.) The default sort of release year descending now lives in the schema, where clients can see it, so the most recent movies appear first when the argument is omitted; the null guard here only covers a client sending an explicit sort: null, which the nullable argument still allows.
Every sort also ends with .and(Sort.by("id")), and that tiebreaker is not optional. Our seed data has three movies tied at a 9.0 rating and two more tied at 8.8: sort by RATING without a unique final column and the database returns tied rows in whatever order it likes, so a movie can show up on two consecutive pages or on neither. Any paginated sort must end in a unique column such as id.
Spring for GraphQL can also resolve a Spring Data Sort directly as a controller method argument, but only if you declare a SortStrategy bean - unlike the cursor strategy you will meet in Step 7, it is not auto-registered by Boot (see the Controllers reference). We map MovieSort to Sort by hand here so the wiring stays visible.
Step 5: Replace the Controller Method
@QueryMapping
public MoviePage movies(@Argument MovieFilter filter, @Argument Integer page,
@Argument Integer size, @Argument MovieSort sort) {
return movieService.findMovies(filter, page != null ? page : 0, size != null ? size : 10, sort);
}
This replaces the zero-argument movies() method from Class 2, so delete that method when you add this one. Two @QueryMapping methods cannot both bind to Query.movies; if the old one is still there, startup fails with an IllegalStateException ("Ambiguous mapping. Cannot map ... There is already ... mapped.").
Spring GraphQL automatically maps the MovieFilter and MovieSort input types from the schema to the corresponding Java records. No manual deserialization required.
One more subtlety: the schema defaults cover omitted arguments, so page arrives as 0 when the client leaves it out. The Java null-checks only guard the edge case of a client sending an explicit page: null, which the nullable Int = 0 declaration still allows.
Step 6: Test Offset Pagination
Restart your application and open GraphiQL at http://localhost:8080/graphiql.
Basic Pagination
query {
movies(page: 0, size: 3) {
content {
title
releaseYear
rating
}
totalElements
totalPages
currentPage
hasNext
hasPrevious
}
}
Filtering by Genre and Minimum Rating
query {
movies(filter: { genre: DRAMA, minRating: 8.5 }) {
content {
title
rating
genre
}
totalElements
}
}
Combining Filter and Sort
query {
movies(
filter: { minYear: 1990, maxYear: 2010 }
sort: { field: RATING, order: DESC }
size: 5
) {
content {
title
releaseYear
rating
}
totalElements
totalPages
}
}
Filtering by In-Theaters Status
query {
movies(filter: { inTheaters: true }) {
content {
title
releaseYear
inTheaters
}
totalElements
}
}
You should get exactly the three movies we marked in the DataInitializer at the top of this class. If totalElements is 0 here, you skipped that seeding edit.
Step 7: Cursor-Based Pagination (Connection Pattern)
Now let's implement the second approach: the connection pattern that public GraphQL APIs like GitHub and Shopify converge on. The Relay Connection specification defines a standard shape for cursor-paginated results:
Each movie is wrapped in an edge that pairs the movie (the node) with an opaque cursor string. The pageInfo tells the client whether more pages exist and provides the cursors needed to fetch them.
The Built-In Way
Spring for GraphQL ships connection support out of the box, so you do not hand-write the edges, cursors, or pageInfo at all. The framework's auto-registered ConnectionTypeDefinitionConfigurer reads your schema, and its auto-registered CursorStrategy<ScrollPosition> decodes the incoming after cursor and Base64-encodes the outgoing cursors (first is a plain Int that becomes the subrange's count, not a cursor). Your job is to declare one schema field and write one repository method plus one controller method. We start with the schema, because the schema name is what drives the code generation.
Add the connection field to the Query type:
type Query {
# ... existing queries
moviesConnection(first: Int, after: String): MovieConnection!
}
That is the entire schema change. You do not define type MovieConnection, type MovieEdge, or type PageInfo yourself. When the framework sees a field returning MovieConnection, it strips the Connection suffix, takes the base name Movie, and because Movie is a real type in your schema it generates the full Relay shape for you:
# Generated by the framework, do NOT add this yourself
type MovieConnection {
edges: [MovieEdge]
pageInfo: PageInfo!
}
type MovieEdge {
node: Movie!
cursor: String!
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String
endCursor: String
}
One nullability detail is worth knowing: the Cursor Connections spec only requires that edges be a list of edge types, and Spring generates it fully nullable ([MovieEdge]), looser than the [MovieEdge!]! you will see in many hand-written Relay schemas. Introspection shows the nullable version, so that is what we print here.
The base name must match your domain type exactly. Naming the return type MovieConnection gives you node: Movie!, which is what you want. Naming it something like MovieBuiltInConnection would make the framework generate node: MovieBuiltIn!, a type that does not exist, and the application would fail to start. So the return type name is load-bearing: keep it MovieConnection.
The naming is intentional. first: 10, after: "cursor" reads as "give me the first 10 items after this cursor." This makes the API self-documenting and aligns with the Relay Connection specification that many GraphQL clients understand natively. The spec also defines a mirror pair, last: Int and before: String, for paginating backward, and allows a connection field to accept forward arguments, backward arguments, or both; our field is forward-only. If you later add last/before to the schema, the same ScrollSubrange argument resolves the backward direction for you - the framework generates the connection types but never the field arguments, so declaring them is always your job.
Next, the repository method. Spring Data JPA can return a Window<Movie>, which is a slice of results carrying the scroll position that produced it:
import org.springframework.data.domain.Window;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Sort;
// ... inside the MovieRepository interface
Window<Movie> findAllBy(ScrollPosition position, Limit limit, Sort sort);
The name needs the By keyword: Spring Data parses derived method names looking for By as the boundary before the predicate, so findAll(ScrollPosition position) fails at startup with a PropertyReferenceException (there is no property named findAll on Movie). With nothing after By, the derived query matches every row. Plain findBy works too; we use findAllBy because it reads better and avoids visual confusion with the Query-by-Example findBy(Example, Function) that JpaRepository already inherits.
Finally, the controller method. Spring for GraphQL resolves the ScrollSubrange argument for you from the incoming first/after, and the ConnectionFieldTypeVisitor plus WindowConnectionAdapter adapt the returned Window<Movie> into the Relay connection shape:
import org.springframework.graphql.data.query.ScrollSubrange;
import org.springframework.data.domain.Window;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Sort;
// ... inside the MovieController class
@QueryMapping
public Window<Movie> moviesConnection(ScrollSubrange subrange) {
ScrollPosition position = subrange.position().orElse(ScrollPosition.offset());
int count = subrange.count().orElse(10);
return movieRepository.findAllBy(position, Limit.of(count), Sort.by("id").ascending());
}
ScrollSubrange.position() returns an Optional<ScrollPosition>, so we fall back to ScrollPosition.offset(), which is the forward-from-the-start default. count() returns an OptionalInt, so we default it to 10 when the client omits first. The auto-registered CursorStrategy<ScrollPosition> is what decoded after into that position in the first place and what will Base64-encode endCursor in the response, so the cursors stay opaque without any code from you.
ScrollPosition.offset() is index-based, so it inherits offset pagination's page-drift problem: an insert near the top shifts every later item by one. To eliminate the drift, switch to ScrollPosition.keyset(), which remembers the last row's key rather than its index. Keyset scrolling is stable as long as the Sort ends in a unique column, which is why we sort by id. Keyset cursors also look different (they Base64-encode the last row's sort keys as JSON rather than an index), and when you scroll forward, hasPreviousPage always comes back false, because a forward keyset position only knows what comes after it.
Neither size nor first has an upper bound: movies(size: 100000) and moviesConnection(first: 100000) will happily return 100,000 rows, and none of the depth or complexity limits we add in Class 13 stop them, because those count query fields, not result rows. Public APIs cap the page size server-side - GitHub requires first to be within 1-100, and Shopify allows at most 250 resources per page. One line in each controller does it: int pageSize = Math.clamp(size != null ? size : 10, 1, 100); in the Step 5 controller, and int count = Math.clamp(subrange.count().orElse(10), 1, 100); in this one. The floor of 1 matters too, because PageRequest.of and Limit.of reject zero and negative sizes by throwing an IllegalArgumentException. Without the clamp, movies(page: -1) or size: 0 surfaces as a generic INTERNAL_ERROR through the Class 5 catch-all handler instead of a helpful BAD_REQUEST. On the connection side, the Cursor Connections spec says a negative first should produce an error ("If first is less than 0: Throw an error."), but ScrollSubrange quietly treats a negative count as absent, so the default of 10 kicks in; the clamp is where you would add an explicit rejection if you want the spec's behavior.
This simple built-in example scrolls the full movie list with no filter. Combining the multi-field MovieFilter with scrolling requires Querydsl or a Specification that supports scroll positions, which is an advanced topic beyond this class.
Under the Hood: What the Framework Generates
You never write the edges, cursors, or pageInfo records above, but it helps to see the shape the framework produces so the response is not a black box. Conceptually, the generated MovieConnection behaves like these records, and the built-in CursorStrategy runs cursor logic equivalent to the Base64 encoding shown below. Treat everything in this subsection as an illustration of the machinery Spring runs for you, not as code to add to your project.
public record MovieEdge(Movie node, String cursor) {}
public record PageInfo(boolean hasNextPage, boolean hasPreviousPage,
String startCursor, String endCursor) {}
public record MovieConnection(List<MovieEdge> edges, PageInfo pageInfo) {}
And the cursor logic the framework performs for each edge is equivalent to Base64-encoding the scroll position, roughly:
// For each movie in the window, the framework encodes its scroll position,
// prefixing the position type before Base64-encoding ("O_" marks an offset):
String cursor = Base64.getEncoder().encodeToString(
("O_" + index).getBytes(StandardCharsets.UTF_8));
MovieEdge edge = new MovieEdge(movie, cursor);
// startCursor / endCursor are the first and last edge cursors.
// hasNextPage comes from Window.hasNext(); Window has no hasPrevious(), so the
// WindowConnectionAdapter derives hasPreviousPage from the starting scroll
// position (for offset scrolling: offset != 0).
The one reason you would hand-write your own connection type is to add a field the generator omits. The generated MovieConnection has no totalCount, for instance. The generator only creates definitions that are not already in your schema (its documented contract is to add the required type definitions if they don't already exist), so the supported way to get totalCount is to declare type MovieConnection yourself with the extra field. Two consequences follow. First, hand-declaring MovieConnection switches off generation for its companions as well, so you must also declare MovieEdge and PageInfo in the schema, or startup fails on the undefined type references. Second, the controller must stop returning Window<Movie> and instead build and return your own connection object; if it kept returning a Window, the framework would adapt it into its own connection representation, which has no totalCount, and that field would error at runtime. A returned record whose class name ends in Connection (a record literally named MovieConnection qualifies) passes through the connection machinery untouched. The price is that you now own the whole shape, including encoding every cursor yourself.
Never expose raw database IDs or offsets as cursors. If clients start parsing your cursors, they become part of your API contract and you can never change the implementation. The framework's Base64 encoding signals "this is an opaque token"; clients should only pass endCursor back into after, never parse it.
Step 8: Test Cursor Pagination
First Page
query {
moviesConnection(first: 3) {
edges {
cursor
node {
title
releaseYear
}
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}
Next Page (use endCursor from the previous response)
query {
moviesConnection(first: 3, after: "T18y") {
edges {
cursor
node {
title
}
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}
Walking Forward with a Cursor
The built-in example scrolls the full list, so there is no filter argument on moviesConnection. To move forward, take the endCursor from the previous response and pass it as after:
query {
moviesConnection(first: 5, after: "PASTE_endCursor_HERE") {
edges {
cursor
node {
title
rating
genre
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Understanding the Cursor Flow
Request 1: first: 3 (no cursor, so start from the beginning)
Database: [M1, M2, M3, M4, M5, M6, M7]
Response: [M1, M2, M3]
endCursor: Base64("O_2") --> "T18y"
hasNextPage: true
Request 2: first: 3, after: "T18y" (start after index 2)
Database: [M1, M2, M3, M4, M5, M6, M7]
Response: [M4, M5, M6]
endCursor: Base64("O_5") --> "T181"
hasNextPage: true, hasPreviousPage: true
Request 3: first: 3, after: "T181" (start after index 5)
Database: [M1, M2, M3, M4, M5, M6, M7]
Response: [M7]
hasNextPage: false, hasPreviousPage: true
The O_ prefix marks an offset scroll position; keyset scrolling produces K_ plus a JSON keyset instead. The client never needs to know any of this. It just passes endCursor from one response into after for the next request.
Offset vs. Cursor: The Page Drift Problem
Here is why cursor pagination matters for data that changes. Imagine a movie list sorted by newest first, with offset pagination showing 3 per page:
With offset pagination, inserting a new item shifts everything. The client sees movie "C" on both requests (page=0 and page=1). Cursor pagination fixes this only when the cursor records which item you stopped at rather than where in the list it sat. A keyset cursor (ScrollPosition.keyset()) remembers the last row's key, so "give me items after C" keeps meaning "after C" no matter what is inserted above it. The offset cursor our Step 7 example produces still remembers an index, so it drifts exactly like page/size - which is why the caution box in Step 7 recommends keyset scrolling for frequently-changing data.
Exercises
Exercise 1: Add Title Search to Offset Pagination
Use the titleContains filter to search for movies containing "The" and paginate through the results. Verify that totalElements reflects the filtered count, not the total database count.
Exercise 2: Walk Through All Pages
Write a sequence of cursor-based queries that walks through your entire movie database 2 items at a time. Start with no cursor, then use each endCursor for the next request. Verify that hasNextPage is false on the final page.
Exercise 3: Combine Filters
Query for crime movies from the 1990s with a rating above 7.0, sorted by rating descending. How many results does the filter return? You should get exactly one - Goodfellas. An empty result here means a filter bug (see Common Issues below); one row means everything works.
Common Issues
Issue: Filter returns no results when it should
Symptom: totalElements is 0 even though matching movies exist
Solution: Check that enum values match exactly. SCIFI in the schema must match SCIFI in the Java Genre enum. Also verify that your minRating/maxRating are not accidentally swapped. For inTheaters: true specifically, check that you seeded the flag in the DataInitializer; an unset primitive boolean defaults to false, so until you seed it no movie is in theaters.
Issue: Pagination returns duplicate items
Symptom: The same movie appears on two consecutive pages
Solution: Ensure any paginated query sorts by a stable, unique field, or ends its sort with one, like the .and(Sort.by("id")) tiebreaker in buildSort. Sorting by rating alone can cause duplicates when multiple movies share the same rating because the database order is non-deterministic for tied values. This applies to the offset path and the cursor path alike.
Issue: Cursor is rejected or decodes to the wrong position
Symptom: The after cursor produces an error or unexpected results
Solution: Cursors from endCursor must be passed as-is to after. The framework's CursorStrategy decodes them, so do not URL-encode or modify them. If you are copying from the GraphiQL response, make sure you include the full Base64 string.
Summary
In this class, you learned:
- Offset pagination is simple and supports random page access, but suffers from page drift when data changes. Use it for admin dashboards and static datasets.
- Cursor pagination (the Connection pattern) is efficient for large datasets and, when backed by keyset cursors, stable under inserts, but does not support "jump to page 7." Use it for infinite scroll and real-time feeds.
- The built-in connection support makes the ecosystem standard nearly free. Connections are the shape public GraphQL APIs converge on (GitHub and Shopify are cursor-only), but no official source mandates them - the GraphQL spec is intentionally silent on pagination, and offset remains the right call for admin tables, jump-to-page UIs, and small or stable datasets. When you want the connection shape, a controller method that accepts a
ScrollSubrangeand returns aWindow<Movie>from afindAllByrepository method gets it for free: the framework generatesMovieConnection/MovieEdge/PageInfofrom the schema name and itsCursorStrategydecodesafterand Base64-encodesendCursorfor you. The hand-rolled edge/cursor records are worth understanding only as the machinery Spring runs on your behalf. - Filtering with nullable parameters and
IS NULL ORin JPQL lets a single query handle any combination of filter criteria. - Sorting maps GraphQL enum values to JPA field names through a
buildSorthelper. - Cursors should be opaque. The framework Base64-encodes them so clients cannot parse or construct them; clients only pass
endCursorback intoafter.
Further Reading
Everything in this chapter traces back to a handful of primary sources, and they are worth reading in the original:
- GraphQL Cursor Connections Specification - the external spec behind
MovieConnection, edges, andPageInfo; it mandates thePageInfotype and thefirst/afterforward-pagination arguments the framework generates for you. - graphql.org: Pagination - the official design reasoning for cursor connections, including the honest admission that the connection shape is more complex than a plain list.
- Spring for GraphQL reference: Scroll pagination -
ScrollSubrange,Window, and theCursorStrategy<ScrollPosition>from Step 7; the connection type generation is documented under Request Execution: Pagination. - Spring Data Commons reference: Scrolling - offset versus keyset scroll positions and the trade-offs between them.
- Spring Data JPA reference: Specifications - the production path for dynamic filtering when the
IS NULL ORpattern outgrows its welcome. - GitHub GraphQL API: Using pagination - an external production example of a cursor-only connection API, including its cap of 100 on
first.
What's Next?
In Class 12: TV Shows, Union Types & Custom Scalars, we'll add an entire new domain to our API by applying everything we've learned so far. You'll build TV shows with episodes, implement union types for cross-type search, and register a custom DateTime scalar.
Time to put your skills to the test on a fresh domain!