# 03 — A real reflection failure, its fix, and the trap inside the fix [← Previous: 02 — building the image](02-building-the-image.md) | [Back to README →](../README.md) | [Next: 04 — honest limits →](04-honest-limits.md) ## The trap [`ReportController`](../src/main/java/com/ankurm/graalvmdemo/report/ReportController.java) builds a class name at request time from a query parameter and instantiates it reflectively -- [`PlainTextReport`](../src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java) or [`JsonReport`](../src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java) -- the same shape as a plugin loaded by name from configuration, or a strategy resolved from a database column. Spring's AOT engine registers reflection metadata for everything it can see statically -- every `@RestController`, every bean -- but it has no way to know that a `format` query parameter can resolve to one of these two classes; nothing in the source connects them at build time. On the plain JVM, this works exactly as written. On the first native build (no hints added), hitting the endpoint fails outright: ``` REFLECTION-FAILED class=com.ankurm.graalvmdemo.report.PlainTextReport exception=java.lang.ClassNotFoundException message=com.ankurm.graalvmdemo.report.PlainTextReport ``` Full transcript: [docs/output/03-native-startup-broken.txt](output/03-native-startup-broken.txt). Note it is `ClassNotFoundException`, not merely a reflection-access error -- GraalVM's closed-world build excluded the class from the binary entirely, because nothing reachable from `main()` proved it was needed. ## The fix: the tracing agent, run against the plain jar `native-image-agent`, attached to a normal JVM run, watches real reflective calls and writes the same unified `reachability-metadata.json` format the AOT engine produces: ``` java -agentlib:native-image-agent=config-output-dir=agent-output -jar target/app.jar curl "localhost:8083/report?format=plain" curl "localhost:8083/report?format=json" ``` Full transcript: [docs/output/04-tracing-agent.txt](output/04-tracing-agent.txt). The agent adds real overhead to startup while attached -- 5.552 s here versus the 3.472 s baseline -- which is why it belongs in a one-off exploratory run or a dedicated test suite, not in production. ## The trap inside the fix Copying the agent's generated entries for `PlainTextReport` and `JsonReport` into `src/main/resources/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json` -- the project's own Maven coordinates, which felt like the obviously correct place -- silently did nothing. Rebuilding and re-testing still failed with the identical `ClassNotFoundException`. The cause, confirmed by diffing the two files byte-for-byte: Spring's own AOT engine writes its generated metadata to that *exact same path*, and whichever copy the build processes last wins. `target/classes/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json` turned out to be Spring's 188 KB generated file, identical to the one in `target/spring-aot/`, with the two hand-added entries nowhere in it. The fix is to put hand-written hints under a namespace that cannot collide with a real Maven coordinate -- this project uses [`src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json`](../src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json). GraalVM merges every `META-INF/native-image/**/reachability-metadata.json` it finds on the classpath regardless of what the intermediate folder names are -- they exist purely so different jars' metadata files don't collide with each other, which is exactly the property this fix needs. With that in place, the same two endpoints succeed: ``` OK via reflection on com.ankurm.graalvmdemo.report.PlainTextReport: REPORT: quarterly numbers OK via reflection on com.ankurm.graalvmdemo.report.JsonReport: {"report":"quarterly numbers"} ``` Full transcript: [docs/output/05-native-startup-fixed.txt](output/05-native-startup-fixed.txt). Startup and memory barely moved (0.104 s, ~105.7 MB RSS versus 0.091 s and ~105.5 MB without the fix) -- two extra reflectively-constructible classes are noise against the rest of the image. - Never hand-write hints at your own project's exact `groupId`/`artifactId` path -- verify with `diff` against `target/spring-aot/main/resources/...` if a hint you added seems to have no effect. - `@RegisterReflectionForBinding` on the calling code is the alternative to a hand-written or agent-generated JSON file, and does not have this collision risk since it feeds Spring's own AOT-generated file rather than a second one.