Skip to main content

Spring Data JPA 3 to 4 Migration Guide: What Breaks, What Changes, and What Stays the Same

Part 1 of a 3-part Spring Data JPA 3 to 4 migration series. Covers the platform bump, the hibernate-jpamodelgen to hibernate-processor rename, repository interface compatibility, the JSpecify @NullMarked null-handling shift, and what genuinely doesn’t change. Verified against Spring Boot 4.0.6 and Spring Data JPA 4.0.6.

Deep-dive companion #1 of 3 to the Spring Data JPA 3 to 4 Migration Guide. Spring Boot 4.0.6 · Spring Framework 7 · Spring Data JPA 4.0.6 · Jakarta EE 11 · JPA 3.2 · Hibernate ORM 7.2. Every code sample below was compiled and executed on JDK 21 — the console output you see is the real output, not hand-written.

Most “what’s new in Spring Data JPA 4” posts stop at the version table: bump Jakarta, bump Hibernate, done. That misses the point. Most of your repository interfaces will compile completely unchanged — but the platform and runtime contracts underneath them have changed, in ways that compile fine, pass a smoke test, and then surprise you in production. This article covers the baseline every migration needs: the platform bump, the build change, repository compatibility, and the JSpecify null-handling shift. Two follow-up articles go deeper on the query engine rewrite and on advanced infrastructure — links to both are below.

# Article Focus
0 Full Guide (Overview) Beginner-to-advanced walkthrough of the whole repository layer; this series is its deep-dive companion
1 Migration Baseline (this article) Platform, repositories, nullability, compatibility
2 Query Engine Rewrite Criteria → JPQL, provider behavior, sorting, query functions
3 Advanced Migration Specifications, AOT, EntityManager wiring

Companion code

All examples in this series are compiled and executed against Spring Boot 4.0.6, Spring Data JPA 4.0.6, Hibernate ORM 7.2, and JDK 21, in the companion repository sdjpa4-demo. This article’s code: pom.xml, repo/package-info.java, repo/AuthorRepository.java, and MigrationBehaviorTests.java. Check out the tag for this article: git checkout article-1-baseline.

Who Needs to Worry?

Not every team needs to read this whole series. A rough filter before you dive in:

You’re mostly fine, budget a day or two: you use JpaRepository/CrudRepository with derived and @Query methods, standard entity mappings, and Hibernate as your provider. Most of your work is the platform bump (Boot 4, JDK 17+) and the hibernate-jpamodelgenhibernate-processor rename below. This article and its checklist are enough.

Budget real time and regression tests: you maintain a custom RepositoryFactory/Querydsl integration or anything importing org.springframework.data.mapping/util (covered below), currently pass unvalidated null into finder arguments (covered below), run EclipseLink instead of Hibernate or care about query performance (see Article 2), or built bulk delete/update on Specification workarounds, inject a bare @Autowired EntityManager, or build native images (see Article 3). Read the whole series.

A Minimal 3.x → 4.0 Diff

Before the section-by-section detail, here’s what an actual migration touches on a small, real repository — build coordinate, package-level nullability, and nothing else. The method signature does not change; only the engine and the runtime contract underneath it do.

pom.xml:

-       <artifactId>hibernate-jpamodelgen</artifactId>
+       <artifactId>hibernate-processor</artifactId>

repo/package-info.java:

+ @NullMarked
+ package com.ankurm.sdjpa4demo.repo;
+
+ import org.jspecify.annotations.NullMarked;

repo/AuthorRepository.java:

  public interface AuthorRepository extends JpaRepository<Author, Long> {
      Optional<Author> findByName(String name);
-     // 3.x: derived query runs through CriteriaQuery; null argument passes through untouched
+     // 4.0: derived query compiles to a JPQL string (see Article 2); a null argument to a
+     //      non-Optional, non-@Nullable method now throws IllegalArgumentException (see below)
  }

Nothing else in that repository interface needs to change. The signature is identical in both versions — everything that’s different is either build-time (the processor artifact) or runtime behavior you can’t see by reading the method declaration, which is exactly why this migration is easy to under-test.

The Baseline: What “4.0” Actually Drags In

Spring Data JPA 4.0 is not a standalone upgrade. It is the JPA module of the Spring Data 2025.1 release train, which ships inside Spring Boot 4.0 (GA 20 November 2025). You cannot adopt it without also moving the platform underneath it. Here is the floor:

Layer Spring Data JPA 3.x Spring Data JPA 4.0
Spring Framework 6.x 7.0
Java baseline 17 17 (any supported LTS for production)
Jakarta EE EE 10 EE 11 (Servlet 6.1)
JPA spec JPA 3.1 JPA 3.2
Hibernate ORM 6.x 7.1+ (7.2 in current 4.0.x)
Nullability Spring @Nullable JSpecify
Async wrapper ListenableFuture CompletableFuture only

On Java specifically: the baseline is Java 17. The Spring Data team points at the latest LTS (JDK 25) as the forward-looking target, but any supported LTS is fine for production — this series’ sample project is built and verified on JDK 21. Read “17” as the floor, not “use only 17.”

