Compiling to a native binary
Take everything from the previous lessons and produce a single statically-linked binary with GraalVM. ~50ms startup, ~50MB image, no JVM required at runtime. The framework's substrate-VM bridges handle Flyway resource scanning and Jackson 3 record reflection for you.
A native image is GraalVM’s ahead-of-time compilation of your whole JVM application — domain code, framework, JDK — into one ELF/Mach-O binary. The result: ~50ms cold start (vs. ~2-5s on the JVM), ~50MB image (vs. ~250MB JVM + jar), zero warm-up. Trade-off: no dynamic classloading, no runtime reflection without metadata, and a slower build (~2-4 minutes).
This lesson assumes you’ve completed Getting started and Your first Action. The wallet runs on the JVM. Now we’ll compile it to native.
1. Install GraalVM
sdk install java 25-graal
sdk use java 25-graal
java -version # → 25 + GraalVM
(SDKMAN! is the simplest path on macOS / Linux. If you don’t use it, grab the tarball from oracle.com/graalvm.)
2. Add the native-bridge dependency
ekbatan-native ships:
- A Flyway
ResourceProviderthat walks theresource:/NIO filesystem on substrate VM (Flyway’s default classpath scanner fails on native — see Reference → Native image for the why) - Jackson 3 record reflection metadata for the framework’s event-envelope DTOs
- A vendored HikariCP
ResourceManagementRequiredconfig so the connection pool works on substrate VM
Add it to every native-target subproject:
// build.gradle.kts
dependencies {
implementation("io.github.zyraz-io:ekbatan-native:0.1.0")
}
plugins {
id("org.graalvm.buildtools.native") version "0.10.3"
}
graalvmNative {
binaries {
named("main") {
buildArgs.add("-Dio.ekbatan.graalvm.scan.packages=io.example,io.ekbatan")
}
}
}<dependency>
<groupId>io.github.zyraz-io</groupId>
<artifactId>ekbatan-native</artifactId>
<version>0.1.0</version>
</dependency>
<!-- in <plugins>: -->
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.3</version>
<configuration>
<buildArgs>
<buildArg>-Dio.ekbatan.graalvm.scan.packages=io.example,io.ekbatan</buildArg>
</buildArgs>
</configuration>
</plugin>// build.gradle.kts
dependencies {
implementation("io.github.zyraz-io:ekbatan-native:0.1.0")
}The Quarkus Gradle plugin auto-detects native builds. Pass the scan-packages flag via Quarkus config:
# application.properties
quarkus.native.additional-build-args=-Dio.ekbatan.graalvm.scan.packages=io.example,io.ekbatan<dependency>
<groupId>io.github.zyraz-io</groupId>
<artifactId>ekbatan-native</artifactId>
<version>0.1.0</version>
</dependency>Plus in application.properties:
quarkus.native.additional-build-args=-Dio.ekbatan.graalvm.scan.packages=io.example,io.ekbatandependencies {
implementation("io.github.zyraz-io:ekbatan-native:0.1.0")
}The Micronaut Gradle/Maven plugin handles native builds. The io.ekbatan.graalvm.scan.packages system property goes into nativeImage { buildArgs } (Gradle) or <buildArgs> (Maven). See ekbatan-examples/micronaut-wallet-rest-gradle-native-pg for the full block.
dependencies {
implementation("io.github.zyraz-io:ekbatan-native:0.1.0")
}
plugins {
id("org.graalvm.buildtools.native") version "0.10.3"
}
graalvmNative {
binaries {
named("main") {
mainClass.set("io.example.Main")
buildArgs.add("-Dio.ekbatan.graalvm.scan.packages=io.example,io.ekbatan")
}
}
}The io.ekbatan.graalvm.scan.packages value is the comma-separated list of root packages the framework’s substrate-VM bridges should scan for @EkbatanAction, @EkbatanRepository, @EkbatanEventHandler, @EkbatanDistributedJob, and @AutoBuilder-generated builder classes. Include your own root package; the framework’s own packages are added automatically.
3. Build the native binary
./gradlew nativeCompileOutput: build/native/nativeCompile/<app-name> (a single ELF/Mach-O executable, no JVM needed). ~2-4 minutes on a modern laptop.
./mvnw -Pnative native:compileOutput: target/<app-name> (single executable).
./gradlew build -Dquarkus.native.enabled=trueOutput: build/<app-name>-runner (single executable).
./mvnw package -DnativeOutput: target/<app-name>-runner.
./gradlew nativeCompileOutput: build/native/nativeCompile/<app-name>.
./mvnw -Pnative native:compile./gradlew nativeCompile./mvnw -Pnative native:compile4. Run it
./build/native/nativeCompile/wallet
# Started Application in 0.052s (process running for 0.057s)./target/wallet./build/wallet-runner # Gradle
# OR
./target/wallet-runner # Maven./build/native/nativeCompile/wallet./build/native/nativeCompile/wallet <walletId> 10.00Hit the deposit endpoint exactly as in Your first Action — the behavior is identical to the JVM build. Same wallet code, same outbox writes, same event handlers. The binary just happens to be a single 50MB file with no JVM dependency.
5. Run native tests
The ekbatan-integration-tests framework’s own native test setup is a good template. With GraalVM Build Tools’ nativeTest (Gradle) or Quarkus’s testNative:
./gradlew nativeTest./mvnw -Pnative test./gradlew testNative./mvnw test -Pnative./gradlew nativeTest./mvnw -Pnative test./gradlew nativeTest./mvnw -Pnative testThe whole Testcontainers-driven integration suite runs against the compiled binary. If something breaks on native that worked on the JVM, this is where you’ll see it — usually a missing reflection metadata entry that needs adding to the framework’s bridges (or to your own reflect-config.json).
What just happened
- No JVM at runtime. The binary is statically linked; deploy it to a
FROM scratchDocker image and ship a 50MB container. - No application-code changes. The same
Wallet,WalletDepositMoneyAction,WalletControlleryou wrote in earlier lessons compile to native unchanged. - The framework handled the substrate-VM hard parts — Flyway resource scanning, Jackson 3 record reflection metadata, HikariCP pool startup. You added one dependency (
ekbatan-native) and one build arg (-Dio.ekbatan.graalvm.scan.packages=...). - Cold start drops from ~3s to ~50ms. Serverless and on-demand workloads stop paying the JVM warm-up tax.
Where this can bite
- Build time is much longer than the JVM build (2-4 min vs 10-15s). Save it for
releaseandnativeTestworkflows; iterate against the JVM during development. - No runtime reflection without metadata. If you add a library that uses reflection (e.g. a JSON mapper that isn’t covered by Jackson 3’s record support), you may need to add
reflect-config.jsonentries. The native-image agent (-agentlib:native-image-agent=...) records them automatically during a JVM run. - Some Java features don’t work on substrate VM —
Thread.holdsLockintrospection, dynamic proxies for unknown interfaces,MethodHandles.privateLookupInacross modules. These rarely come up in Ekbatan-style apps but check the Reference → Native image page if a deep library fails to start.
See also
- Reference → Native image — substrate-VM bridges,
FlywayHelper, scan packages, troubleshooting ekbatan-examples/*-native-*— 12 runnable native projects (4 stacks × 3 dialects), each with full integration tests against the compiled binary- GraalVM Build Tools docs — the official
nativeCompile/nativeTestplugin reference - Quarkus native guide — Quarkus-specific notes if you’re on that stack
🎉 You’ve finished the Learn track. From a single implementation(...) line you now have a sharded, native-compiled, outbox-native, event-driven service. From here: pick what you actually need from the Reference and ignore the rest — most production services use ~30% of the framework’s surface.