Spring Boot Course · Lesson 8
GET /api/books returns every book in one response. With a dozen seeded rows that's fine; with fifty thousand it's a slow query, a huge JSON payload, and a server straining to hold it all in memory at once. In this lesson you'll hand the client a steering wheel — ask for one page at a time, in the order you want — by changing almost nothing in your code.
Pageable parameter and your list endpoints accept ?page=&size=&sort= for free — Spring Data does the LIMIT/OFFSET/ORDER BY for you. Responses come back as a clean Page: the slice of rows plus the metadata a UI needs (totalElements, totalPages, current page). You'll also fix a subtle trap — the default page JSON is officially unstable — with one annotation, and cap the page size so a client can't ask for a million rows.
A collection endpoint that always returns the whole table has three problems that all grow with your data:
Pagination fixes all three by pushing a LIMIT and OFFSET down to the database: fetch page 0, 20 rows, then page 1, and so on. Spring Data has this built into every repository — you mostly just have to ask for it.
Pageable Pageable is Spring Data's bundle of "which page, how big, sorted how." Add it as a controller parameter and Spring builds it automatically from the page, size, and sort query parameters — no @RequestParam wiring needed. The repository's inherited findAll(Pageable) then returns a Page instead of a List:
src/main/java/com/example/library/book/BookController.java — the all handler
@GetMapping // GET /api/books?author=...&page=0&size=20&sort=title
public Page<BookResponse> all(
@RequestParam(required = false) String author,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return books.findAll(author, pageable).map(BookResponse::from);
}
The filter from Lesson 5 (?author=) is still here — paging and filtering compose. The new imports are org.springframework.data.domain.Page, org.springframework.data.domain.Pageable, and org.springframework.data.web.PageableDefault (more on @PageableDefault in Step 4).
Push the Pageable down through the layers. The service keeps deciding which query to run (the Lesson 3 rule — the service decides, the controller translates), now handing the page request to whichever repository method it picks:
src/main/java/com/example/library/book/BookService.java — the changed method
public Page<Book> findAll(String author, Pageable pageable) {
return (author == null)
? books.findAll(pageable) // inherited from JpaRepository
: books.findByAuthorName(author, pageable); // our derived query, now paged
} findAll(Pageable) already exists on JpaRepository. For the derived query, just add a Pageable parameter and switch the return type to Page — Spring Data rewrites the rest:
src/main/java/com/example/library/book/BookRepository.java
public interface BookRepository extends JpaRepository<Book, UUID> {
// Traverses the relationship: WHERE author.name = ?, one page at a time
Page<Book> findByAuthorName(String name, Pageable pageable);
} Page vs. Slice vs. List
A repository paging method can return three shapes. Page<T> is the rich one: the rows plus a second COUNT query so it knows totalElements and totalPages — what you need to render "Page 2 of 9." Slice<T> skips the count (cheaper) and only knows whether a next page exists — good for "Load more" infinite scroll. A plain List<T> honours the paging but drops all metadata. We use Page because a Library UI wants the totals.
Notice the controller never builds a Page by hand — it calls .map(BookResponse::from). Page is a functor: map transforms each element while carrying all the metadata across unchanged. So Page<Book> becomes Page<BookResponse> — the same Lesson 6 rule (entities never cross the boundary; map to a DTO at the edge), now one page at a time. The service and repository still speak entities; only the controller maps.
Restart the app. Three query parameters now steer the endpoint — and you didn't write code for any of them:
page — which page, zero-based (page=0 is the first page).size — rows per page.sort — field,direction, e.g. sort=year,desc. Repeat the parameter to sort by several fields.# Page 0, three per page, newest first
curl -s "http://localhost:8080/api/books?size=3&page=0&sort=year,desc" The books come back in the right order and the response carries the paging metadata — but look closely at the shape of that metadata:
{
"content": [
{ "id": "019efc0b-1386-…", "title": "Refactoring", "year": 2018, "author": { … } },
{ "id": "019efc0b-1373-…", "title": "Effective Java", "year": 2018, "author": { … } },
{ "id": "019efc0b-1362-…", "title": "Clean Architecture", "year": 2017, "author": { … } }
],
"empty": false,
"first": true,
"last": false,
"number": 0,
"numberOfElements": 3,
"pageable": {
"offset": 0, "pageNumber": 0, "pageSize": 3, "paged": true,
"sort": { "empty": false, "sorted": true, "unsorted": false },
"unpaged": false
},
"size": 3,
"sort": { "empty": false, "sorted": true, "unsorted": false },
"totalElements": 7,
"totalPages": 3
} That's a lot of sprawl — and worse, the server logged a warning the moment it serialized this:
WARN ... PageModule$WarningLoggingModifier :
Serializing PageImpl instances as-is is not supported, meaning that there is no
guarantee about the stability of the resulting JSON structure!
For a stable JSON structure, please use Spring Data's PagedModel
(globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) Page is a trap Page's default implementation, PageImpl, is an internal domain type, not a designed API response. Jackson will happily serialize all its fields — but the Spring team makes no promise that this structure stays the same across versions. Build a client against pageable.pageNumber today and a future upgrade could rename or move it. The warning is Spring telling you: pin down a stable contract before you ship.
The fix is a single annotation that switches Spring to a small, guaranteed-stable page representation called PagedModel. Your controllers keep returning Page<BookResponse> — Spring renders it in the stable shape automatically:
src/main/java/com/example/library/LibraryApplication.java
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;
@SpringBootApplication
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class LibraryApplication { … }
Restart and re-run the same request. The warning is gone, and the metadata collapses to exactly what a client needs — the rows under content, the paging facts under one tidy page object:
{
"content": [
{ "id": "019efc0b-1386-…", "title": "Refactoring", "year": 2018, "author": { "id": "…", "name": "Martin Fowler" } },
{ "id": "019efc0b-1373-…", "title": "Effective Java", "year": 2018, "author": { "id": "…", "name": "Joshua Bloch" } },
{ "id": "019efc0b-1362-…", "title": "Clean Architecture", "year": 2017, "author": { "id": "…", "name": "Robert C. Martin" } }
],
"page": {
"size": 3,
"number": 0,
"totalElements": 7,
"totalPages": 3
}
}
This is the contract to build a UI on: content is the current slice, page.totalPages drives the pager, page.number is where you are. Ask for the next page and only number (and the rows) change:
curl -s "http://localhost:8080/api/books?size=3&page=1&sort=title"
# -> content = ["Patterns of Enterprise Application Architecture", "Refactoring", "The Clean Coder"]
# page = { "size": 3, "number": 1, "totalElements": 7, "totalPages": 3 }
Two production touches. First, @PageableDefault (already on the handler in Step 1) decides what an unparameterized request gets. Without it, a bare GET /api/books uses Spring's global default of 20 rows, unsorted; with it you set a sensible page size and a default sort so results never come back in arbitrary order:
@PageableDefault(size = 20, sort = "title") Pageable pageable Second — and this one matters for safety — the page size is capped. A client can't escape pagination by asking for everything:
curl -s "http://localhost:8080/api/books?size=99999"
# -> page = { "size": 2000, "number": 0, "totalElements": 7, "totalPages": 1 }
# the requested 99999 was clamped to 2000
Spring caps size at spring.data.web.pageable.max-page-size (default 2000). Lower it to something realistic for your API — a request for a million rows quietly becomes a request for, say, 100:
src/main/resources/application.properties
# Cap how many rows one page request can pull
spring.data.web.pageable.max-page-size=100
# (optional) change the default page size for unparameterized requests
spring.data.web.pageable.default-page-size=20 LIMIT/OFFSET over an ORDER BY. If the sort key isn't unique — say two books share a year — the database may order the ties differently between queries, so the same row can appear on page 1 and page 2, or vanish entirely. The cure is a tie-breaker on a unique column, giving a total order like ORDER BY year DESC, id ASC. But guaranteeing that order is a correctness invariant of your API — too important to depend on whether each client remembers to append &sort=id. Enforce it in the handler: take whatever sort arrives and append id if it isn't already there.
That guarantee is stateless and identical for every paged endpoint, so define it once as a small helper rather than inlining it in each controller (and copy-pasting it into the next). In package-by-feature, a cross-cutting utility earns its own home:
src/main/java/com/example/library/support/Pageables.java
package com.example.library.support;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
public final class Pageables {
private Pageables() {} // utility class, never instantiated
// Append the unique id as a final sort key (if absent) so paging is deterministic.
public static Pageable withIdTiebreaker(Pageable pageable) {
Sort sort = pageable.getSort();
boolean hasId = sort.stream().anyMatch(o -> o.getProperty().equals("id"));
return hasId ? pageable
: PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort.and(Sort.by("id")));
}
}
Now the handler stays a one-liner — it just runs the incoming Pageable through the helper before querying. No new imports in the controller beyond Pageables itself:
src/main/java/com/example/library/book/BookController.java — the all handler
@GetMapping
public Page<BookResponse> all(
@RequestParam(required = false) String author,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return books.findAll(author, Pageables.withIdTiebreaker(pageable)).map(BookResponse::from);
}
A client's ?sort=year,desc now runs as year DESC, id ASC; a request with no sort becomes title ASC, id ASC; and a client that already sorts by id is left untouched — no duplicate clause, and their chosen direction wins. The server's contract is simply "a unique key is always in the ORDER BY," not "id is forced to the end." And because our id is a time-ordered UUIDv7, that tiebreaker is also meaningful — a stable, insertion-ordered fallback, a quiet payoff of the Lesson 5 id choice.
Pageables.withIdTiebreaker keeps that guarantee in a single place — every endpoint calls the same method. Its one weakness: a brand-new endpoint could forget to call it. When you have enough paged endpoints that this worries you, promote the helper to a custom Pageable argument-resolver — then every Pageable arrives already hardened and the call disappears from the controllers entirely.
GET /api/authors has the identical unbounded-list problem. The change is mechanical — mirror all of the above:
AuthorService.findAll takes a Pageable and returns Page<Author> (delegating to the inherited authors.findAll(pageable));AuthorController.all takes @PageableDefault(size = 20, sort = "name") Pageable and returns authors.findAll(Pageables.withIdTiebreaker(pageable)).map(AuthorResponse::from) — the same helper, so the deterministic-order guarantee is defined once and reused, not copied.
No repository change is needed — findAll(Pageable) is inherited. The @EnableSpringDataWebSupport annotation you added is global, so the authors endpoint gets the stable shape too.
AuthorSummary — so a page of 20 books fires up to 20 extra author queries (the N+1 problem from Lesson 6, now bounded by page size instead of table size). Paging makes the damage finite, but the right fix — fetching the authors in one query — is the whole subject of the next lesson.
sort=year,desc seems ignored? The sort field must be the entity property name (year, title), not the DB column or the JSON field. A typo'd property is silently dropped, leaving the result unsorted.@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) annotation is missing or on a class outside com.example.library — it must be picked up at startup.?size=5000 only returns 2000 (or 100) rows? Working as intended — that's the max-page-size cap protecting the server.Answer from memory before revealing.
Pageable parameter to a controller method. Where do page, size, and sort come from?Page (PageImpl) as JSON?page.map(BookResponse::from) do to the paging metadata?year and you sort only by year. What can go wrong across pages? Primary source: the Spring Data reference on web support & pagination — the authoritative description of Pageable resolution, the PageImpl serialization problem, and the VIA_DTO / PagedModel fix you used here. For a gentler walkthrough with more examples, Paging with Spring Boot (Reflectoring) is excellent.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.