Spring Data JPA 3 to 4 Migration Guide: Query Derivation Changes, the hibernate-processor Coordinate Swap, and Value-Objects

When I upgraded a service from Spring Boot 3 to 4, the framework and web-layer changes got all the attention — but the part that actually cost me an afternoon was the repository layer. Spring Boot 4 pulls in the Spring Data 2025.1 generation (Spring Data JPA 4.0), and that carries three changes that don’t show up in a casual read of the release notes: an annotation-processor coordinate that was renamed out from under you, a complete rewrite of how derived queries are generated, and a much friendlier story for modelling value objects. None of these are loud failures. Two of them compile cleanly and then misbehave, which is the worst kind.

This guide walks the whole repository-layer migration beginner to advanced, on a real Spring Boot 4.1.0 project running Spring Data JPA 4.0, Hibernate 7.1, and Java 25. We start with how to confirm which Spring Data generation you’re actually on, then the hibernate-processor coordinate swap and the silent metamodel failure it causes, then the CriteriaQuery-to-JPQL rewrite of derived queries and its one real behavioural edge, then value objects — records as embeddables and as projections — and finally the removals that will fail your build outright. Every error message below is the real one, not a paraphrase.

Where this guide is going

Which Spring Data version you’re actually on

Before touching any code, get the version map straight, because “Spring Data JPA 4” is never a dependency you add yourself — it arrives transitively through Spring Boot, and the version numbers deliberately don’t line up. Spring Data ships as a release train with a calendar version, and each train maps to a generation number that the individual modules actually carry.

Your Spring BootSpring Data release trainspring-data-jpa moduleJPA baseline / Hibernate
3.2 – 3.52023.x – 2024.x3.2.x – 3.4.xJPA 3.1 / Hibernate 6.x
4.0 – 4.12025.14.0.xJPA 3.2 / Hibernate 7.1

So the repository-layer migration in this guide is really “Spring Data JPA 3.x to 4.0”, and it comes bundled with the jump to JPA 3.2 and Hibernate 7.1. Don’t take the mapping on faith — confirm it against your own build. Running mvn dependency:tree on a Boot 4.1.0 project shows exactly what the data-jpa starter resolves to:

org.springframework.boot:spring-boot-starter-data-jpa:jar:4.1.0:compile
  +- org.springframework.boot:spring-boot-starter-jdbc:jar:4.1.0:compile
  +- org.hibernate.orm:hibernate-core:jar:7.1.0.Final:compile
  +- org.springframework.data:spring-data-jpa:jar:4.0.0:compile
  +- org.springframework:spring-orm:jar:7.0.0:compile
  \- jakarta.persistence:jakarta.persistence-api:jar:3.2.0:compile

spring-data-jpa:4.0.0, hibernate-core:7.1.0.Final, and jakarta.persistence-api:3.2.0 — that trio is the fingerprint of the new generation. Everything that follows in this guide is a consequence of moving from the row above it in the table to that row. The very first consequence hides in a completely different part of your build file: the annotation processor.

The hibernate-processor coordinate swap

If your repositories use the JPA static metamodel — those generated Book_, Author_ classes that let you write type-safe Specification and Criteria queries against Book_.title instead of the string "title" — then something in your build generates them at compile time. That something is Hibernate’s annotation processor, and in Hibernate 7 it was renamed and moved.

The module previously published as hibernate-jpamodelgen is now published as hibernate-processor, and the processor class itself moved from org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor to org.hibernate.processor.HibernateProcessor. Here is the exact Maven change — the old configuration first:

<!-- Spring Boot 3.x / Hibernate 6.x -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.hibernate.orm</groupId>
                <artifactId>hibernate-jpamodelgen</artifactId>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

And the Boot 4 replacement — the only line that changes is the artifactId. The version is managed by the Boot BOM, so you leave it off (or reference ${hibernate.version}):

<!-- Spring Boot 4.x / Hibernate 7.1 -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.hibernate.orm</groupId>
                <artifactId>hibernate-processor</artifactId>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

