§ learn

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:

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.ekbatan
dependencies {
    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 nativeCompile

Output: build/native/nativeCompile/<app-name> (a single ELF/Mach-O executable, no JVM needed). ~2-4 minutes on a modern laptop.

./mvnw -Pnative native:compile

Output: target/<app-name> (single executable).

./gradlew build -Dquarkus.native.enabled=true

Output: build/<app-name>-runner (single executable).

./mvnw package -Dnative

Output: target/<app-name>-runner.

./gradlew nativeCompile

Output: build/native/nativeCompile/<app-name>.

./mvnw -Pnative native:compile
./gradlew nativeCompile
./mvnw -Pnative native:compile

4. 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.00

Hit 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 test

The 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

Where this can bite

See also

🎉 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.