Week 15: Advanced Spring Data — Specifications, Projections & Query Optimization

Weeks 4–5 got you to JpaRepository, derived query methods, and relationships that work. That's enough for most CRUD screens; it stops being enough the moment a search form needs five optional filters combined at runtime, or a list endpoint needs to return twelve fields instead of a whole entity graph. This week covers the query patterns that actually show up in production repositories: building queries dynamically, shaping exactly the data you return, and fixing the N+1 problem for good instead of hoping it doesn't happen.

Module 12 of 22 Week 15 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build dynamic, runtime-composed queries with JPA Specifications
  • Return DTO projections and use entity graphs to eliminate N+1 queries
  • Reach for batch operations and native queries when JPQL genuinely can't do the job

1. Dynamic Queries with JPA Specifications

A derived query method like findByStatusAndCategory works when the set of filters is fixed. It stops working the moment a search endpoint needs to combine an arbitrary, optional subset of filters — a user might filter by status alone, by category alone, by both, or by neither. Writing a derived method for every combination doesn't scale (five optional filters is already 32 combinations); a Specification lets you build the query's WHERE clause programmatically, adding only the conditions that actually apply.

TaskSpecifications.java
package com.codeverse.week15;

import org.springframework.data.jpa.domain.Specification;

class TaskSpecifications {

    static Specification<Task> hasStatus(String status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    static Specification<Task> hasCategory(String category) {
        return (root, query, cb) ->
            category == null ? null : cb.equal(root.get("category"), category);
    }

    static Specification<Task> createdAfter(Instant after) {
        return (root, query, cb) ->
            after == null ? null : cb.greaterThan(root.get("createdAt"), after);
    }
}

Returning null from a Specification is deliberate, not a bug — Spring Data JPA's Specification.where() and .and() silently skip any null predicate, so a filter that wasn't supplied simply doesn't appear in the final WHERE clause at all, instead of matching everything or nothing.

TaskRepository.java & the service composing filters at runtime
interface TaskRepository extends JpaRepository<Task, Long>,
                                  JpaSpecificationExecutor<Task> {}

// in the service layer
Specification<Task> spec = Specification
    .where(TaskSpecifications.hasStatus(filter.status()))
    .and(TaskSpecifications.hasCategory(filter.category()))
    .and(TaskSpecifications.createdAfter(filter.createdAfter()));

List<Task> results = taskRepository.findAll(spec);

Extending JpaSpecificationExecutor<Task> alongside JpaRepository is what unlocks findAll(Specification) — the repository doesn't need a single new method written for this to work; the same interface now accepts any combination of the Specifications above, composed at request time based on which filter fields the caller actually supplied.

Specifications trade query readability for flexibility — use them where you need the flexibility

A Criteria API lambda is harder to read at a glance than a named derived method or a @Query annotation. Reach for Specifications specifically for genuinely dynamic, multi-filter search endpoints — for a query with a fixed, known shape, a derived method or @Query stays more readable and is the better default.

2. DTO Projections & Entity Graphs

findAll() returning full Task entities is wasteful the moment a list endpoint only needs three of a table's fifteen columns — every unused column still gets selected, mapped, and (worse) can trigger lazy-loading of associations nobody asked for. A DTO projection asks the repository to select and construct exactly the shape you need, directly at the query level.

an interface-based projection
interface TaskSummary {
    Long getId();
    String getTitle();
    String getStatus();
}

interface TaskRepository extends JpaRepository<Task, Long> {
    List<TaskSummary> findByStatus(String status);   // selects only 3 columns
}

Spring Data generates a proxy implementing TaskSummary at runtime and, critically, restricts the generated SQL's SELECT clause to only the columns the interface's getters expose — this is a real query-level optimization, not just a Java-side filter applied after fetching everything.

The other half of this section is Week 4's N+1 problem, revisited properly. Lazy associations avoid loading data you don't need — until you iterate a list and access that association on every element, which fires one extra query per row. An entity graph tells JPA up front which associations to fetch eagerly, in the same query, for this specific repository call:

solving N+1 with @EntityGraph
interface TaskRepository extends JpaRepository<Task, Long> {

