What are the key correctness gotchas when authoring AOT processors — build-time execution, determinism, the excluded-bean rule, and native-only failures?
answer
- Build-time = deterministic, no runtime state
- isBeanExcludedFromAotProcessing() default true
- Missing hint = JVM passes, native fails
- AOT path opt-in → test nativeTest
- Order-independent, null-safe, exceptions abort build
basics
~20 sAOT processors run at build time, so their logic must be deterministic and independent of runtime state. Processor beans are excluded from the runtime context by default. Missing RuntimeHints pass on the JVM but fail only in the native image, so hints must match every reflective call in the generated code.
solid answer
~50 sFour principal-level pitfalls. First, processors execute during the build-time AOT pass (processAot task), not at runtime; anything they compute is frozen into generated code, so the logic must be deterministic — no clocks, randomness, network, or environment that differs between build and run. Second, a bean implementing BeanRegistrationAotProcessor is by default excluded from the optimized runtime context (isBeanExcludedFromAotProcessing() returns true) because it is build-time infrastructure. Third, hint completeness is safety-critical: reflection works unconditionally on the JVM, so a missing hint only surfaces as a runtime failure inside the native image — you must register a hint for every reflective/resource/proxy access the generated code performs. Fourth, AOT-optimized runtime is opt-in (spring.aot.enabled / native profile), so the generated path can diverge from the classic path; test both. Also handle null returns and avoid ordering assumptions between processors.
go deeper
Know processors run at build time and that native images need hints for reflection.
Know determinism matters, hints must match reflective calls, and the excluded-bean default.
Can explain the JVM-passes/native-fails failure mode and why to run native tests, plus null-safety.
Reasons about processor commutativity, reproducible builds, graceful optional-dependency handling, and divergence between classic and AOT runtime paths across an ecosystem of modules.
## Why gotchas here are subtle AOT processors run in an unusual place — the **build**, not the running app — and their mistakes tend to surface **late** (only in the native image, or only under the AOT profile). A principal engineer is expected to anticipate these. ### 1. Build-time execution ⇒ determinism The `processAheadOfTime` methods and contribution `applyTo`s run during `./gradlew processAot` (or the Maven equivalent), producing Java source frozen into the artifact. Consequences: - **No runtime state.** Do not read `System.currentTimeMillis()`, random seeds, network resources, or environment variables that differ at runtime. Whatever you read at build time is what the app gets forever. - **Reproducibility.** Non-deterministic output breaks build caching and native-image reproducibility. Iterate collections in a stable order; avoid `HashMap` iteration order leaking into generated code. ### 2. The excluded-bean rule ```java default boolean isBeanExcludedFromAotProcessing() { return true; } ``` A `BeanRegistrationAotProcessor` registered as a bean is, by default, **not** contributed to the optimized runtime context — it is build-time-only infrastructure. Override to `false` only if the same object genuinely must exist at runtime (rare). Forgetting this is why a build-only helper sometimes unexpectedly appears (or, more often, correctly disappears) at runtime. ### 3. Hint completeness — the native-only failure class On the JVM, reflection succeeds regardless of hints, so the **AOT-generated JVM run passes**. In a GraalVM native image, only reflection/resources declared via registered `RuntimeHints` are retained; anything the generated code reflects on **without** a matching hint throws at runtime (`ClassNotFoundException`, `NoSuchMethodException`, missing resource). This makes hint gaps a classic late-surfacing defect. Mitigation: - Register a hint for **every** reflective member the generated code touches. - Run the GraalVM reachability-metadata/agent and actual native tests in CI, not just JVM tests. ### 4. AOT path is opt-in and can diverge The optimized runtime activates only with AOT enabled (`spring.aot.enabled=true`, automatically in native images, or via the `native`/`nativeTest` tasks). So there are effectively **two code paths**: classic reflective startup and AOT-generated startup. A processor that changes bean instantiation can make them diverge. Always run `nativeTest` (or at least the AOT-enabled JVM tests) so both paths are exercised. ### 5. Null-safety and ordering - `processAheadOfTime` returns `@Nullable`; return `null` when there is nothing to do rather than an empty contribution, and never NPE on beans you don't handle. - Do **not** assume an execution order among processors from different modules; contributions all write into the same `GenerationContext`, so design them to be **commutative** — independent of the order in which they run. ### 6. Exceptions abort the build An exception thrown from a processor fails the `processAot` task and thus the whole build. Guard classpath-optional logic (e.g. `ClassUtils.isPresent`) so an absent optional dependency degrades gracefully instead of breaking every consumer's build. ## Putting it together The mental model: *AOT processors are compilers plugins.* Treat their output like generated code that must be correct forever, verified against the native image, and independent of both runtime state and sibling-processor ordering.
- A native image throws NoSuchMethodException at startup but JVM tests are green — what's the likely cause?A missing RuntimeHint: the AOT-generated code reflects on a member that wasn't registered. Reflection works on the JVM regardless, so only the native image, which retains only hinted members, fails.
- Why must contributions from different modules be order-independent?They all write into one shared GenerationContext with no guaranteed processor ordering across modules, so any dependence on execution order produces non-deterministic, unreliable generated output.
- How do you keep an optional-dependency AOT processor from breaking every consumer's build?Guard the classpath-dependent logic with ClassUtils.isPresent(...) and return null when the optional type is absent, so the processor degrades gracefully instead of throwing and failing processAot.
saying these in an interview costs you the question
- Reading runtime state (time/env/network) at build time
- Assuming JVM-green means native-safe
- Assuming a fixed execution order among processors
- Letting an unguarded exception abort every consumer's build
- Thinking the processor bean lives at runtime by default