When would you choose functional bean registration (registerBean / Kotlin DSL) over annotations, and what are the performance, AOT/native-image, and design trade-offs?
answer
- libraries: don't force @ComponentScan
- dynamic loop: one bean per tenant/config
- supplier = direct call, no reflection/CGLIB -> AOT/native
- skip scanning -> faster startup
- cost: not discoverable, explicit wiring, imperative conditions
basics
~20 sChoose functional registration for library code that shouldn't force scanning, dynamic sets of beans (one per config entry), and reflection-free startup for GraalVM native / Spring AOT. Trade-off: you lose annotation discoverability and must wire dependencies explicitly.
solid answer
~50 sFunctional registration (registerBean with a Supplier, or the Kotlin beans DSL) wins when annotations are a poor fit: (1) library/starter code that must contribute beans without forcing consumers to component-scan a package; (2) dynamic registration — a variable number of beans decided at runtime, e.g. one client per configured tenant, done in a plain loop; (3) reflection-lean startup for GraalVM native images and Spring AOT, where a supplier lambda constructs instances without reflective constructor resolution or CGLIB, shrinking the reflection metadata AOT must generate; (4) faster startup by skipping classpath scanning. Trade-offs: beans aren't annotation-discoverable (tooling, @ComponentScan, and readers who grep for @Service won't find them); you own dependency wiring explicitly with a supplier (autowiring is bypassed); conditional registration moves from @Conditional to imperative code, which is flexible but less declarative. Most apps mix both: annotations for app beans, functional registration for infrastructure/library and native-optimized paths.
code
java · 18 lines// Data-driven registration: one client bean per configured tenant
public class TenantBeansInitializer
implements ApplicationContextInitializer<GenericApplicationContext> {
private final List<TenantConfig> tenants;
TenantBeansInitializer(List<TenantConfig> tenants) { this.tenants = tenants; }
@Override
public void initialize(GenericApplicationContext ctx) {
for (TenantConfig t : tenants) {
ctx.registerBean(
"client-" + t.id(),
TenantClient.class,
() -> new TenantClient(t.url(), t.apiKey()), // direct, reflection-free
bd -> bd.setLazyInit(true)
);
}
}
}go deeper
Say functional registration is good for library code and dynamic bean creation, and is reflection-light.
Give concrete cases (per-tenant loop, starter beans) and note the discoverability/explicit-wiring trade-off.
Discuss AOT/native reflection reduction, startup cost, and mixing functional with annotation config in one context.
Frame a design policy: annotations for app beans, functional for infra/library/native; address CGLIB avoidance, post-processor ordering, and library API surface design.
**The decision framework** Annotations (`@Component`/`@Bean`) are the default for application code — discoverable, declarative, terse. Reach for **functional registration** when their model fights you: 1. **Library / auto-configuration / starter code.** A library that ships beans shouldn't force consumers to add its package to `@ComponentScan`. Registering via an `ApplicationContextInitializer` (the Kotlin `beans { }` DSL literally *is* one) or a post-processor lets the library contribute beans transparently. It also avoids leaking Spring annotations onto library types. 2. **Dynamic / data-driven bean sets.** When the *number* of beans depends on runtime config (one `KafkaClient` per configured cluster, one `DataSource` per tenant), a plain loop calling `registerBean(name, Type.class, () -> new Type(cfg))` is far cleaner than trying to bend annotations to it. 3. **Reflection-free / AOT / GraalVM native.** This is the modern headline reason. A `Supplier` (`Type::new`) constructs the bean by a **direct call**, not reflective constructor resolution or CGLIB subclassing. That reduces the reflection/proxy metadata Spring AOT must register for a native image and aligns with Spring's functional style (the Kotlin DSL and `registerBean` are AOT-friendly). Note: annotation-based config is *also* supported under AOT via generated code, but explicit suppliers minimize reflective surface. 4. **Startup latency.** Skipping classpath scanning and annotation parsing shaves startup time; meaningful at large scale or in serverless/cold-start contexts. 5. **Fine-grained programmatic control.** Setting scope/primary/lazy/qualifiers via `BeanDefinitionCustomizer` or `BeanDefinitionBuilder` in code, computed from environment, without splitting across annotations. **Trade-offs / costs** - **Discoverability.** Functionally-registered beans are invisible to `@ComponentScan`, IDE 'find beans' tooling, and developers scanning for `@Service`. Documentation/convention matters more. - **Explicit wiring.** With a `Supplier`, constructor autowiring is bypassed — you pass collaborators yourself (closure capture, `ref()` in the Kotlin DSL, or `ctx.getBean`). More boilerplate, but no reflection and no ambiguity. - **Conditional logic** moves from declarative `@Conditional`/`@Profile` to imperative `if`/`profile { }`. More powerful, but the 'why is this bean here' logic is now in code paths. - **Ordering / lifecycle.** Must register before `refresh()`; post-processor ordering (`BeanDefinitionRegistryPostProcessor` before `BeanFactoryPostProcessor`) matters for dynamic registration. - **Mixing.** Functional and annotation beans coexist in one context; but a functional bean can't be `@Autowired`-discovered by *type* any less than an annotated one — both end up as `BeanDefinition`s, so injection by type still works. The loss is purely at the *declaration/discovery* stage. **A note on @Configuration proxying (contrast, not scope creep):** full-mode `@Configuration` classes are CGLIB-proxied so inter-`@Bean` method calls return the singleton. Functional registration has no such proxy — you reference other beans via `ref`/registry lookups, sidestepping that entire mechanism (relevant for native, where CGLIB is undesirable). **Rule of thumb:** annotations for ordinary app beans; functional registration for infrastructure/library beans, dynamic bean families, and native/AOT-optimized paths. Design libraries to expose a small functional registration entry point rather than forcing scanning.
- Why is a Supplier-based registration friendlier to GraalVM native images?The Supplier constructs the bean via a direct method call instead of reflective constructor resolution or CGLIB proxying, so Spring AOT needs to register less reflection/proxy metadata for the native image, keeping the closed-world config smaller.
- Do functionally-registered beans lose @Autowired injection by type into other beans?No. They become BeanDefinitions like any other, so injection by type/name still resolves them. What they lose is discovery at declaration time — @ComponentScan and IDE bean tooling won't find the declaration.
- How would you let a library contribute beans without forcing consumers to component-scan its package?Expose an ApplicationContextInitializer (or a Kotlin beans { } DSL, which is one) that calls registerBean, and register it via spring.factories / context.initializer.classes or addInitializers — the beans appear without any @ComponentScan of the library.
saying these in an interview costs you the question
- Claiming annotations can't work with AOT/native at all (they can, via generated code)
- Saying functional beans can't be autowired into other beans
- Ignoring that you must register before refresh()
- Treating functional registration as always faster/better rather than situational
- Confusing this with @Import/ImportBeanDefinitionRegistrar (a different mechanism)