Skip to content

Lazy BeanDefinition registration of framework components prevents BeanFactoryPostProcessor-time consumers (e.g. @MockitoBean) from seeing them #4575

Description

@MateuszNaKodach

Basic information

  • Axon Framework version: 5.1.1-SNAPSHOT (also reproduced against axon-spring:5.1.0)

  • JDK version: 25

  • Spring Boot version: 3.5.14 (Spring Framework 6.2.18)

  • Complete executable reproducer if available (e.g. GitHub Repo):
    MateuszNaKodach/HeroesOfDomainDrivenDesign.EventSourcing.Java.Axon.Spring,
    branch af5-migration/test1. The failure surfaces on top of commit
    d3319d9
    ("fix(af5-migration): enable Spring Boot test context for AF5 JPA event store"), which got the
    Spring test context loading under AF5; with the JPA/@EntityScan blockers out of the way, the
    next layer — @MockitoSpyBean CommandGateway — fails as described below. The three affected
    tests in that repo are:

    • src/test/java/com/dddheroes/heroesofddd/astrologers/automation/whenweekstartedthenproclaimweeksymbol/WhenWeekStartedThenProclaimWeekSymbolTest.java
    • src/test/java/com/dddheroes/heroesofddd/astrologers/automation/whenweeksymbolproclaimedthenincreasedwellingavailablecreatures/WhenWeekSymbolProclaimedThenIncreaseDwellingAvailableCreaturesTest.java
    • src/test/java/com/dddheroes/heroesofddd/creaturerecruitment/automation/WhenCreatureRecruitedThenAddToArmyTest.java

    Run ./mvnw test -Dtest='WhenWeekStartedThenProclaimWeekSymbolTest,WhenWeekSymbolProclaimedThenIncreaseDwellingAvailableCreaturesTest,WhenCreatureRecruitedThenAddToArmyTest'
    on top of commit d3319d9 (and before the workaround commit
    0d22913
    — see the "Practical workaround" section — has been applied) to reproduce the failure on the
    unmodified tests.

Steps to reproduce

  1. Add axon-spring-boot-starter (or axoniq-spring-boot-starter) to a Spring Boot 3.x project.

  2. Write a @SpringBootTest with a @MockitoSpyBean (or @MockitoBean) field of an AF5 framework
    component, e.g.:

    @SpringBootTest
    class WhenSomethingThenSendCommandTest {
    
        @MockitoSpyBean
        private CommandGateway commandGateway;
    
        @Test
        void verifies_command_dispatch() {
            // ...
        }
    }
  3. Run the test.

Expected behaviour

The Spring test context loads, the @MockitoSpyBean wraps the AF5-registered CommandGateway
bean, and verify(commandGateway, ...).send(...) works as it would for any other Spring bean.

Actual behaviour

Context refresh is aborted before any bean is instantiated:

java.lang.IllegalStateException: Unable to select a bean to wrap: there are no beans of type
org.axonframework.messaging.commandhandling.gateway.CommandGateway (as required by field
'WhenSomethingThenSendCommandTest.commandGateway'). If the bean is defined in a @Bean method,
make sure the return type is the most specific type possible (for example, the concrete
implementation type).
    at org.springframework.test.context.bean.override.BeanOverrideRegistrar ...
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsBeforeInitialization ...

The exact same symptom occurs with @MockitoBean and with every other framework component
registered through AF5's component registry (EventGateway, QueryGateway, EventSink,
CommandBus, EventBus, QueryBus, etc.).

Root cause

SpringComponentRegistry registers AF5 components as Spring BeanDefinitions lazily, from
inside SpringComponentRegistry#initialize(). That method is invoked from
postProcessAfterInitialization (SpringComponentRegistry.java:300-363) — i.e. it only runs once
the first non-infrastructure bean is being post-processed during the bean instantiation phase.
The actual registration happens in registerLocalComponentsWithApplicationContext
(SpringComponentRegistry.java:566-585).

Spring Boot's @MockitoSpyBean / @MockitoBean is installed by BeanOverrideContextCustomizer,
which contributes a BeanFactoryPostProcessor (BeanOverrideRegistrar). BFPPs run before any
bean is instantiated, and BeanOverrideRegistrar.applyBeanOverrides requires a BeanDefinition
of the overridden type to already be present at that moment — otherwise it raises
Unable to select a bean to wrap.

The two phases never line up:

[ BFPP phase ]      BeanOverrideRegistrar looks up CommandGateway     -> not registered yet -> FAIL
[ BPP phase  ]      SpringComponentRegistry.initialize() would have
                    registered CommandGateway as a BeanDefinition
                    (never reached because refresh was aborted)

This means @MockitoSpyBean / @MockitoBean cannot wrap any AF5 component out of the box.

Why the obvious workaround does not work

A user-level @Bean method that exposes the framework component via AxonConfiguration:

@Configuration
class AxonGatewaysBeanConfiguration {
    @Bean
    CommandGateway commandGateway(AxonConfiguration axonConfiguration) {
        return axonConfiguration.getComponent(CommandGateway.class);
    }
}

…causes a BeanCurrentlyInCreationException. The cycle is in
SpringComponentRegistry$SpringConfiguration.getComponent (SpringComponentRegistry.java:640):

@Override
public <C> C getComponent(Class<C> type) {
    try {
        return beanFactory.getBean(type);   // <-- delegates back to Spring
    } ...
}

