skip to content

How do dynamic / programmatic bean registration mechanisms behave under Spring AOT, and what breaks?

level: seniorimportance: should knowfreq 45%

answer

  1. BFPP/BDRPP/registrars run at BUILD time under AOT
  2. output frozen into generated ApplicationContextInitializer
  3. runtime-state branching = wrong build-time snapshot
  4. post-startup registerBean not in frozen graph / native closed-world
  5. fix: deterministic registrar + RuntimeHints + AotProcessor

basics

~10 s

Programmatic registration (BeanDefinitionRegistryPostProcessor, ImportBeanDefinitionRegistrar, registerBean) runs at build time during AOT and its output is frozen into generated code. Registration that depends on runtime state, or done after startup, is lost in native images.

solid answer

~40 s

AOT runs the context lifecycle up to bean instantiation at build time, so `BeanFactoryPostProcessor`, `BeanDefinitionRegistryPostProcessor`, `ImportBeanDefinitionRegistrar`, and `ImportSelector` all execute **then**, and whatever definitions they produce are captured in the generated `ApplicationContextInitializer`. The catch: if those components branch on **runtime-only state** (an env var read at boot, a remote call, a scanned directory), AOT will snapshot whatever they saw during the build — which is usually wrong or empty. Worse, any bean you try to register **after** the context has started (`GenericApplicationContext.registerBean`, refreshing a dynamic child context) is not part of the frozen graph, and in a GraalVM native image the closed-world model means the required reflection/proxy metadata for those types won't exist. The fix is to make registrars deterministic at build time, contribute `RuntimeHints`, or register a custom `BeanRegistrationAotProcessor`.

code

java · 19 lines
java
// ANTI-PATTERN under AOT: decision depends on runtime env,
// but this runs at BUILD time and snapshots the build machine.
class EnvDrivenRegistrar implements ImportBeanDefinitionRegistrar {
    @Override
    public void registerBeanDefinitions(AnnotationMetadata meta, BeanDefinitionRegistry reg) {
        if ("true".equals(System.getenv("ENABLE_X"))) {   // read at build, not prod!
            reg.registerBeanDefinition("x",
                new RootBeanDefinition(XService.class));
        }
    }
}

// BETTER: always register the bean; let it read runtime config,
// and declare any reflection it needs via RuntimeHints.
@Configuration
@ImportRuntimeHints(XHints.class)
class XConfig {
    @Bean XService xService(Environment env) { return new XService(env); }
}

go deeper

for a junior

Know that programmatic registration runs at build time under AOT.

for a middle

Name BFPP/BDRPP/ImportBeanDefinitionRegistrar and that their output is frozen into generated code.

for a senior

Explain runtime-state snapshot hazards, post-startup registration failing in the closed world, and RuntimeHints/AotProcessor fixes.

for a principal

Set a policy: registrars must be pure functions of build-time inputs; runtime variability moves into always-present dispatching beans.

## The mechanisms in question Spring lets you register beans **programmatically** rather than via `@Bean`: - `BeanFactoryPostProcessor` (BFPP) / `BeanDefinitionRegistryPostProcessor` (BDRPP) — mutate/add bean definitions before instantiation. - `ImportBeanDefinitionRegistrar` and `ImportSelector` — used by `@Import` and most `@Enable...` annotations to add definitions based on annotation metadata. - `GenericApplicationContext.registerBean(...)` — imperative registration. ## What AOT does with them AOT processing **runs the full context setup up to (not including) bean instantiation** at build time. That means BFPPs, BDRPPs, registrars, and selectors **execute during the build**. Their resulting `BeanDefinition`s are serialized into **generated code** (the `ApplicationContextInitializer` + `BeanDefinition` builder methods). At runtime those post-processors are typically **not re-run** — the beans they would have created are already baked in. ## What breaks 1. **Runtime-dependent decisions.** A registrar that reads `System.getenv`, a config server, a database, or scans a folder to decide which beans to create will observe the **build environment**, not production. The snapshot is frozen — often empty or wrong. 2. **Post-startup registration.** Calling `registerBean` after the context is running, or spinning up a refreshable child context, is outside the frozen graph. On the JVM it may partially work; in a **native image** the closed-world model means the classes/reflection/proxies for those beans were never included, so it fails. 3. **Non-deterministic order or identity** across build vs runtime can produce subtle mismatches. ## Doing it correctly - Make registrars **deterministic and build-time computable** — base decisions on classpath/annotation metadata, not live runtime state. - Contribute a `BeanRegistrationAotProcessor` or `BeanFactoryInitializationAotProcessor` to customize how a bean is contributed to the generated code. - Register **`RuntimeHints`** (via `RuntimeHintsRegistrar` + `@ImportRuntimeHints`, or `@RegisterReflectionForBinding`) for any reflection/serialization/proxy your code needs at runtime, since GraalVM won't discover it dynamically. - If a bean set genuinely varies at runtime, prefer a **single always-present bean** that dispatches based on runtime configuration values, rather than varying the bean *definitions*. ## Gotchas - `@ConditionalOnMissingBean` ordering is resolved at build time — surprising interactions if a registrar adds a bean that a condition elsewhere checks for. - FactoryBeans and scanned components (`@ComponentScan` with dynamic filters) are likewise resolved at build time. - Test this on the JVM first with `spring.aot.enabled=true`; many issues surface there without the native compile cost. ## When to use Programmatic registration is fine under AOT **as long as it is a pure function of build-time-available inputs**. The moment it needs runtime state to decide *which beans exist*, redesign toward runtime-valued behavior.

  • When exactly do BeanFactoryPostProcessors run in an AOT-processed application?
    At build time, during AOT processing, which runs the context up to just before bean instantiation. Their contributed definitions are captured in generated code; they are generally not re-executed at runtime.
  • You must include reflection metadata for a bean AOT can't infer. What's the mechanism?
    Implement RuntimeHintsRegistrar and wire it with @ImportRuntimeHints (or use a BeanRegistrationAotProcessor / @RegisterReflectionForBinding). GraalVM's closed world requires these hints since it can't discover reflection dynamically.

saying these in an interview costs you the question

  • Believing post-processors re-run at runtime in an AOT/native app
  • Registrars that branch on System.getenv / remote calls to choose beans
  • Expecting GenericApplicationContext.registerBean after startup to work in native images
  • Forgetting RuntimeHints for reflection/proxies the closed world can't discover

context