skip to content

When would you choose functional bean registration (registerBean / Kotlin DSL) over annotations, and what are the performance, AOT/native-image, and design trade-offs?

level: principalimportance: should knowfreq 22%

answer

  1. libraries: don't force @ComponentScan
  2. dynamic loop: one bean per tenant/config
  3. supplier = direct call, no reflection/CGLIB -> AOT/native
  4. skip scanning -> faster startup
  5. cost: not discoverable, explicit wiring, imperative conditions

basics

~20 s

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

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

for a junior

Say functional registration is good for library code and dynamic bean creation, and is reflection-light.

for a middle

Give concrete cases (per-tenant loop, starter beans) and note the discoverability/explicit-wiring trade-off.

for a senior

Discuss AOT/native reflection reduction, startup cost, and mixing functional with annotation config in one context.

for a principal

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)

context