intermediate~3h

Projections, Pagination & Sorting

Loading full entities and unbounded result sets is wasteful by default — projections fix over-fetching in shape, pagination fixes it in size, and Page/Slice/Stream trade off cost against what information you actually need.

Learning objectives

  • Explain the performance difference between an interface projection and loading a full entity.
  • Choose correctly between Page, Slice, and Stream based on an endpoint's actual requirements.
  • Explain why unbounded pagination on a public endpoint is both a performance and a resource-protection risk.

Loading full entities for every query is often wasteful — and no real application should ever hand a user interface an unbounded result set. This chapter covers avoiding over-fetching in two dimensions: which FIELDS you load, and how MANY rows you load at once.

📖 Story

Imagine a restaurant menu with a thousand items. A customer flipping through it doesn't need the chef's full recipe, sourcing notes, and nutritional breakdown for every single dish — they need the name, a short description, and the price, a manageable page at a time.

Here's what loading the "full recipe binder" looks like in code, for a product listing page that only ever displays three fields:

List<Product> products = productRepository.findByCategory("electronics"); // Loads EVERY mapped field of EVERY Product — including lazy associations // that might trigger their own queries — just to show name/price/thumbnail.

Here's the "just the menu" version:

public interface ProductSummary { String getName(); BigDecimal getPrice(); String getThumbnailUrl(); } public interface ProductRepository extends JpaRepository<Product, Long> { List<ProductSummary> findByCategory(String category); // returns ONLY these 3 fields }

Same method name, same query — but Spring Data generates a query selecting ONLY name, price, and thumbnail_url, with no full entity ever constructed at all.

Projection — a query result shaped to include only specific fields, not a full entity. DTO projection — a projection mapped into a plain class you define, decoupled from your entity. Page — a Spring Data result wrapper including total element/page counts (needs an extra COUNT query). Slice — a lighter alternative to Page that only knows whether a next page exists, skipping the count query. Stream — consuming a large result set incrementally, without loading it all into memory.

Let's extend this chapter's ProductSummary example to cover pagination too — because loading three fields instead of thirty doesn't help if you're still loading a million rows at once.

DTO projections — for more control than an interface gives you

public interface ProductRepository extends JpaRepository<Product, Long> { @Query("SELECT new com.shop.ProductSummaryDto(p.name, p.price) FROM Product p WHERE p.category = :cat") List<ProductSummaryDto> findSummariesByCategory(@Param("cat") String category); }

This "constructor expression" maps query results directly into a DTO class you define yourself — useful when you need computed fields or a shape the interface-projection style can't quite express.

Page vs. Slice — the pagination half of this chapter's problem

// Page: also runs a separate COUNT query, so you know "page 3 of 47" Page<ProductSummary> page = productRepository.findByCategory("electronics", PageRequest.of(0, 20)); System.out.println(page.getTotalElements()); // works — but cost a second query // Slice: skips the count query entirely — cheaper, but no total available Slice<ProductSummary> slice = productRepository.findByCategory("electronics", PageRequest.of(0, 20)); System.out.println(slice.hasNext()); // true/false — no total count needed or paid for

If your UI genuinely shows "page 3 of 47," you need Page. If it's an infinite-scroll feed that only ever needs "load more," Slice gets you the same behavior for less cost.

Stream — for a batch job processing millions of rows

try (Stream<Product> stream = productRepository.streamAllByCategory("electronics")) { stream.forEach(product -> processOneProduct(product)); // Rows are fetched and processed incrementally — never all held in memory at once. }

For interface projections like this chapter's ProductSummary, Spring Data generates a JPQL query selecting only the fields your projection interface's getters correspond to, then at runtime builds a dynamic proxy implementing your interface, backed directly by that query result row — no full entity is ever constructed. Page's implicit count query is a completely separate SQL statement (SELECT COUNT(*) FROM ... WHERE <same conditions>), executed alongside your main paginated query — this is exactly why Page costs strictly more than Slice for the same query.

  • This chapter's product listing page is exactly the standard e-commerce/marketplace case for a projection — avoiding the cost of hydrating full entities the UI never fully uses.
  • Infinite-scroll feeds (social-media style) prefer Slice over Page specifically because they only ever need "is there more to load," never a total count.
  • A nightly batch export processing millions of rows uses Stream<T> to keep memory bounded, rather than attempting to load the entire result set at once.
  • Use a projection, like this chapter's ProductSummary, for any read path where the consumer needs only a subset of fields — an easy, low-risk win with no downside for read-only display data.
  • Prefer Slice over Page whenever you don't actually need a total count displayed — the count query's cost is pure waste if nothing renders it.
  • Use Stream (with try-with-resources, as shown above) for any batch job processing many rows, to keep memory bounded.
  • Always paginate any endpoint that could return an unbounded number of rows.

⚠️ Why this keeps happening

Projections, Page, Slice, and Stream all look similar at the method-signature level — different return types — so it's easy to default to whichever one an IDE autocompletes, rather than deliberately matching the tool to the actual access pattern.

  • Always using Page even when the total count is never displayed anywhere, paying for an unnecessary count query on every single request.
  • Loading full entities for a pure display list, like this chapter's opening "wrong way" example, that only ever shows 2-3 fields.
  • Forgetting to close a Stream<T> (skipping try-with-resources), leaving the underlying database cursor open longer than intended.

A projection skips full entity hydration entirely — for a wide entity with many columns and lazy associations, this is often a substantial, easy performance win on read-heavy list views, exactly like this chapter's product listing. Slice avoiding the count query is a direct, measurable win over Page for the same paginated query.

No projection-specific risk directly, but a DTO projection is a natural, deliberate point to enforce which fields ever reach a given API caller — a good habit for preventing accidental over-exposure of sensitive entity fields that a full entity serialization might otherwise leak.

Track whether count queries (from Page-based endpoints) show up disproportionately in slow-query logs relative to their paired paginated query — a large table's count query can, counter-intuitively, sometimes be slower than the paginated data query itself.

Set a maximum enforced page size for any public-facing paginated endpoint — an unbounded client-requested page size defeats the entire purpose of pagination as a resource-protection mechanism.

  1. Build this chapter's ProductSummary interface projection, and confirm (via SQL logging) that the generated query selects only those specific columns.
  2. Implement the same paginated query using both Page and Slice, and compare how many SQL statements each executes.
  3. Process a large table using Stream<T> with a bounded fetch size, confirming the full result set is never held in memory at once.

✓ Quick recap

  • Projections (interface-based, like ProductSummary, or DTO-based) select only needed fields, skipping full entity hydration.
  • Page includes a separate count query; Slice skips it, cheaper when a total count isn't needed; Stream processes large result sets incrementally.
  • Always paginate any endpoint whose result set size isn't inherently bounded.

Want a visual for this concept?

Generate a diagram tailored to “Projections, Pagination & Sorting” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Entity Mapping — @Entity, @Id, Inheritance & Composite Keys← Back to all Spring Data JPA & Hibernate Mastery chapters