GraalVM Native Image

A framework without reflection is a framework native-image can see through: routing is a list, handlers are lambdas the closed-world analysis follows, and there is no reflection config to write for core. What needs preparing is the parts that are not the framework, and the example project shows all of them: gradle :example-flashcard:nativeCompile builds a self-contained binary serving the same app.

What the example prepares

Templates

A native binary carries no compiler, so jte’s default of compiling templates at runtime is not an option. The jte Gradle plugin’s generate() turns each template into a Java class at build time (Templates), and its NativeResourcesExtension writes the reflection config for those generated classes, which jte loads by name:

plugins {
    id 'gg.jte.gradle' version '3.2.4'
}

jte {
    sourceDirectory = file('src/main/resources/jte').toPath()
    contentType = gg.jte.ContentType.Html
    generate()
    jteExtension 'gg.jte.nativeimage.NativeResourcesExtension'
}

dependencies {
    jteGenerate 'gg.jte:jte-native-resources:3.2.4'
}

That config is the whole of jte’s reflection: the engine looks a generated class up by name and invokes its render method, while the expressions inside a template run as compiled code that never reflects over the model — see The Scope of "No Reflection". No model class, and no framework class, needs an entry.

Row mapping

The repositories map rows with spring-jdbc’s DataClassRowMapper and feed inserts through SimplePropertySqlParameterSource, both of which reach the domain records reflectively — the example’s own choice, kept because so much existing JDBC code looks exactly like this. What that choice costs under native image is a reflection config listing every domain type, and the build derives that list from the package (see below) rather than maintaining it by hand.

Resources and the driver

The rest is declared in the build: the classpath resources the app reads at runtime, and the reachability metadata for H2, which the plugin fetches from GraalVM’s shared repository.

plugins {
    id 'org.graalvm.buildtools.native' version '1.1.10'
}

graalvmNative {
    binaries {
        main {
            imageName = 'flashcard'
            buildArgs.add('--no-fallback')
            resources.includedPatterns.addAll(['schema\\.sql', 'public/.*'])
        }
    }
    metadataRepository {
        enabled = true
    }
}

Build and run

# needs a GraalVM JDK, e.g. `sdk install java 25.2.4-graalce`
gradle :example-flashcard:nativeCompile
example-flashcard/build/native/nativeCompile/flashcard

Built with GraalVM CE 25, the binary comes out at 61MB and Jetty reports itself started after roughly 35ms, against roughly 350ms for the same app on a JVM. Everything the example does — routing, jte templates, static files, sessions and flash, the JSON API, the OpenAPI export, H2 over spring-jdbc with transactions — runs in the binary with no reflection config written by hand: every entry in the image is generated, by the jte plugin for the template classes and by the build itself for the domain package, and not one of them names a framework class. --no-fallback keeps that honest, failing the build rather than quietly emitting a binary that still needs a JVM.

The --dev template mode is the one thing that stays on the JVM, since hot-reloading a template means compiling it at runtime.

The binary in a container

Deployment sets up Jib to containerize the app on a JRE base without a Docker daemon on the build machine; the binary is one file, and -Pnative re-aims that same build at it. The switch is a conditional in the example’s build, plus Jib’s native-image extension on the buildscript classpath:

buildscript {
    dependencies {
        classpath 'com.google.cloud.tools:jib-native-image-extension-gradle:0.1.0'
    }
}

if (providers.gradleProperty('native').present) {
    jib {
        from.image = 'gcr.io/distroless/base-debian12'
        to.image = 'ghcr.io/benelog/spider-silk-flashcard:native'
        pluginExtensions {
            pluginExtension {
                implementation = 'com.google.cloud.tools.jib.gradle.extension.nativeimage.JibNativeImageExtension'
                properties = [imageName: 'flashcard']
            }
        }
    }
}

