Skip to main content

Spring Data JPA 4 Advanced Migration: Specifications, AOT Repositories, and EntityManager Wiring

The refined Specification API, AOT repository generation, and shared EntityManager wiring are the parts of the Spring Data JPA 3 to 4 migration that don’t show up in a repository interface. Part 3 of the migration series covers DeleteSpecification/UpdateSpecification, JpaSort.unsafe, AOT knobs, and native-image implications.

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

  1. Move any Specification-based bulk delete/update onto DeleteSpecification/UpdateSpecification.
  2. Audit fluent findBy(…) pagination endpoints — decide where you actually need a count query versus a plain Slice.
  3. Check hand-rolled EntityManager injection in non-standard, multi-datasource configs against the shared-EntityManager change above.
  4. If you build a native image, confirm which query methods are eligible for AOT repository generation and review the spring.aot.jpa.repositories.* knobs.
  5. Re-run integration tests for anything touching custom RepositoryFactory or 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

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!

Leave a Reply

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