    @EntityGraph(attributePaths = {"assignee", "tags"})
    List<Task> findByProjectId(Long projectId);
}

Without the entity graph, fetching 50 tasks and touching task.getAssignee() on each one fires 1 query for the tasks plus 50 more — one per row — for the lazily loaded assignee. With it, JPA generates a single query with the appropriate joins, because the entity graph declares the intent up front instead of discovering it lazily, one row at a time, during iteration.

Enable SQL logging while you work on this section

spring.jpa.show-sql=true plus logging.level.org.hibernate.SQL=debug in application.properties makes N+1 impossible to miss — you'll see the extra 50 queries scroll by in the console the moment you iterate a lazy association without an entity graph, which is a far more convincing signal than reading the code and guessing.

3. Batch Operations & Native Queries

Calling save() in a loop for 10,000 rows issues 10,000 individual INSERT statements (or worse, one SELECT per row first, to check whether it's an insert or an update). Batch inserts group statements together and send them to the database in chunks:

application.properties — enabling JDBC batching
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

Batching alone isn't enough for a genuinely large bulk update, though — updating 10,000 rows by loading each entity, mutating it, and letting Hibernate's dirty checking issue individual UPDATE statements is still slow at scale. A bulk modifying query issues a single UPDATE ... WHERE statement directly against the database, bypassing the persistence context entirely:

a bulk update with @Modifying
interface TaskRepository extends JpaRepository<Task, Long> {

    @Modifying
    @Query("UPDATE Task t SET t.status = 'ARCHIVED' WHERE t.updatedAt < :cutoff")
    int archiveStaleTasks(@Param("cutoff") Instant cutoff);
}

@Modifying is required to tell Spring Data this @Query changes data rather than reading it — without it, Spring Data assumes a SELECT and the call fails. The tradeoff worth understanding: this update bypasses Hibernate's first-level cache entirely, so any already-loaded Task entities in the current persistence context won't reflect the change unless you explicitly clear or refresh them.

Some queries — a recursive CTE, a database-specific full-text search, a query tuned around a particular index — genuinely can't be expressed in JPQL. A native query escapes to real SQL when that's the honest answer, rather than fighting JPQL to approximate it:

a native query
@Query(value = """
    SELECT * FROM task
    WHERE to_tsvector('english', title) @@ plainto_tsquery('english', :term)
    """, nativeQuery = true)
List<Task> searchByFullText(@Param("term") String term);
Native queries trade portability for capability — know which one you're choosing

The full-text search above is Postgres-specific syntax; it won't run unmodified against MySQL or another database. Reach for a native query when you genuinely need something JPQL can't express and you're not planning to change databases — for anything that JPQL or a Specification can express, prefer them, since they stay portable across the JPA providers and databases this course has used.

4. Hands-on Exercise

Hands-on

Build a dynamic search endpoint and eliminate a real N+1

Apply Specifications, projections, entity graphs, and a bulk update to the task service from earlier weeks.

Requirements:

  1. Build a GET /api/tasks/search endpoint accepting optional status, category, and createdAfter query parameters, composed at runtime with JPA Specifications — confirm it works correctly with zero, one, and all three filters supplied.
  2. Add a DTO projection interface for a lightweight task list view, and confirm with SQL logging that the generated query only selects the needed columns.
  3. Reproduce a real N+1: iterate a list of tasks and access a lazy association on each one, and count the queries logged. Then add an @EntityGraph and confirm the query count drops to one.
  4. Write a bulk @Modifying query that archives all tasks older than a given cutoff in a single statement, and confirm via SQL logging that it issues one UPDATE, not N.
Hint

Hibernate's hibernate.generate_statistics=true plus a debug log level on org.hibernate.stat prints a query count summary at the end of each session — a fast, precise way to confirm "one query" rather than counting log lines by eye.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why does a Specification returning null for an unsupplied filter work correctly, rather than breaking the query?

Spring Data JPA's Specification.where() and .and() are written to detect a null predicate and simply omit it from the composed query, rather than treating it as a condition to evaluate. That's what lets a single Specification method be reused across requests where the corresponding filter may or may not have been supplied, without conditional logic scattered through the calling code.

Q2

Why does @EntityGraph eliminate N+1 queries where simply marking an association FetchType.EAGER would not solve the problem as well?

@EntityGraph is scoped to one specific repository method, so only the calls that actually need the association eagerly fetched pay for the extra join. A blanket FetchType.EAGER on the entity itself forces every single load of that entity, everywhere in the application, to always fetch the association — even for the many places that never touch it — which trades one performance problem for a different, harder-to-see one.

Q3

Why can a bulk @Modifying update leave an already-loaded entity in the persistence context showing stale data?

A @Modifying query executes a direct SQL statement against the database, bypassing Hibernate's persistence context and first-level cache entirely. Any entity instance that was already loaded into that context before the bulk update ran keeps its in-memory field values as they were at load time — the database has changed, but the cached Java object hasn't been told, unless it's explicitly refreshed or the context is cleared.