Gradle users make the same one-word swap:

// Before
annotationProcessor 'org.hibernate.orm:hibernate-jpamodelgen'
// After
annotationProcessor 'org.hibernate.orm:hibernate-processor'

Now the two ways this bites. If you leave the old hibernate-jpamodelgen path in place and let the Boot 4 BOM pick the version, Maven can’t find it — that artifact ID isn’t published for the 7.1 line at all — and the build fails honestly and immediately:

[ERROR] Failed to execute goal on project catalog:
        Could not resolve dependencies for project com.ankurm:catalog:jar:0.0.1-SNAPSHOT:
        Could not find artifact org.hibernate.orm:hibernate-jpamodelgen:jar:7.1.0.Final

That one is fine — a fast, obvious failure you fix in ten seconds. The dangerous version is subtler. If you had pinned hibernate-jpamodelgen to an explicit old version (say 6.6.0.Final) so it still resolves, or if a stale processor path silently stops matching, the build compiles but no metamodel classes are generated. You don’t find out until the compiler reaches the code that references them:

[ERROR] .../book/BookSpecifications.java:[24,49] cannot find symbol
        symbol:   variable Book_
        location: class com.ankurm.book.BookSpecifications

A cannot find symbol: variable Book_ that appears out of nowhere after a version bump is almost always this: the metamodel generator didn’t run, so Book_ was never written into target/generated-sources. The fix is never in your Criteria code — it’s the processor coordinate above.

Callout — The silent metamodel failure

A missing or mis-coordinated annotation processor does not error on its own. It simply produces nothing, and the failure surfaces hundreds of lines later as cannot find symbol: variable Book_ in whatever class first touches the metamodel. Before you go debugging your Specification code, check that target/generated-sources/annotations actually contains the _ classes. If it’s empty, the coordinate swap is your bug — not the query.

The metamodel matters here because it’s the type-safe backbone of Criteria and Specification queries — and how those queries are built underneath is the next, and biggest, change in Spring Data JPA 4.

Query derivation moved from CriteriaQuery to JPQL

This is the change most people won’t notice and a few will notice sharply. A derived query is any repository method whose implementation Spring Data writes for you by parsing its name:

public interface BookRepository extends JpaRepository<Book, Long> {

    // Spring Data parses each method name and builds the query for you.
    List<Book> findByTitleContainingIgnoreCaseOrderByPublishedOnDesc(String fragment);

    Optional<Book> findByIsbn(String isbn);

    long countByGenre(Genre genre);
}

For 15 years, Spring Data JPA turned those method names into JPA CriteriaQuery objects at bootstrap. In the 4.0 generation, the team rewrote derivation to emit String-based JPQL instead. Their reasoning, from the release notes: JPA providers have to re-evaluate a CriteriaQuery on every execution, whereas Hibernate caches String JPQL aggressively. They measured a 3.5x improvement on in-memory databases and roughly 25% higher query throughput on a typical application from query caching alone. Your method signatures don’t change; what changes is the machinery behind them.

For the overwhelming majority of methods this is a free performance win. But swapping the query-building engine after 15 years is exactly the kind of change that can shift edge behaviour, and the Spring team says as much — they explicitly asked for reports of methods that behave differently. Three things are worth actively checking.

1. New functions you can now use. Because derivation and @Query parsing rode along with the JPA 3.2 upgrade, JPQL now understands union, intersect, except, cast, left, right, and replace, plus the || string-concatenation operator and Hibernate’s JSON/XML functions. Queries you previously dropped to native SQL for can move back to portable JPQL:

// Valid JPQL in Spring Data JPA 4.0 - the || operator and left() were not
// available before the JPA 3.2 baseline.
@Query("select b.title || ' (' || left(b.isbn, 3) || ')' from Book b where b.id = :id")
String titleWithIsbnPrefix(@Param("id") Long id);

