Deep-dive companion #3 of 3 to the Spring Data JPA 3 to 4 Migration Guide. Spring Boot 4.0.6 · Spring Data JPA 4.0.6 · Hibernate ORM 7.2 · JDK 21.
By this point in the series your platform is on Spring Boot 4, your repository interfaces compile unchanged, and you’ve regression-tested the Criteria-to-JPQL rewrite. For most teams, that’s the whole migration. This article is for the rest: the biggest migration risks for advanced users are no longer repository method signatures — they are custom infrastructure and framework extension points. Specification-based bulk operations, hand-rolled EntityManager wiring, and Ahead-of-Time repository generation all changed in 4.0, and none of them show up if you only read the repository interface.
| # | 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 | Platform, repositories, nullability, compatibility |
| 2 | Query Engine Rewrite | Criteria → JPQL, provider behavior, sorting, query functions |
| 3 | Advanced Migration (this article) | Specifications, AOT, EntityManager wiring |
Companion code
The bulk-delete pattern, the full DeleteSpecification/UpdateSpecification/PredicateSpecification workflow, and the AOT configuration are all real, compiled, executed code in the companion repository, sdjpa4-demo: repo/AuthorRepository.java (deleteByCountry, and its JpaSpecificationExecutor<Author> base), repo/AuthorSpecifications.java (the Specification-family helpers), DemoRunner.java (demonstrations G–J), and pom.xml/application.properties for the build and AOT-adjacent configuration. The Specification-family demos were added after this article’s original tag — check out git checkout article-3-advanced for the code exactly as first quoted here, or git checkout corner-scenarios (or main) to run the newer Specification demos too.
The Refined Specification API
The classic Specification<T> you pass to JpaSpecificationExecutor still works for reads, unchanged for callers. What changed is that Spring Data stopped forcing every specification through CriteriaQuery, because delete and update use a different Criteria hierarchy (CriteriaDelete/CriteriaUpdate). 4.0 introduces purpose-built types (release notes: §Refined Specification API):
| Type | Use it for |
|---|---|
Specification<T> |
SELECT predicates (unchanged for callers) |
DeleteSpecification<T> |
bulk deletes over CriteriaDelete |
UpdateSpecification<T> |
bulk updates over CriteriaUpdate |
PredicateSpecification<T> |
reusable predicate fragments, query-type-agnostic |
Migration note
If you built bulk delete/update on top of Specification via workarounds, move them onto DeleteSpecification/UpdateSpecification — the workarounds relied on the very CriteriaQuery coupling that 4.0 removed. Plain read specifications need no change.
All Four Types, Verified in the Companion Repo
The four types above are real, compiled, and executed against the companion project — not hypothetical. The same PredicateSpecification instance is reused for both a read and a bulk delete; DeleteSpecification is used explicitly; UpdateSpecification composes an UpdateOperation (the SET clause over CriteriaUpdate) with a .where(...) predicate:
public static PredicateSpecification<Author> hasCountry(String country) {
return (root, cb) -> cb.equal(root.get("country"), country);
}
public static DeleteSpecification<Author> byCountryDelete(String country) {
return (root, delete, cb) -> cb.equal(root.get("country"), country);
}
public static UpdateSpecification<Author> relabelCountry(String from, String to) {
return UpdateSpecification
.<Author>update((root, update, cb) -> update.set(root.get("country"), to))
.where(hasCountry(from));
}
================ H. PredicateSpecification reused across read and delete ================
findAll(hasCountry("US")) -> [Joshua Bloch, Grady Booch, Robert Martin]
================ I. DeleteSpecification bulk delete ================
delete(byCountryDelete("GB")) removed -> 0 row(s)
delete(hasCountry("US").and(name="Grady Booch")) removed -> 1 row(s)
================ J. UpdateSpecification bulk update ================
update(relabelCountry("UK" -> "GB")) updated -> 1 row(s)
findAll(hasCountry("GB")) -> [Martin Fowler]
PredicateSpecification composes too
PredicateSpecification.and(...)/or(...) work exactly like Specification.and(...)/or(...) — the second delete above is hasCountry("US").and((root, cb) -> cb.equal(root.get("name"), "Grady Booch")), composed inline rather than as its own named helper.
Fluent Queries Without a Count Query
Fluent findBy(…) can now return a Slice without a count query. Previously a paginated fluent query always issued a count. Now you opt into the count only when you need it (and can pass an optimized count specification), which removes a needless SELECT count(*) from endpoints that only render “next page” navigation.
Lightweight ORDER BY Parsing With JpaSort.unsafe
A new ORDER BY parser translates simple path expressions, function calls, and CASE clauses into Criteria expressions when you combine JpaSort.unsafe(...) with a Specification. Anything more complex should still go straight to CriteriaQuery.
The Bulk-Operation Pattern That Still Works
For comparison with the new Specification types above, a derived deleteBy… method still works exactly as it did in 3.x, and still returns the affected count. This is real, compiled, executed code from the companion project:
@Modifying @Transactional
long deleteByCountry(@Nullable String country);
================ G. Derived delete returns count ================
deleteByCountry('DE') removed -> 1 row(s)
The Shared EntityManager Is No Longer Auto-Registered
Spring’s LocalContainerEntityManagerFactoryBean already creates a shared EntityManager, so Spring Data 4.0 stopped registering its own SharedEntityManagerCreator when the EntityManagerFactory comes from AbstractEntityManagerFactoryBean. It also deprecated — and no longer auto-registers — EntityManagerBeanDefinitionRegistrarPostProcessor.
Breaking for some setups
If you inject a bare @Autowired EntityManager (or @PersistenceContext onto a field expecting the Spring-registered shared bean) in code that doesn’t go through the standard Boot auto-configuration, that bean may no longer exist. Re-register EntityManagerBeanDefinitionRegistrarPostProcessor yourself, or provide a BiPredicate<String, BeanDefinition> to control which factories get a SharedEntityManagerCreator. Standard spring-boot-starter-data-jpa apps are unaffected; hand-rolled multi-datasource configs are the ones to check. Details: release notes §Reuse of shared EntityManager.
AOT Repository Generation Defaults to On — But Only Bites in AOT Builds
Spring Data JPA, JDBC, MongoDB and Cassandra now ship Ahead-of-Time repository support: query-method implementations are generated at build time instead of via reflection at runtime. Code generation for it is enabled by default whenever you run an AOT build — it does not silently switch on for an ordinary JVM run of an ordinary application. The upside, once you do run in AOT mode, is faster startup, lower memory, native-image friendliness, and — usefully — you can now step into a generated query method in a debugger. Background: release notes §Ahead Of Time Repositories. The knobs:
# turn AOT repositories off entirely
spring.aot.repositories.enabled=false
# or per module
spring.aot.jpa.repositories.enabled=false
Note
AOT-generated repository fragments are only used when you actually run in AOT mode (native image, or spring-boot:process-aot). Ordinary ./mvnw spring-boot:run during development still uses the runtime path, so behavior is identical — but if you build a native image, this is the mechanism generating your query methods. See also: Spring Data Ahead of Time Repositories — Spring blog.
Advanced Migration Checklist
- Move any
Specification-based bulk delete/update ontoDeleteSpecification/UpdateSpecification. - Audit fluent
findBy(…)pagination endpoints — decide where you actually need a count query versus a plainSlice. - Check hand-rolled
EntityManagerinjection in non-standard, multi-datasource configs against the shared-EntityManagerchange above. - If you build a native image, confirm which query methods are eligible for AOT repository generation and review the
spring.aot.jpa.repositories.*knobs. - Re-run integration tests for anything touching custom
RepositoryFactoryor Querydsl integrations (see Article 1’s repository-internals section) alongside these infrastructure changes.
Get the Code
The bulk-delete demonstration above comes from the same companion project used across this series, sdjpa4-demo. It boots H2 in memory, so there’s nothing to install beyond a JDK. For the newest additions — an @Embeddable value object with derived-path queries and record projections, plus a real corner case where a derived-query projection fails to resolve a nested path — check out corner-scenarios (or just use main).
git clone https://ankurm.com/git.app/asmhatre/sdjpa4-demo.git
cd sdjpa4-demo
git checkout article-3-advanced
./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 (Specification API, EntityManager reuse, AOT repositories)
- Ahead of Time Optimizations — Spring Data JPA 4.0.6 reference
- Spring Data Ahead of Time Repositories — Spring blog
Continue the Series
Full guide: Spring Data JPA 3 to 4 Migration Guide (overview)
Previous: Article 2 — Derived Queries Are Now JPQL
Start from the beginning: Article 1 — Migration Baseline
No Comments yet!