skip to content

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?

level: principalimportance: should knowfreq 30%

answer

  1. variability in values, not bean presence
  2. always-present strategy beans + runtime flag lookup
  3. @ConfigurationProperties for env values, one image many values
  4. image-per-env only when bean sets truly differ
  5. AOT-on-JVM CI gate + reproducible build env

basics

~20 s

Move 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 s

The 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
java
// 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

for a junior

Know the guidance: prefer always-present beans over conditional ones for native.

for a middle

Explain moving toggles from bean presence to runtime values / feature flags.

for a senior

Design strategy-registry patterns, @ConfigurationProperties value-driven behavior, and an AOT-on-JVM CI gate.

for a principal

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

context