Given build-time frozen conditions, how do you architect a Spring Boot service that still needs environment-specific and runtime-toggleable behavior across native deployments?
answer
- variability in values, not bean presence
- always-present strategy beans + runtime flag lookup
- @ConfigurationProperties for env values, one image many values
- image-per-env only when bean sets truly differ
- AOT-on-JVM CI gate + reproducible build env
basics
~20 sMove variability from bean presence to bean behavior: always register the beans, decide at runtime via configuration values. Reserve @Profile/@Conditional for build-time-stable choices, and build one native image per environment when bean sets truly differ.
solid answer
~50 sThe core rule is that under AOT the **bean graph is fixed at build time**, so anything that must vary at runtime cannot vary the *set of beans*. Architecturally: (1) prefer **always-present beans** that read runtime `@ConfigurationProperties`/`Environment` values and branch internally, instead of `@ConditionalOnProperty`-gated beans; (2) use **feature-flag services** (a flag provider bean is always present; flags are runtime data) rather than conditional wiring; (3) keep `@Profile`/`@Conditional` for decisions that are genuinely stable per build — classpath, infrastructure adapters; (4) when environments truly need different bean sets, produce **one native image per environment** with `spring.profiles.active` set during `processAot`, treating profile selection as a pipeline concern; (5) make any programmatic registrars **deterministic** and supply `RuntimeHints`. Validate everything by running AOT-on-JVM in CI. This trades the JIT flexibility of 'one jar, many runtime configs' for fast startup and low memory.
code
java · 17 lines// Structural toggle (AOT-hostile): bean may vanish at build time.
// @Bean @ConditionalOnProperty("export.enabled") Exporter exporter() {...}
// Behavioral toggle (AOT-friendly): bean always present, flips at runtime.
@Configuration
class ExportConfig {
@Bean
Exporter exporter(ExportProps props, MeterRegistry reg) {
return props.isEnabled() ? new MetricsExporter(reg) : Exporter.NOOP;
}
}
@ConfigurationProperties("export")
class ExportProps { // 'enabled' is a runtime value,
private boolean enabled; // resolved from the live Environment,
// getters/setters... // so ONE native image serves both states.
}go deeper
Know the guidance: prefer always-present beans over conditional ones for native.
Explain moving toggles from bean presence to runtime values / feature flags.
Design strategy-registry patterns, @ConfigurationProperties value-driven behavior, and an AOT-on-JVM CI gate.
Own the policy and trade-offs: value-vs-structural variability, image-per-environment decisions, reproducible builds, and when native is the wrong fit entirely.
## Reframing the constraint AOT freezes **which beans exist**; it does **not** freeze the **values those beans read** at runtime. Good AOT/native architecture pushes all runtime variability into the *value* dimension and keeps the *structural* (bean-presence) dimension build-time-stable. ## Concrete tactics 1. **Behavioral toggles over structural toggles.** Replace `@Bean @ConditionalOnProperty("x.enabled") X x()` with an always-present `X` (or a no-op/strategy variant) that checks `properties.isEnabled()` at call time. The bean is always in the frozen graph; the behavior flips at runtime. 2. **Strategy/registry patterns.** Register *all* implementations of an interface; select among them at runtime by a configuration key (`Map<String, Handler>` injection + lookup). No conditional bean removal needed. 3. **Feature-flag provider.** A single always-present `FeatureFlags` bean backed by config/remote store; flags are runtime **data**, not wiring. This is the canonical way to keep flags flippable in native images. 4. **`@ConfigurationProperties` everywhere** for env-specific values (URLs, sizes, credentials via secrets) — these are resolved at runtime from the live `Environment`, so a single image serves many value configurations. 5. **Image-per-environment only when bean sets genuinely differ** (e.g. a cloud-only exporter with heavy deps). Set `spring.profiles.active` during `processAot`; the CI pipeline builds the matrix. Accept this as the cost of the closed world. 6. **Deterministic registrars + RuntimeHints.** Any `ImportBeanDefinitionRegistrar`/`BeanFactoryPostProcessor` must be a pure function of build-time inputs; declare reflection/proxy/resource needs via `RuntimeHintsRegistrar` + `@ImportRuntimeHints`. ## Validation & governance - **AOT-on-JVM CI gate** (`spring.aot.enabled=true`) before the native build; assert critical beans exist and profiles resolve. - **Reproducible build environment**: pin the env vars/property files/classpath present during `processAot`, since they shape the frozen snapshot (CI-vs-local drift is a real bug source). - **Actuator `/beans` diff** between JIT and AOT modes in tests to catch accidental drops. - Document a **policy**: `@Conditional`/`@Profile` = build-stable only; runtime variability = values/flags. ## Trade-offs to articulate - **Gain**: sub-100ms startup, low memory, small attack surface — ideal for serverless/scale-to-zero/many-replica workloads. - **Lose**: the 'one artifact reconfigures itself at boot' flexibility, longer/heavier builds, an image matrix per environment, and reflection-hint maintenance. - **Decision heuristic**: choose native when startup/memory/density dominates and configuration variance is mostly *values*; stay JVM (or JVM+AOT off) when you need runtime structural reconfiguration or plugin-style dynamic beans. ## When NOT to go native Apps that dynamically load beans/plugins at runtime, heavily use runtime-generated proxies, or must swap wiring per tenant at boot are poor native fits — the frozen graph fights them at every turn.
- When is building one native image per environment justified versus a single image?When environments genuinely require different bean *sets* — e.g. a cloud-only exporter dragging heavy dependencies you don't want in other images. If the only difference is values (URLs, sizes, credentials), a single image plus @ConfigurationProperties is better; the image matrix is pure overhead.
- What kinds of applications are poor candidates for native / AOT because of frozen conditions?Plugin/extension systems that load beans at runtime, multi-tenant apps that rewire per tenant at boot, and code relying on runtime-generated proxies or heavy dynamic reflection — the closed world and frozen graph fight all of these.
saying these in an interview costs you the question
- Trying to keep @ConditionalOnProperty toggles flippable at runtime in native
- Building an image-per-environment matrix when only values differ
- Using @Profile for things that should be runtime configuration values
- No AOT-on-JVM CI gate; discovering frozen-condition bugs only after native compile
- Ignoring reproducibility of the processAot build environment