2. Nulls precedence now works everywhere. Previously, asking for a specific null ordering (NULLS LAST) was only honoured in hand-written JPQL; the Criteria path ignored it. JPA 3.2’s Criteria API can declare nulls precedence, so a Sort like this is now respected on derived and Criteria queries too:

// subtitle is nullable; keep the null-subtitle rows at the end.
// In 3.x this null-handling hint was silently dropped on Criteria-backed sorts.
Sort sort = Sort.by(Sort.Order.asc("subtitle").nullsLast());
List<Book> books = bookRepository.findByGenre(Genre.SCIENCE, sort);

3. Single-result methods now use getSingleResultOrNull(). Internally, single-result query methods now call JPA 3.2’s Query.getSingleResultOrNull() instead of getSingleResult(), so an empty result no longer produces a NoResultException under the hood. For an Optional<Book> return type the observable behaviour is unchanged — you still get Optional.empty():

Optional<Book> result = bookRepository.findByIsbn("978-0000000000");

// Output (no matching row):
// result.isPresent() == false   -> Optional.empty(), same as Spring Data 3.x

Callout — Where getSingleResultOrNull actually matters

Repository methods returning Optional or a nullable type behave identically before and after, because Spring Data already translated the old NoResultException into an empty/null result for you. The place this leaks is your own code: if you inject an EntityManager and call query.getSingleResult() directly and catch (NoResultException e) as control flow, that catch block now becomes dead code for any Hibernate 7 query you migrate to getSingleResultOrNull() — the method returns null instead of throwing, and an unguarded caller gets a NullPointerException. Grep for NoResultException in your codebase before you ship.

Those three checks cover the derivation change itself. But derived queries don’t just return whole entities — increasingly they return trimmed-down value objects, and that story got materially better in 4.0.

Value objects: records as embeddables and projections

A value object is a small immutable type defined by its values rather than an identity — a Money, an Address, an IsbnRange. Java record types are the natural fit: their components are private final, and equals(), hashCode(), and toString() come for free with value semantics. On Spring Data JPA 4 / Hibernate 7, records slot into your persistence layer in two legitimate roles.

Role one: an @Embeddable value object inside an entity. A record can model a cluster of columns that belong together:

// A value object: no identity, just its two values.
@Embeddable
public record Money(BigDecimal amount, String currency) {}

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String isbn;

    @Embedded                 // Money's fields become columns on the book table
    private Money price;      // amount -> price_amount, currency -> price_currency

    // constructors, getters ...
}

Because Money is a plain embedded value object, derived queries can traverse straight into its components using the normal property-path naming — Price then Amount:

public interface BookRepository extends JpaRepository<Book, Long> {

    // Walks price -> amount. Resolves to: where b.price.amount > :min
    List<Book> findByPriceAmountGreaterThan(BigDecimal min);

    // Combine embedded-path traversal across both components.
    List<Book> findByPriceCurrencyAndPriceAmountLessThanEqual(String currency, BigDecimal max);
}

Role two: a projection / DTO target. When you only need a few columns, a record makes an ideal projection type. Spring Data can populate it by matching the record’s constructor parameters — a class-based (DTO) projection — with no @Query at all:

// The projection value object - only what the caller needs.
public record BookSummary(String title, BigDecimal amount) {}

public interface BookRepository extends JpaRepository<Book, Long> {

    // Class-based projection: return type drives the SELECT.
    // Spring Data selects title + price.amount and calls new BookSummary(...).
    List<BookSummary> findByGenre(Genre genre);
}
List<BookSummary> summaries = bookRepository.findByGenre(Genre.SCIENCE);
summaries.forEach(s -> System.out.println(s));

// Output:
// BookSummary[title=A Brief History of Time, amount=18.99]
// BookSummary[title=Cosmos, amount=14.50]

When you need explicit control — a computed column, a join — the same record works as a JPQL constructor-expression target:

// Explicit constructor expression - note the fully-qualified record name.
@Query("select new com.ankurm.catalog.BookSummary(b.title, b.price.amount) " +
       "from Book b where b.title like %:fragment%")
