Skip to content

SpringComponentRegistry — LazyDefinitions - getComponent resolves a bean that getOptionalComponent reports as absent #4702

Description

@MateuszNaKodach

Basic information

  • Axoniq Framework version: 5.2.0-SNAPSHOT (observed); fix requested for 5.3.0
  • Axon Framework version (when different from Axoniq Framework version): affects the Spring integration org.axonframework.extension.spring.config.SpringComponentRegistry (module extensions/spring)
  • JDK version: 21
  • Complete executable reproducer if available: N/A — observed in a Spring Boot + Axon Server (DCB) application during AxoniqFramework distributed-tracing work (feature 3594); a minimal reproducer can be added.

Steps to reproduce

  1. Run a Spring Boot application on the Axon Spring integration (so the active ComponentRegistry / Configuration is a SpringComponentRegistry).
  2. Contribute an optional component as a Spring bean that is lazily created and not yet instantiated at lookup time — e.g. a SpanFactory bean registered through a ConfigurationEnhancer. In the registry it appears as SpanFactory:null -> LazyInitializedComponentDefinition (uniquely defined, not yet built).
  3. From a component/decorator being resolved early (before that bean is instantiated), call both accessors for the same type, same Configuration, same moment:
config.getOptionalComponent(SpanFactory.class); // -> Optional.empty()
config.getComponent(SpanFactory.class);         // -> OpenTelemetrySpanFactory (resolved!)

Expected behaviour

getComponent(type) and getOptionalComponent(type) MUST be consistent: getComponent should succeed iff getOptionalComponent is present — i.e. getComponent(type, name) behaves like getOptionalComponent(type, name).orElseThrow(ComponentNotFoundException). This is the contract in DefaultComponentRegistry, and the two accessors should be substitutable across ComponentRegistry implementations.

Actual behaviour

In SpringComponentRegistry's inner SpringConfiguration, the two accessors use different Spring BeanFactory APIs with different laziness semantics:

@Override
public <C> C getComponent(Class<C> type) {
    try {
        return beanFactory.getBean(type);              // forces creation of the (unique) bean
    } catch (NoUniqueBeanDefinitionException e) {
        C bean = beanFactory.getBeansOfType(type).get(type.getName());
        if (bean == null) {
            throw e;
        }
        return bean;
    }
}

@Override
public <C> Optional<C> getOptionalComponent(Class<C> type) {
    return Optional.ofNullable(beanFactory.getBeanProvider(type).getIfUnique()); // null when not yet instantiated
}

getBean(type) forces the bean to be created and returns it; getBeanProvider(type).getIfUnique() returns null when the (uniquely-defined) bean has not been instantiated yet. So for a not-yet-created bean, getComponent returns the instance while getOptionalComponent returns Optional.empty() — same type, same moment.

By contrast, DefaultComponentRegistry (module common) keeps them consistent: its getComponent(type, name) is defined as getOptionalComponent(type, name).orElseThrow(ComponentNotFoundException), so getComponent succeeds exactly when getOptionalComponent is present. The Spring integration violates that invariant.

Impact (how it surfaced): In AxoniqFramework distributed tracing (feature 3594), the tracing ConfigurationEnhancers resolve the optional SpanFactory via getOptionalComponent(SpanFactory.class) at component-decoration time. In a Spring Boot + Axon Server (DCB) app, the command/query buses and bus connectors are resolved early (during connector startup) — before the OpenTelemetry SpanFactory bean is instantiated — so getOptionalComponent returns empty and the CommandBus / QueryBus / EventBus / connector tracing decorators are skipped. Result: no bus/connector spans and fragmented cross-process traces.

Workaround applied (AxoniqFramework tracing, not a fix): resolve via config.getComponent(SpanFactory.class) and catch ComponentNotFoundException → treat as absent. getComponent forces creation of the bean, so it resolves correctly. This works around the inconsistency but relies on the very behavioural difference reported here.

To discuss

  • Suggested fix: make SpringConfiguration.getOptionalComponent(type) resolve the same way getComponent(type) does — e.g. beanFactory.getBeanProvider(type).getIfAvailable(), or attempt getBean(type) and translate NoSuchBeanDefinitionExceptionOptional.empty()or define getComponent in terms of getOptionalComponent (mirroring DefaultComponentRegistry), so the two accessors agree for lazy / not-yet-created beans.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority 2: ShouldHigh priority. Ideally, these issues are part of the release they’re assigned to.Type: BugUse to signal issues that describe a bug within the system.

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions