Record a JVM AOT cache (JEP 483 / JEP 514) from your Spring integration tests, and bundle it into a container image so your application starts faster.
This library revives the proposal from spring-framework#36774 (a feature request the Spring team declined) and maintains it as a standalone, community project.
The Spring team's preference is the existing Buildpacks / Dockerfile training run. This project is for teams who want to reuse their integration-test workload as the training run, which is attractive because it already exercises realistic application code paths.
If that fits your workflow, this library gives you:
- a single switch to record a cache from your integration tests;
- automatic reuse of the cache in container images via the
Paketo Spring Boot buildpack
(
aot-cache/application.aot).
Like Quarkus (@QuarkusIntegrationTest), the training run boots your packaged
application in its own JVM (optionally inside a target container image) and drives it with
black-box HTTP tests. Recording against the packaged application's own class path is the only
way to produce a cache that application can load, so it is the only training mode this
project provides.
An AOT cache only loads against the exact JVM build, architecture and class path it was
recorded with. The class path of a packaged Spring Boot application (runner.jar plus
lib/) is shorter than a test class path, and the build host's JVM usually differs from
the runtime JVM. A cache recorded by an ordinary @SpringBootTest run therefore can never be
loaded by the packaged application.
This library sidesteps that by starting the packaged application in its own JVM with
-XX:AOTCacheOutput, so the recorded class path is the runtime class path. The integration
tests run as an external client and talk to the application over HTTP.
Note: a cache also must be recorded and consumed with the same JVM distribution, OS, architecture, class path contents, and many JVM flags. The JVM rejects a cache that does not match, so mismatches cost the optimization rather than correctness. Run the training run and the application on the same JDK distribution.
| Module | Purpose |
|---|---|
aot-cache-training |
AotCache helpers and JUnitPlatformVersion used by the build plugins. |
aot-cache-training-trainer |
OutOfProcessTrainingLauncher, the entry point that starts the packaged application and runs the tests as an HTTP client. |
aot-cache-training-maven-plugin |
Maven record and verify goals. |
aot-cache-training-gradle-plugin |
Gradle aotCacheTraining and verifyAotCache tasks. |
The minimal setup is a single switch. The plugin builds your boot JAR, starts it in its own
JVM, drives it with your tests, and records build/aot-cache/application.aot.
Kotlin DSL (build.gradle.kts):
plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.github.vpelikh.aot-cache-training")
}
aotCacheTraining {
enabled = true
}Groovy DSL (build.gradle):
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.github.vpelikh.aot-cache-training'
}
aotCacheTraining {
enabled = true
}The same single switch, plus the two goals. record needs the repackaged application JAR,
so it binds to the verify phase (after package) by default:
<plugin>
<groupId>io.github.vpelikh</groupId>
<artifactId>aot-cache-training-maven-plugin</artifactId>
<version>0.1.0</version>
<executions>
<execution>
<id>aot-record</id>
<goals><goal>record</goal></goals>
</execution>
<execution>
<id>aot-verify</id>
<goals><goal>verify</goal></goals>
</execution>
</executions>
</plugin>This records target/aot-cache/application.aot. The plugin locates the repackaged
application JAR automatically (or takes one with applicationJar).
record is off by default; enable it with -Daot.cache.record=true or <enabled>true</enabled>.
The optional settings below apply here too, set through <configuration> (for example
<containerImage>). No plugin-level dependencies are needed, and the plugin never modifies
your test class path.
The training workload is your black-box integration tests. They read the running
application's base URL from the aot.training.url system property and drive it over HTTP:
class GreetingHttpTests {
@Test
void greets() throws Exception {
String baseUrl = System.getProperty("aot.training.url");
// ... call the application over HTTP with java.net.http.HttpClient ...
}
}Run the training:
./gradlew aotCacheTraining verifyAotCache # Gradle
mvn verify -Daot.cache.record=true # MavenBoth build tools share the same optional settings; the ones you are most likely to touch:
| Setting | Default | Purpose |
|---|---|---|
readyUrl |
http://localhost:8080/ |
URL polled until the application is ready. |
containerImage |
unset | Record inside this image's JVM instead of the local one. Only needed when the runtime image's JVM build or architecture differs from your build host. |
packagesToScan |
unset | Limit the training workload to specific packages. |
failOnTestFailure |
true |
Fail the training run when a test fails. |
allowEmptyWorkload |
false |
Record even when no tests are discovered. |
startTimeout |
120 |
Seconds to wait for the application to become ready. |
containerImage is optional. Leave it unset when the runtime JVM matches your build host:
the packaged application then records against the local JVM, and the cache loads wherever
that same JVM build and architecture run.
Set it when the cache must be loaded by an image whose JVM differs from the build host, a
different JDK distribution or version, or a different CPU architecture. This is the common
case: building on macOS arm64 and deploying a linux/amd64 image produces a cache the image
cannot load. Recording inside the image fixes it, because an AOT cache only loads against the
exact JVM build, architecture and class path it was recorded with:
aotCacheTraining {
enabled = true
containerImage = "my-app:latest" // the image the cache must match
}./gradlew aotCacheTrainingWith Maven, add the same setting to the record goal's configuration:
<configuration>
<!-- Record inside the image so the cache matches that image's JVM build. -->
<containerImage>my-app:latest</containerImage>
</configuration>Then build the image. Both plugins wire this up for you with no manual jar step: they
embed the recorded aot-cache/application.aot into the packaged application JAR, where the
buildpack looks for it.
./gradlew bootBuildImage # Gradle
mvn verify spring-boot:build-image-no-fork -Daot.cache.record=true # MavenThe Paketo Spring Boot buildpack finds aot-cache/application.aot in the application
content, skips its own training run, and loads the cache at startup with -XX:AOTCache.
- Gradle points
bootBuildImageat a cache-embedded copy of the boot JAR automatically. - Maven embeds the cache during the
recordgoal into both the repackaged application JAR and itstarget/<finalName>.jar.originalbackup, becausespring-boot:build-imagere-lays-out the application from that backup (<embedInApplicationJar>false</embedInApplicationJar>to opt out). Usebuild-image-no-fork, notbuild-image: the forkingbuild-imagegoal rerunspackageand would overwrite the embedded JAR.
Pre-recorded cache support needs Paketo Spring Boot buildpack 5.39.0 or later, and the
bootBuildImagebuilder must bundle it.
The training workload must be black-box tests that call the application over HTTP. Plain
@SpringBootTesttests run in-process and cannot drive the packaged application.
- Package the application. The plugin builds the Spring Boot boot JAR (
bootJarfor Gradle,packagefor Maven) so the application has a packaged class path. - Run the training workload.
OutOfProcessTrainingLauncherextracts the boot JAR torunner.jarpluslib/, starts the application in its own JVM (optionally insidecontainerImage) with-XX:AOTCacheOutput=<build>/aot-cache/application.aot, waits forreadyUrl, and runs your tests as an external HTTP client. The application's own class path is what gets recorded, so the cache matches what the image will load. - Assemble the cache. The application JVM assembles the final cache on clean exit, after shutdown hooks run.
- Verify. The
verifygoal / task fails the build when recording was requested but no non-empty cache was produced. A training run that discovers no tests also fails by default, because an empty workload records a large but useless cache.
The plugin resolves the test launcher at your project's own JUnit Platform version and
isolates it to the client-test JVM. The version is read from your resolved test dependencies
(falling back to JAR names). It never adds JUnit (or anything else) to your test class path,
so your dependency tree and your normal test task are untouched.
The training run needs a JDK 25+ JVM. Gradle uses the project's Java toolchain (defaulting
to 25) for the training task; Maven uses the JVM running Maven. Both fail fast with an
actionable message when the training JVM is too old. For Maven, point the plugin at another
JVM with -Daot.cache.trainingJvm=/path/to/java or the <trainingJvm> configuration.
The JVM assembles the cache after shutdown hooks have run. A shutdown-hook based check
would therefore always report a missing cache, so verification lives in the build tooling
and runs after the training JVM exits (AotCache.verifyRecordedCache).
- JDK 25+ to record (single-step
-XX:AOTCacheOutput, JEP 514). - JDK 24+ to load a pre-recorded cache (
-XX:AOTCache, JEP 483). - A packaged Spring Boot application (
bootJar/mvn package) with an HTTP health or readiness endpoint the tests can poll, plus black-box tests that drive it over HTTP. - Built against JUnit Platform 6 with JUnit Platform 5 runtime compatibility, and written for the JDK 25 toolchain the AOT cache feature requires.
./gradlew buildThe Maven integration tests publish all modules to build/local-repo and run a real Maven
build against it.
Publishing is driven by the release workflow, run manually from the Actions tab
(Run workflow) with an optional version, defaulting to the one in gradle.properties. It:
- builds and verifies the project,
- publishes every module to Maven Central via the Sonatype Central Portal (the deployment is released automatically, no manual step),
- publishes the Gradle plugin to the Gradle Plugin Portal,
- creates the
vX.Y.Ztag and a GitHub release. Its notes start from GitHub's pull-request list and then append any commits that were pushed directly, so a range that mixes pull requests and direct commits lists both, and - bumps the patch version in
gradle.propertiesand pushes it, so the next release is ready.
The workflow reads these repository secrets:
| Secret | Purpose |
|---|---|
CENTRAL_PORTAL_USERNAME / CENTRAL_PORTAL_PASSWORD |
Sonatype Central Portal user token. |
SIGNING_KEY |
ASCII-armored GPG private key used to sign the Maven Central artifacts. |
SIGNING_PASSWORD |
Passphrase for SIGNING_KEY. |
GRADLE_PUBLISH_KEY / GRADLE_PUBLISH_SECRET |
Gradle Plugin Portal API key. |
Apache License, Version 2.0.