List<BookSummary> searchSummaries(@Param("fragment") String fragment);

And when you’d rather not create a DTO at all, an interface projection still works and returns a lightweight proxy:

public interface TitleOnly {
    String getTitle();
}

// Interface projection - Spring Data returns a proxy exposing only getTitle().
List<TitleOnly> findByPriceCurrency(String currency);

The one place a record still cannot go is the identity role. A record is immutable and has no no-argument constructor, so Hibernate cannot manage it as an entity. Annotate one with @Entity and bootstrap fails:

// DON'T: a record can never be a JPA @Entity.
@Entity
public record Book(@Id Long id, String title) {}

// Output at startup:
// org.hibernate.AnnotationException: Entity class 'com.ankurm.catalog.Book'
//   ... records cannot be mapped as @Entity types

Callout — The record rule of thumb

Records are for the value side of your model, never the identity side. Use a record for an @Embeddable value object and for every projection/DTO you return from a query. Keep your mutable, identity-bearing @Entity as a class. If you catch yourself reaching for @Entity on a record, that’s the signal you actually wanted a projection, not a new table.

Value objects raise an obvious question: which of those record components can be null, and does the repository know? In 4.0 that question has a real answer, because nullability became a first-class, checkable contract.

JSpecify null-safety across the repository layer

Every Spring Data 4.0 module now expresses parameter and return nullability with JSpecify annotations instead of the old Spring-specific @Nullable. Practically, you opt a package into null-marked territory once, in package-info.java:

// package-info.java - everything here is non-null unless marked otherwise.
@NullMarked
package com.ankurm.catalog.repository;

import org.jspecify.annotations.NullMarked;

Inside a @NullMarked package, parameters and return values are non-null by default, and you annotate the exceptions explicitly. That makes the intent of a finder unambiguous to both readers and static-analysis tools:

public interface BookRepository extends JpaRepository<Book, Long> {

    // May legitimately return null when nothing matches.
    @Nullable
    Book findFirstByIsbn(String isbn);

    // Preferred: encode absence in the type instead of a nullable return.
    Optional<Book> findByIsbn(String isbn);

    // A @Nullable parameter derives an IS NULL predicate when null is passed.
    List<Book> findBySubtitle(@Nullable String subtitle);
}

Two things to internalise here. First, prefer Optional over a @Nullable return where you can — it composes better and it’s impossible to dereference by accident. Second, this pairs directly with the getSingleResultOrNull change above: a nullable single-result method now has a clean, exception-free path from “no row” to “null return”, and JSpecify is how you document that contract to callers. If you want the deeper treatment of null-marking a whole Boot 4 codebase, I wrote that up separately in the JSpecify Null-Safety in Spring Boot 4 guide.

Nullability and value objects both feed the same higher-level API you reach for when method names run out of road: Specifications. Those got a cleanup in 4.0 too.

The refined Specification API

Good news up front: your existing Specification<Book> read queries keep working untouched. What 4.0 added is a cleaner separation of concerns for the cases the old single interface handled awkwardly. Three new types join Specification:

  • PredicateSpecification<T> — a reusable specification that returns a bare Predicate, usable regardless of whether the surrounding query is a select, update, or delete.
  • DeleteSpecification<T> — targets CriteriaDelete, so bulk deletes no longer have to pretend to be selects.
  • UpdateSpecification<T> — the same idea for CriteriaUpdate bulk updates.
// A reusable predicate, context-agnostic - works for reads, updates, deletes.
PredicateSpecification<Book> cheaperThan(BigDecimal max) {
    return (root, cb) -> cb.lessThan(root.get("price").get("amount"), max);
}

// Read (unchanged Specification-style usage):
List<Book> cheap = bookRepository.findAll(Specification.where(cheaperThan(TEN)));