SpringConfiguration is a thin proxy over Spring (not a separate registry), so the user
@Bean resolves itself through beanFactory.getBean(CommandGateway.class) while it is still
in creation:

Spring creates `commandGateway` (factory method)
    -> axonConfiguration.getComponent(CommandGateway.class)
        -> SpringConfiguration.getComponent
            -> beanFactory.getBean(CommandGateway.class)
                -> "Requested bean is currently in creation"

Practical workaround (consumer side)

Wrap the AF5-registered bean with Mockito.spy(...) from a BeanPostProcessor and use
@Autowired in the test instead of @MockitoSpyBean:

@TestConfiguration
public class CommandGatewaySpyConfiguration {

    @Bean
    static BeanPostProcessor commandGatewaySpyPostProcessor() {
        return new BeanPostProcessor() {
            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) {
                if (bean instanceof CommandGateway gateway
                        && CommandGateway.class.getName().equals(beanName)) {
                    return Mockito.spy(gateway);
                }
                return bean;
            }
        };
    }
}
@SpringBootTest
@Import(CommandGatewaySpyConfiguration.class)
class WhenSomethingThenSendCommandTest {

    @Autowired
    private CommandGateway commandGateway;
    // verify(commandGateway, times(1)).send(eq(cmd), eq(meta), any());
}

By the time this BPP runs on the bean named
org.axonframework.messaging.commandhandling.gateway.CommandGateway (the FQCN under which
SpringComponentRegistry.registerLocalComponentsWithApplicationContext registers it),
SpringComponentRegistry.initialize() has already produced the fully-decorated
ConvertingCommandGateway. Replacing the singleton-cache entry with Mockito.spy(real) makes
the spy visible to every subsequent getBean(CommandGateway.class) lookup — including the
parameter-injection performed by AF5 for automation processors — so verify(...) works as it
would for @MockitoSpyBean.

The drawback is that consumers have to roll their own BPP per spied/mocked component, and have
to abandon Spring Boot's standard @MockitoSpyBean / @MockitoBean annotations.

Suggested fix

Move framework-component BeanDefinition registration out of the BPP phase into a
BeanDefinitionRegistryPostProcessor's postProcessBeanDefinitionRegistry callback (or any
other hook that runs before regular BeanFactoryPostProcessors). The BeanDefinition already
uses a Supplier factory (() -> component.resolve(configuration)) in
registerLocalComponentsWithApplicationContext, so only the definition needs to move earlier;
the actual Component.resolve(...) call can still happen lazily when Spring needs an instance.

Roughly:

// SpringComponentRegistry implements BeanDefinitionRegistryPostProcessor
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
    initializeIfNeeded();                              // run enhancers, build Components, decorate
    components.postProcessComponents(component -> {
        String name = nameFor(component);
        if (registry.containsBeanDefinition(name)) return;
        AbstractBeanDefinition def = BeanDefinitionBuilder
                .rootBeanDefinition(
                        ResolvableType.forType(component.identifier().type().getType()),
                        () -> component.resolve(configuration))
                .getBeanDefinition();
        registry.registerBeanDefinition(name, def);
    });
}

With that change in place:

  • @MockitoSpyBean / @MockitoBean works against CommandGateway, EventGateway,
    QueryGateway, EventSink, CommandBus, EventBus, QueryBus, and every other framework
    component, without per-project bridging.
  • The existing BPP-side decoration logic in postProcessAfterInitialization keeps working for
    user-defined beans that should be wrapped/decorated by AF5.
  • The Spring Boot 4 infrastructure-bean hazard noted in SpringComponentRegistry#isInfrastructureBean
    remains addressable: skipping initialization while only ROLE_INFRASTRUCTURE beans exist is
    not relevant during the BDRPP phase since no beans are instantiated there.

Additional context

  • Affected AF artifacts: org.axonframework.extension.spring:axon-spring,
    org.axonframework.extension.spring:axon-spring-boot-starter,
    io.axoniq.framework:axoniq-spring-boot-starter.
  • The bug report uses @MockitoSpyBean / @MockitoBean as the concrete example because that is
    the first place real-world consumers hit it, but the root cause — framework BeanDefinitions
    being registered only during the bean-instantiation phase — generalises to any Spring
    facility that needs to see those BeanDefinitions at the BeanFactoryPostProcessor phase or
    earlier. Other consumers that are likely affected by the same race:
    • The Spring Framework Bean Override API in general — @TestBean (Spring Framework 6.2+),
      @MockitoSpyBean, @MockitoBean, custom BeanOverrideProcessor implementations
      (BeanOverrideContextCustomizer / BeanOverrideRegistrar).
    • Spring Boot's ApplicationContextRunner.withBean(...) / withUserConfiguration(...) when a
      user configuration redeclares a framework component.
    • Conditional-on-bean evaluation in user @Configuration classes that runs before any framework
      bean is instantiated (e.g. @ConditionalOnMissingBean(CommandGateway.class) on a user-supplied
      fallback).
    • Spring Boot AOT/native image generation, which traverses BeanDefinitions ahead-of-time.
    • Any user BeanDefinitionRegistryPostProcessor / BeanFactoryPostProcessor that inspects or
      decorates framework BeanDefinitions.

Metadata

Metadata

Labels

Priority 2: ShouldHigh priority. Ideally, these issues are part of the release they’re assigned to.Status: Under DiscussionUse to signal that the issue in question is being discussed.

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions