I've used Spring Data for almost a decade. Repositories have always felt like the magical part of Spring Boot: write an interface with method names, get a working implementation at runtime through proxies and reflection. It works, but it's slow at startup, opaque when something breaks, and famously hostile to GraalVM native image compilation.
Spring Boot 4 changed that quietly. AOT Data Repositories generate the implementation at compile time. The repository proxy you got at runtime is now a real Java class on disk, written to source by the build, compiled with the rest of your code. Faster startup. Native images that work. Stack traces you can actually read.
I haven't seen many people talking about this, even though it's one of the more important changes in the Spring Boot 4 generation. This post covers what it does, how to turn it on, and what to know before you commit to it.
What are Spring Boot 4 AOT data repositories?
Spring Boot 4 AOT data repositories are Spring Data repository implementations generated at build time, replacing the reflective runtime proxies that Spring Data has historically used.

When you write this:
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByEmailContaining(String fragment);
Optional<User> findByEmailIgnoreCase(String email);
long countByActiveTrue();
}The traditional Spring Data flow at startup:
- Scan your classpath for repository interfaces.
- For each method, parse the name into a query.
- Derive the JPQL or SQL.
- Generate a CGLIB or JDK proxy that intercepts every call.
- Inject the proxy as a Spring bean.
All of that happens inside applicationContext.refresh(), every time the app starts.
With AOT repositories, steps 1 through 3 happen during the Maven or Gradle build. Step 4 generates a real class file named UserRepositoryImpl__AotRepository in the same package as your interface. Step 5 still happens at startup, but the bean it wires up is the pre-generated class, not a runtime proxy.
The startup that took 2.1 seconds because Hibernate, Spring Data, and validation all kicked in together is now closer to 1.4 seconds, in my testing. And the heap it occupied has shrunk because the proxy bookkeeping is gone.
Why does compile-time generation matter?
Compile-time generation matters because it converts a category of work that scales with application size from "runtime cost on every startup" to "build cost paid once."
The motivation isn't just speed. It's also correctness and visibility.
The old runtime proxy model has three properties that hurt at scale. First, it's reflective, which means GraalVM native image needs explicit hints for every reflective call. Spring Boot has shipped those hints incrementally over years, but it's a moving target. Second, the generated SQL is invisible at code-review time. You can run the app and inspect the logs, but you can't grep for it. Third, when a method name doesn't parse cleanly, the failure shows up at startup, not at compile time. I've shipped a regression where a typo in a derived method name (findByEmailIs vs findByEmailEqualTo) only surfaced in the dev environment because it didn't break the build.
AOT repositories address all three. The reflection is gone (or at least dramatically reduced). The generated SQL is real Java code in target/generated-sources/aot/ that you can read. And invalid method names break the build, not the application.
The Spring Data team's framing for it: "Generated query methods contain the exact same code you would write if you would not use Spring Data to run your query." That's not a stretch. The output is straightforward Java that calls into EntityManager (for JPA) or executes a JdbcTemplate query (for JDBC). No more wondering what the proxy did under the covers.
How do you enable AOT repositories?
AOT repositories are enabled by default when the Spring AOT engine is active. Two ways to activate the AOT engine:
Maven build with AOT goal:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>After running mvn package, the generated AOT sources sit under target/spring-aot/main/sources/. Open one and you'll see the actual method bodies for your repository.
Gradle build:
plugins {
id("org.springframework.boot")
id("org.graalvm.buildtools.native") // only if you want native image
}
springBoot {
mainClass.set("com.example.Application")
}Running ./gradlew bootJar triggers the AOT pipeline.
Native image:
./mvnw -Pnative native:compileOr with Gradle:
./gradlew nativeCompileIn native mode, AOT repositories aren't optional. They're how the build avoids the reflective code paths that GraalVM can't analyze.
To turn AOT repositories off (for testing, or to compare behavior):
spring.aot.repositories.enabled=falseTo disable for a specific module:
spring.aot.jdbc.repositories.enabled=false
spring.aot.jpa.repositories.enabled=false
spring.aot.mongodb.repositories.enabled=false
spring.aot.cassandra.repositories.enabled=falseIn normal JVM mode without the AOT goal, your app keeps using the runtime proxies. Nothing forces you onto the AOT path until you opt in via the build plugin.
What gets generated at build time?
The build generates a Java class for each imperative repository interface, named <RepositoryName>Impl__AotRepository, in the same package as the interface.
For the UserRepository example above, the generated class is UserRepositoryImpl__AotRepository. Each query method becomes a real Java method that calls into the persistence layer directly.
For Spring Data JPA, the generated method for findByEmailContaining looks roughly like this (simplified for clarity):
public List<User> findByEmailContaining(String fragment) {
String jpql = "SELECT u FROM User u WHERE u.email LIKE ?1";
return entityManager
.createQuery(jpql, User.class)
.setParameter(1, "%" + fragment + "%")
.getResultList();
}For Spring Data JDBC, the equivalent generates a SQL string with parameterized binding. For MongoDB, it generates the document filter. For Cassandra, the CQL statement.
The generated code is technology-specific, which is the point. There's no abstraction layer at runtime, no proxy interception, no reflective Method.invoke. You get the same JPQL or SQL you'd write by hand.
What it does NOT generate: anything for reactive repositories. The build skips ReactiveCrudRepository and friends silently. Anything for @Query-annotated methods that use SpEL expressions evaluated at runtime. Anything for repository fragments backed by custom implementations (your UserRepositoryCustom impl class is left alone).
The metadata side of generation is also useful: Spring Data writes JSON files alongside the generated classes containing every query method's derived SQL. These are the same metadata Spring uses to wire native image runtime hints. If you want to audit every query your app issues without running it, those JSON files are the cleanest source.
A practical thing I do with the JSON metadata: feed it into a script that diffs the queries between two builds. Adding a method to a repository or renaming an existing one shows up as a clean diff in CI. This catches a category of accidental N+1 queries that I used to find only in production. The build now tells me when a derived query suddenly fans out into a per-row lookup instead of a single join. It's the kind of tooling I always wanted but never had a reasonable place to plug in. With AOT generation putting the SQL on disk, that integration point exists by default.
How do they work with GraalVM native images?
Native images are the headline use case. AOT repositories make Spring Data viable in GraalVM native compilation by removing the reflection that native image can't follow.
GraalVM native image needs to know at build time what classes will be reflected on, what methods will be called dynamically, and what proxies will be created. Spring's traditional Data infrastructure relied heavily on all three. Even with reachability metadata files, edge cases would slip through and you'd hit MissingReflectionRegistrationError at runtime in the native binary.
With AOT repositories, the reflective machinery doesn't exist in the compiled binary. The repository is a regular class. The only reflection left is what Hibernate or the JDBC driver does for entity introspection, and Spring Boot ships proper hints for that.
I migrated a Spring Boot 3.x service to native image last year, and the longest single chunk of effort was tracking down the Spring Data reflection hints. Having tested the same migration on Spring Boot 4 with AOT repositories, the failure modes that took me days to debug are mostly gone. There are still a few corner cases (custom converters, audit listeners with reflection-based wiring), but the repository layer itself is no longer the rough part.
The startup numbers in native mode are dramatic. A native image that starts in 80ms vs a JVM application that starts in 1.5 seconds is the kind of win that changes how you think about scale-to-zero deployments. AOT repositories aren't the only reason that gap exists, but they're a significant chunk of it.
One specific optimization worth knowing: Spring Data uses ManagedTypes to enumerate the entity set at build time, because classpath scanning isn't available in native mode. As long as your entities are detected by the build (they should be if they're annotated with @Entity and on the classpath), the AOT pipeline collects them automatically. No manual entity registration.
What are the tradeoffs and limitations?
The tradeoffs are real and worth knowing before you commit.
Configuration is frozen at build time. The Spring Data team's exact words: the framework "trades certain dynamic aspects" for the speed. You can't generate database-specific SQL and swap databases without rebuilding. If you have an app that runs on Postgres in prod and H2 in tests with different schemas, the AOT-generated SQL is targeted to one of them.
Reactive repositories aren't supported. If your stack is built on ReactiveMongoRepository or similar, AOT generation skips it entirely. The runtime proxy is still in use for those. Spring Data 2025.1 explicitly limits AOT to imperative interfaces. The team has signaled future work on reactive support but no timeline.
Build time goes up. AOT processing isn't free. On a service with 40-odd repository interfaces, the AOT build phase added about 18 seconds to a previously 35-second build. Not catastrophic, but noticeable, and it scales with repository count. If you've got a monolith with hundreds of repositories, plan for a longer CI.
Generated SQL can surprise you. The AOT pipeline derives SQL from method names, the same as the runtime proxy did. The difference is that with runtime proxies, you found out the SQL was wrong by reading logs. With AOT, you find out at build time when the metadata pipeline rejects an ambiguous method name. Most teams find this an upgrade. Some won't.
Custom @Query SpEL expressions break. If you've leaned heavily on @Query("...?#{principal.id}...") style queries, those don't AOT-compile cleanly. The SpEL evaluation needs runtime context. The build will skip generation for those methods and fall back to the runtime path for them, which mostly works but means the performance benefit is partial.
Debugging is different. The good news: you can read the generated source and step into it during a debug session. The bad news: stack traces now point to generated class names, which can confuse tooling that doesn't recognize them. IDEs handle this fine, but some external log aggregators may need configuration.
When should you use AOT repositories?
Three cases where I'd turn them on without hesitation.
If you're targeting GraalVM native image. AOT repositories are the path. Don't fight Spring Data's reflection on native; use the AOT pipeline that Spring Boot 4 ships for exactly this case.
If startup time is on your scorecard. Lambda cold starts, Kubernetes pod scaling, anywhere you're paying for the time between "process started" and "ready to serve traffic." Imperative Spring Data + AOT cuts a big chunk.
If you maintain a service with stable schemas. The "frozen at build time" tradeoff isn't a tradeoff if you're not switching databases or schemas at runtime, which describes 95% of services I've worked on.
Two cases where I'd hold off.
If you're heavily reactive. No point. Wait for reactive AOT support.
If your team relies on @Query SpEL expressions everywhere. The fallback is fine, but you won't see the full benefit, and the mixed mode (some methods AOT, some not) adds cognitive load.
The one decision I'd push every Spring Boot 4 project to make explicitly: turn on AOT in your build pipeline. Even if you're not deploying as native image, the build-time validation of repository method names is worth it. A typo that breaks startup in dev is fine. A typo that breaks startup in prod after a release is a problem you'd rather catch in CI.
The Spring Data team has shipped this feature quietly. It deserves more attention. If you're on Spring Boot 4 and haven't enabled it, do that this sprint.
For the official details, see Spring's blog post on AOT repositories part 2 and the Spring Data JPA AOT optimization docs. The Spring Data Commons AOT reference covers the cross-module pieces that apply regardless of which store you're using.
Keep Reading
- Modern Java with Spring Boot. Background on the Java 21+ features Spring Boot 4 builds on, and how virtual threads pair with AOT for fast-start services.
- Spring Boot Testcontainers Guide. Real-database testing patterns that pair well with AOT repositories. AOT validates the SQL at build time, Testcontainers proves it works against the real engine.
- Hibernate Lazy Init Guide. What Hibernate is doing behind your repositories. The AOT pipeline doesn't change Hibernate's lazy loading semantics, but understanding them is still essential.
- Spring Security Component Revolution. The other major Spring Boot 4 architectural change worth knowing alongside AOT repositories.
