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-jpamodelgen → hibernate-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
ListenableFutureis gone.@Asyncquery methods must returnCompletableFuture. Spring Framework 7 removedListenableFuture, and Spring Data followed (release notes).- Jackson 3. Spring Data REST 5.0 moves to Jackson 3 (core package
tools.jackson.*; the annotations stay undercom.fasterxml.jacksonbut must be a 3.x version). If you expose repositories over REST and customize serialization, this is a separate migration. @PersistenceConstructor→@PersistenceCreatoron 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 thejakarta.persistenceannotation set are untouched by this release. - Existing
@QueryJPQL 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, andStreamreturn 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). @Transactionalsemantics, 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
@NullMarkedkeep their previous (unenforced, or@NonNullApi-enforced) null behavior — the JSpecify shift is opt-in per package, not global.
Migration Checklist
- Move the platform first: Spring Boot 4.0.x (pulls Framework 7, Jakarta EE 11, JPA 3.2, Hibernate 7). Confirm JDK 17+.
- Replace
hibernate-jpamodelgenwithhibernate-processoron the compiler processor path. - Fix imports for
PropertyPath/TypeInformation(→org.springframework.data.core),@PersistenceCreator, andQPageRequest.of(...). - Introduce JSpecify
@NullMarkedpackage by package; run integration tests after each package to catch newly-rejectednullarguments. - Switch
@Asyncrepository methods fromListenableFuturetoCompletableFuture. - Once the baseline is green, continue to Article 2 for the query-engine regression pass, and to Article 3 if you use Specifications, custom
EntityManagerwiring, 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
- Spring Data 2025.1 (4.0) Release Notes — spring-data-commons wiki (baseline, package relocations, ListenableFuture removal)
- Null Handling of Repository Methods — Spring Data JPA 4.0.6 reference (exact source of the runtime
IllegalArgumentExceptioncontract) · JSpecify documentation - Spring Boot 4.0.0 available now
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!