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. Only the parts outside the framework need preparing, and the example project shows each 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. The Scope of "No Reflection" covers the details. 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 keeps these classes by choice, because so much existing JDBC code looks exactly like this. Under native image, that choice costs 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 and Logback, which the plugin fetches from GraalVM’s shared repository. logback.xml is one of those resources, as Logging describes.

graalvmNative {
    binaries {
        main {
            imageName = 'flashcard'
            resources.includedPatterns.addAll(['schema\\.sql', 'public/.*', 'logback\\.xml'])
        }
    }
}

The spider-silk plugin supplies the rest of the block: --no-fallback, the mostly-static --static-nolibc the container section below relies on, and the metadata repository. By hand, that is buildArgs.add('--no-fallback'), buildArgs.add('--static-nolibc'), and metadataRepository { enabled = true } under the org.graalvm.buildtools.native plugin.

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 65MiB and logs its startup line roughly 55ms after the process launches, against roughly 1.2s for the same app on a JVM. Everything the example does runs in the binary with no reflection config written by hand: routing, jte templates, static files, sessions and flash, the JSON API, the OpenAPI export, and H2 over spring-jdbc with transactions. Every reflection config 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 enforces this: it fails the build rather than quietly emitting a binary that still needs a JVM.

Only the --dev template mode stays on the JVM, since hot-reloading a template means compiling it at runtime.

The binary in a container

-Pnative re-aims the Jib build that Deployment sets up at the binary. That build containerizes the app on a JRE base with no Docker daemon on the build machine. The switch ships in the spider-silk plugin, so the example’s build file configures nothing for it. Written out by hand, the switch is a conditional block over Jib’s native-image extension, which must be 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 imageName in the graalvmNative block.

The base image shrinks along with the process. The binary builds mostly static (--static-nolibc, a plugin default), with everything but glibc linked in, and distroless/base is little more than that glibc, CA certificates, and a filesystem skeleton. The image has no shell, no package manager, and no JVM, since the binary stopped needing a JVM at compile time. The mostly-static link makes that base sufficient: linked dynamically, the binary also needs libz, which distroless/base does not carry, so the container dies on startup.

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 34MiB against 119MiB for the JVM image. The price 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

A library that resolves classes by name at runtime can stop a native build. The framework will not. Prefer the code path that says what it means, such as a precompiled template or a hand-written mapper. 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

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

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

allDeclaredConstructors lets DataClassRowMapper find the record’s canonical constructor, and allDeclaredMethods 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 comparison was measured on an earlier version of the example, so both sizes are below the current binary’s 65MiB. 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, a missing entry fails at runtime rather than at build time. 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 example prevents a forgotten entry at build time: it derives 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 picks the file up, and so does everything built from it, jar and native image alike. The sweep is both the point and the caveat: every type in the package is opened to reflection, one source file per top-level type. The task therefore fits a package that holds nothing but the types meant for reflection. 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.