Breaking

There is no “just upgrade Spring Data” path. If your project is still on Spring Boot 3 / Framework 6, you are migrating the whole stack at once. Treat this as a Boot 3→4 migration that happens to include Spring Data, not the other way round.

With the floor established, start where the compiler forces you to start: the build.

The Build Change You Hit Before Any Code

The first thing that breaks is not a repository — it is the annotation processor that generates your JPA static metamodel (Author_, Book_). Hibernate renamed the artifact.

Spring Data JPA 3.x Spring Data JPA 4.0
org.hibernate.orm:hibernate-jpamodelgen org.hibernate.orm:hibernate-processor
hibernate-proxool, hibernate-vibur no longer published

In Maven, the processor belongs on the compiler plugin’s processor path (Boot’s dependency management supplies the version):

<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>

After that, the generated metamodel is emitted by the renamed processor — you can see it in the generated source header:

// target/generated-sources/annotations/.../Author_.java
@StaticMetamodel(Author.class)
@Generated("org.hibernate.processor.HibernateProcessor")   // <-- was hibernate.jpamodelgen before
public abstract class Author_ {
    public static final String NAME = "name";
    public static volatile SingularAttribute<Author, String> name;
    // ...
}

Silent build break

If you keep the old hibernate-jpamodelgen coordinate, the dependency simply won’t resolve under Boot 4’s BOM, and any type-safe Criteria code that references Author_.name stops compiling. Search your build for jpamodelgen before anything else.

Note also that Boot 4 splits the old spring-boot-starter-* jars into finer spring-boot-* modules, but spring-boot-starter-data-jpa is still the starter you depend on. With the build green, look at the repositories themselves.

Repository Interfaces: What Stayed, What Quietly Moved

Good news for the 90% case: JpaRepository, CrudRepository, ListCrudRepository, PagingAndSortingRepository and your derived-query methods are source-compatible. A plain repository like this needs zero changes:

public interface AuthorRepository
        extends JpaRepository<Author, Long>, JpaSpecificationExecutor<Author> {

    Optional<Author> findByName(String name);
    List<Author> findByPriceGreaterThanEqual(BigDecimal price);
}

What moved is internal API that leaks into custom infrastructure, module code, and the occasional clever helper:

Removed / moved in 4.0 Replacement
@PersistenceConstructor @PersistenceCreator
org.springframework.data.mapping.PropertyPath org.springframework.data.core.PropertyPath
org.springframework.data.util.TypeInformation org.springframework.data.core.TypeInformation
QPageRequest(...) constructors QPageRequest.of(...) factory methods
SpEL evaluator classes (DefaultSpELExpressionEvaluator, …) Value Expression API (ValueExpressionEvaluator)
org.springframework.data.repository.util.ClassUtils org.springframework.data.util.ClassUtils / ReflectionUtils

Who this hits

If you only write repository interfaces and entities, you’ll never notice these. If you maintain a custom RepositoryFactory, a Querydsl integration, or anything that imports from org.springframework.data.mapping/util, budget an afternoon for import surgery. This is also why third-party Spring Data add-ons often need their own 4.0-compatible release. Full list of internal-API moves: release-notes §Package relocation.

The most consequential repository-level change is not on this list, though — it’s how null is treated. That’s next, because it changes runtime behavior even when nothing fails to compile.

Null-Handling: The JSpecify Shift That Changes Runtime Behavior

Through Spring Data 3.x you expressed nullability with Spring’s own annotations (org.springframework.lang.@Nullable, @NonNullApi in package-info.java). As of Spring Framework 7 and Spring Data 4, those are deprecated in favor of JSpecify. You mark a package @NullMarked, and non-null becomes the default for every parameter and return value in it.

// repo/package-info.java
@NullMarked
package com.ankurm.sdjpa4demo.repo;

import org.jspecify.annotations.NullMarked;

Inside that package, three single-result methods now mean three different things:

public interface AuthorRepository extends JpaRepository<Author, Long> {

    Optional<Author> findByName(String name);      // (1) empty when missing

    Author getByName(String name);                  // (2) non-null: throws when missing,
                                                    //     rejects a null argument

    @Nullable Author findByCountry(@Nullable String country); // (3) may return null,
                                                    //     tolerates a null argument
}

Running all three against an empty result (verified console output):

================ A. Null-handling of single-result query methods ================
findByName(Optional) missing        -> Optional.empty
getByName(non-null) missing         -> throws EmptyResultDataAccessException
findByCountry(@Nullable) missing    -> null

So far that matches 3.x intuition. The shift is on the argument side. In a @NullMarked package, passing null to a non-null parameter is now rejected at runtime, before the query even runs:

================ B. JSpecify @NullMarked parameter enforcement ================
getByName(null)     -> throws IllegalArgumentException:
                       Parameter name in AuthorRepository.getByName(java.lang.String) must not be null
findByCountry(null) -> allowed (@Nullable param) -> Author{id=3, name='Anonymous', country=null}

Silent behavior shift