Two smaller quality-of-life changes ride along. The fluent findBy(…) API can now return a Slice without firing a count query — useful for “next page exists?” pagination where the total is irrelevant — and it optionally accepts a separate count specification when you do want an optimised count. And JpaSort.unsafe(…) gained a proper ORDER BY parser that translates simple path expressions, function calls, and CASE clauses into Criteria expressions, so lightweight dynamic sorting no longer forces you into raw JPQL.

All of the above is additive — nothing you have to change on day one. The last category is the opposite: things that were removed, where the compiler or startup will insist you deal with them.

The removals that fail your build

The single removal most ordinary applications will hit is a configuration property. Spring Data JPA 4 fully reworked how it introspects and rewrites queries (a new QueryEnhancer / QueryEnhancerSelector arrangement), and in the process the old switch for choosing a native-query parser was deleted:

# Spring Boot 3.x - REMOVED in 4.x, silently ignored:
spring.data.jpa.query.native.parser=jsqlparser

The property no longer exists. If you relied on it to force (or forbid) JSqlParser, you now configure a QueryEnhancerSelector on your repository configuration instead:

@Configuration
@EnableJpaRepositories(queryEnhancerSelector = MyQueryEnhancerSelector.class)
class PersistenceConfig {
    // ...
}

Callout — A removed property fails quietly, not loudly

Unknown properties in application.properties don’t stop the app — Spring just ignores them. So spring.data.jpa.query.native.parser won’t throw; it will simply stop having any effect, and a native query that depended on a specific parser can start behaving differently with no error in the log. Grep your config for it during the upgrade rather than waiting for a query to misbehave in production.

The remaining removals are internal SPI — you only feel them if you extended Spring Data’s guts. For completeness:

Removed in Spring Data 4.0Replacement
spring.data.jpa.query.native.parser propertyQueryEnhancerSelector via @EnableJpaRepositories
QueryMethodEvaluationContextProvider (+ reactive variant)Value Expression support (QueryMethodValueEvaluationContextAccessor)
SpelEvaluator, SpelQueryContextValueExpressionQueryRewriter
QueryMethod.createParameters(Method, TypeInformation)createParameters(ParametersSource)
Auto-registered shared EntityManager (EntityManagerBeanDefinitionRegistrarPostProcessor)Deprecated; register it yourself if you truly need it

If none of those class names appear in your codebase, you have nothing to do in this row — which is the case for almost every application that isn’t a framework extension.

The migration checklist

  • Confirm the generation from a dependency tree, not the docs: you want spring-data-jpa:4.0.x, hibernate-core:7.1.x, and jakarta.persistence-api:3.2.x.
  • Rename the metamodel processor from hibernate-jpamodelgen to org.hibernate.orm:hibernate-processor in every annotationProcessorPaths / annotationProcessor declaration — and update any explicit processor class name to org.hibernate.processor.HibernateProcessor.
  • After that swap, verify target/generated-sources/annotations contains your _ classes. An empty folder plus cannot find symbol: variable Xxx_ means the processor didn’t run.
  • Assume derived queries now generate JPQL, not CriteriaQuery. It’s a performance win, but regression-test any method whose exact ordering, null-ordering, or single-result behaviour you depend on.
  • Grep for NoResultException. Any direct EntityManager single-result call that migrates to getSingleResultOrNull() returns null instead of throwing — guard the caller.
  • Model value objects as records: @Embeddable records for embedded columns, records for every projection/DTO. Never put @Entity on a record — it fails at startup.
  • Adopt JSpecify: @NullMarked your repository packages, prefer Optional returns, and mark genuine nullables explicitly.
  • Grep application.properties/yml for spring.data.jpa.query.native.parser — it’s removed and silently ignored; move to a QueryEnhancerSelector if you used it.
  • Only if you extended internals: check for SpelQueryContext, QueryMethodEvaluationContextProvider, and the old QueryMethod.createParameters signature and move to the replacements in the table above.

An AI prompt to audit your repository layer

Paste this into Claude, ChatGPT, Gemini, or Cursor with your repository in context.

