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
- Run a Spring Boot application on the Axon Spring integration (so the active
ComponentRegistry / Configuration is a SpringComponentRegistry).
- 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).
- 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 NoSuchBeanDefinitionException → Optional.empty() — or define getComponent in terms of getOptionalComponent (mirroring DefaultComponentRegistry), so the two accessors agree for lazy / not-yet-created beans.
Basic information
5.2.0-SNAPSHOT(observed); fix requested for 5.3.0org.axonframework.extension.spring.config.SpringComponentRegistry(moduleextensions/spring)Steps to reproduce
ComponentRegistry/Configurationis aSpringComponentRegistry).SpanFactorybean registered through aConfigurationEnhancer. In the registry it appears asSpanFactory:null -> LazyInitializedComponentDefinition(uniquely defined, not yet built).Configuration, same moment:Expected behaviour
getComponent(type)andgetOptionalComponent(type)MUST be consistent:getComponentshould succeed iffgetOptionalComponentis present — i.e.getComponent(type, name)behaves likegetOptionalComponent(type, name).orElseThrow(ComponentNotFoundException). This is the contract inDefaultComponentRegistry, and the two accessors should be substitutable acrossComponentRegistryimplementations.Actual behaviour
In
SpringComponentRegistry's innerSpringConfiguration, the two accessors use different SpringBeanFactoryAPIs with different laziness semantics:getBean(type)forces the bean to be created and returns it;getBeanProvider(type).getIfUnique()returnsnullwhen the (uniquely-defined) bean has not been instantiated yet. So for a not-yet-created bean,getComponentreturns the instance whilegetOptionalComponentreturnsOptional.empty()— same type, same moment.By contrast,
DefaultComponentRegistry(modulecommon) keeps them consistent: itsgetComponent(type, name)is defined asgetOptionalComponent(type, name).orElseThrow(ComponentNotFoundException), sogetComponentsucceeds exactly whengetOptionalComponentis present. The Spring integration violates that invariant.Impact (how it surfaced): In AxoniqFramework distributed tracing (feature 3594), the tracing
ConfigurationEnhancers resolve the optionalSpanFactoryviagetOptionalComponent(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 OpenTelemetrySpanFactorybean is instantiated — sogetOptionalComponentreturns empty and theCommandBus/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 catchComponentNotFoundException→ treat as absent.getComponentforces creation of the bean, so it resolves correctly. This works around the inconsistency but relies on the very behavioural difference reported here.To discuss
SpringConfiguration.getOptionalComponent(type)resolve the same waygetComponent(type)does — e.g.beanFactory.getBeanProvider(type).getIfAvailable(), or attemptgetBean(type)and translateNoSuchBeanDefinitionException→Optional.empty()— or definegetComponentin terms ofgetOptionalComponent(mirroringDefaultComponentRegistry), so the two accessors agree for lazy / not-yet-created beans.