Code that used to pass null into a finder — often by accident, from an unvalidated request parameter — and got back null or an empty result will now throw IllegalArgumentException the moment you annotate the package @NullMarked. That’s a good change, but it surfaces bugs that were previously swallowed. Roll @NullMarked out package by package and run your integration tests after each one, rather than annotating everything in a single commit.

Where the Runtime Check Actually Comes From

Worth being precise here, because it’s easy to credit the wrong component: JSpecify itself does no runtime work. It’s a set of TYPE_USE annotations meant for static analysis — IntelliJ, Eclipse, and NullAway read them at build/edit time; the JAR ships no bytecode that runs at 3 a.m. in production. The IllegalArgumentException above is thrown by Spring Data’s repository proxy — the same query-method-invocation layer that has validated arguments and return values against a package’s nullability declaration since the older @NonNullApi/@NonNull/@Nullable annotations. In 4.0 that proxy-level validation was extended to read JSpecify’s @NullMarked/@NonNull/@Nullable type-use annotations instead (or in addition, for Kotlin, the compiled-in nullability metadata). The official contract — which method shape throws what, for which side (argument vs. return) — is spelled out per-case in the Spring Data JPA 4.0.6 null-handling reference; it’s worth reading once rather than inferring it from a stack trace.

This validation happens before the query executes, which makes nullability one of the first runtime behavior changes you’re likely to observe during a migration.

The Small Removals That Still Bite

  • ListenableFuture is gone. @Async query methods must return CompletableFuture. Spring Framework 7 removed ListenableFuture, and Spring Data followed (release notes).
  • Jackson 3. Spring Data REST 5.0 moves to Jackson 3 (core package tools.jackson.*; the annotations stay under com.fasterxml.jackson but must be a 3.x version). If you expose repositories over REST and customize serialization, this is a separate migration.
  • @PersistenceConstructor@PersistenceCreator on entities with multiple constructors.

What Does NOT Change?

Migration write-ups skew toward warnings because warnings are what bite you. Equally useful: the long list of things you do not need to touch.

  • Repository interface types and method signatures — JpaRepository, CrudRepository, ListCrudRepository, PagingAndSortingRepository, and every derived-query method name you’ve already written.
  • Entity mapping annotations — @Entity, @Id, @GeneratedValue, @ManyToOne, and the rest of the jakarta.persistence annotation set are untouched by this release.
  • Existing @Query JPQL and native queries that don’t rely on the removed native-parser property keep working exactly as before (see Article 2).
  • Optional, List, Page, Slice, and Stream return types — same semantics, same wrapper behavior for empty results.
  • Plain read Specification<T> usage — the type and its callers are unchanged; only bulk delete/update built on top of it needs to move (see Article 3).
  • @Transactional semantics, propagation, and the rest of Spring’s transaction management — untouched by Spring Data 4.0.
  • Your JDBC driver and connection-pool configuration — the JPA/Hibernate layer changed, not the datasource plumbing underneath it.
  • Packages you haven’t yet annotated @NullMarked keep their previous (unenforced, or @NonNullApi-enforced) null behavior — the JSpecify shift is opt-in per package, not global.

Migration Checklist

  1. Move the platform first: Spring Boot 4.0.x (pulls Framework 7, Jakarta EE 11, JPA 3.2, Hibernate 7). Confirm JDK 17+.
  2. Replace hibernate-jpamodelgen with hibernate-processor on the compiler processor path.
  3. Fix imports for PropertyPath/TypeInformation (→ org.springframework.data.core), @PersistenceCreator, and QPageRequest.of(...).
  4. Introduce JSpecify @NullMarked package by package; run integration tests after each package to catch newly-rejected null arguments.
  5. Switch @Async repository methods from ListenableFuture to CompletableFuture.
  6. Once the baseline is green, continue to Article 2 for the query-engine regression pass, and to Article 3 if you use Specifications, custom EntityManager wiring, or native images.

Get the Code

The complete project used for every output above — four entities, two repositories, a @NullMarked package, seven passing behavior tests, and the A–G demo runner — lives in the companion repository, sdjpa4-demo. It boots H2 in memory, so there’s nothing to install beyond a JDK. The repo has since grown beyond this article too — check out corner-scenarios (or just use main) for additional real, tested corner cases (the refined Specification API, JpaSort.unsafe, and an @Embeddable value object).

git clone https://ankurm.com/git.app/asmhatre/sdjpa4-demo.git
cd sdjpa4-demo
git checkout article-1-baseline
./mvnw spring-boot:run   # prints the labelled A–G demonstrations
./mvnw test               # runs the 7 behavior tests (all green)

Built and verified on Eclipse Temurin JDK 21.0.11, Spring Boot 4.0.6, Spring Data JPA 4.0.6, Hibernate ORM 7.2, H2 in-memory. All console output shown above is copied from the actual run.

Sources

Continue the Series

Full guide: Spring Data JPA 3 to 4 Migration Guide (overview)
Next: Article 2 — Derived Queries Are Now JPQL

No Comments yet!

Leave a Reply

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