You are auditing a Java/Spring persistence layer for a Spring Data JPA 3 -> 4
migration (Spring Boot 4.x, Spring Data 2025.1, Hibernate 7.1, JPA 3.2).

Scan the project and report, in this order:

1. METAMODEL PROCESSOR: any annotationProcessorPaths / annotationProcessor entry
   for org.hibernate.orm:hibernate-jpamodelgen, or an explicit processor class
   org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor. These must become
   hibernate-processor / org.hibernate.processor.HibernateProcessor. Flag any
   explicit old-version pin as high risk (build resolves but generates no
   metamodel -> cannot find symbol: variable Xxx_).

2. SINGLE-RESULT HANDLING: any direct EntityManager usage calling
   getSingleResult(), and any catch (NoResultException). On Hibernate 7 these
   should move to getSingleResultOrNull(), which returns null instead of
   throwing - report unguarded callers that would then NPE.

3. VALUE OBJECTS: any Java record annotated with @Entity (illegal - fails at
   startup). Recommend @Embeddable or projection/DTO usage instead. List record
   types already used as @Embeddable or projection targets as OK.

4. REMOVED CONFIG: any spring.data.jpa.query.native.parser property (removed,
   silently ignored) and any reference to SpelQueryContext, SpelEvaluator,
   QueryMethodEvaluationContextProvider, or QueryMethod.createParameters(Method,
   TypeInformation). Give the replacement for each.

5. QUERY DERIVATION: list derived query methods whose behaviour depends on exact
   ordering, nulls precedence, or single-result semantics - these are the ones
   to regression-test after the CriteriaQuery -> JPQL switch.

For each finding give file:line and the exact replacement. End with a migration
plan ordered by risk.

Frequently asked questions

Which Spring Data version ships with Spring Boot 4?

The Spring Data 2025.1 release train, which went GA on 14 November 2025 — the “4.0 generation.” Its spring-data-jpa module carries version 4.0.x. You never add it directly; Spring Boot 4.0/4.1 pulls it in through spring-boot-starter-data-jpa.

Do my existing derived query methods keep working after the CriteriaQuery-to-JPQL switch?

For the vast majority, yes — the method signatures and results are unchanged, and you gain query-caching performance. The Spring team explicitly flagged that swapping a 15-year-old query engine can shift edge behaviour, so regression-test methods that depend on exact ordering, null precedence, or single-result semantics.

What’s the new Maven coordinate for the JPA metamodel generator?

org.hibernate.orm:hibernate-processor, replacing org.hibernate.orm:hibernate-jpamodelgen. If you name the processor class explicitly, it moved from org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor to org.hibernate.processor.HibernateProcessor. Leave the version to the Spring Boot BOM.

Can a Java record be a JPA @Entity in Spring Data JPA 4?

No. Records are immutable and have no no-arg constructor, so Hibernate can’t manage them as entities — it fails at startup. Records are fully supported as @Embeddable value objects and as projection/DTO targets, which is where they belong.

Did spring.data.jpa.query.native.parser really get removed?

Yes. The query-introspection layer was reworked around QueryEnhancerSelector, and that property is gone. Because unknown properties are silently ignored, it won’t error — it just stops taking effect. Configure a QueryEnhancerSelector via @EnableJpaRepositories if you depended on it.

See also

Conclusion

The repository layer is where the Spring Boot 3-to-4 upgrade hides its quietest work. None of these changes announce themselves: the hibernate-processor rename fails as a phantom cannot find symbol a hundred lines from its cause, the CriteriaQuery-to-JPQL rewrite is invisible until an edge query orders nulls differently, and a removed config property just stops mattering with no log line to warn you. But the through-line is simple once you see it. Confirm the generation from your dependency tree, swap the processor coordinate and check that the metamodel actually generated, retest the handful of queries whose ordering or single-result behaviour you rely on, model your value objects as records, and grep for the one removed property. Do that and you get the good parts for free — faster derived queries, cleaner value objects, and nullability you can actually check.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.