The extension replaces the dependency and class layers Jib would assemble with a single layer holding the binary, sets its executable bit, and points the entrypoint at /app/flashcard; imageName tells it which file to take from build/native/nativeCompile and must match the graalvmNative block’s. The base image shrinks along with the process: native-image links glibc dynamically by default, and distroless/base is little more than glibc, CA certificates, and a filesystem skeleton — no shell, no package manager, and no JVM, since the JVM left at compile time.

gradle :example-flashcard:jibBuildTar -Pnative
# then, on a machine that runs containers:
docker load < example-flashcard/build/jib-image.tar
docker run -p 8080:8080 ghcr.io/benelog/spider-silk-flashcard:native

The tar comes out at 34MB against 124MB for the JVM image, and the trade is portability: Java bytecode runs on any architecture with a JRE image, while a native binary is compiled for one platform, so this image runs on the architecture that built it — there is no cross-compilation.

When a library reflects

The framework will not be what stops you; a library that resolves classes by name at runtime will. Prefer the code path that says what it means — a precompiled template, a hand-written mapper — and where a library must reflect, hand native-image its config: from the library’s own tooling like jte’s extension, from GraalVM’s metadata repository like H2’s, or maintained for your own types as below.

Keeping DataClassRowMapper, measured

What the example’s DataClassRowMapper and SimplePropertySqlParameterSource need is one file: a reflect-config.json on the classpath under META-INF/native-image/<any-directory>/, with an entry per domain type — written by hand, or generated the way the example’s build does.

[
  { "name": "flashcard.domain.Card",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "allDeclaredFields": true }
]

allDeclaredConstructors is what lets DataClassRowMapper find the record’s canonical constructor, and allDeclaredMethods is what lets SimplePropertySqlParameterSource read the accessors; nothing beyond the domain types themselves was needed. The example was built and exercised both ways on one machine, through every repository path in the binary:

Hand-written mappers Reflection + config

Image build time

65s, 82s

73s — within run-to-run variance

Jetty startup, median of 5

38ms

35ms — no measurable difference

Binary size

58.1MiB

60.9MiB (+4.8%)

The reflection metadata is baked in when the image is built, so boot pays nothing; the costs live elsewhere. Reflective mapping is slower per row at request time, and above all the failure mode moves: a record added without a config entry is not a compile error, and --no-fallback cannot catch it — the build succeeds and the first query against that record answers 500 in production. The tracing agent (-agentlib:native-image-agent=config-output-dir=…​) can write the config from a JVM run that clicks through the app, at the price of sweeping in everything else it saw.

Deriving the config from a package

The forgotten-entry failure has a build-time fix, and it is the one the example ships: derive the config from the package instead of maintaining it by hand. This task in example-flashcard’s build registers every type in flashcard.domain, so a record added to the package cannot be missed:

def domainReflectConfig = tasks.register('domainReflectConfig') {
    def domainDir = file('src/main/java/flashcard/domain')
    def outDir = layout.buildDirectory.dir('generated/reflect-config')
    inputs.dir(domainDir)
    outputs.dir(outDir)
    doLast {
        def entries = domainDir.listFiles()
                .findAll { it.name.endsWith('.java') }
                .collect { 'flashcard.domain.' + it.name.replace('.java', '') }
                .sort()
                .collect { ['name': it, 'allDeclaredConstructors': true,
                            'allDeclaredMethods': true, 'allDeclaredFields': true] }
        def json = outDir.get().file('META-INF/native-image/flashcard-domain/reflect-config.json').asFile
        json.parentFile.mkdirs()
        json.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(entries))
    }
}
sourceSets.main.resources.srcDir(domainReflectConfig)

srcDir(domainReflectConfig) adds the generated directory to the main resources and carries the task dependency with it, so processResources — and everything built from it, jar and native image alike — picks the file up. The sweep is the point and also the caveat: every type in the package is opened to reflection, one source file per top-level type, so it fits a package that holds nothing but the types meant for it. Both variants on this page were verified the same way: built with GraalVM CE 25 and exercised through every repository code path in the running binary.