Expected Behavior
A bean annotated @Replaces(SomeInterface.class), where SomeInterface is annotated
@DefaultImplementation(DefaultImpl.class), should replace the default implementation —
including when DefaultImpl is AOP-proxied (e.g. it has an @Async method or uses
method validation). This worked in Micronaut Framework 4.
Actual Behaviour
Since Micronaut Framework 5, the @Replaces is silently ignored when the default bean is
AOP-proxied, and both beans remain candidates. Injecting or looking up the interface fails with:
io.micronaut.context.exceptions.NonUniqueBeanException: Multiple possible bean candidates found:
[GreeterReplacement, $DefaultGreeter$Definition$Intercepted]
Root cause
DefaultReplacesDefinition.checkIfTypeMatches compares the class declared in
@DefaultImplementation against definitionToBeReplaced.getBeanType():
https://github.com/micronaut-projects/micronaut-core/blob/5.0.x/inject/src/main/java/io/micronaut/context/DefaultReplacesDefinition.java#L120-L132
private boolean checkIfTypeMatches(BeanDefinition<T> definitionToBeReplaced,
Class<T> beanTypeToReplace) {
Class<T> bt = definitionToBeReplaced.getBeanType();
Class<?> defaultImplementation = definitionToBeReplaced.getDefaultImplementation();
if (defaultImplementation != null) {
if (defaultImplementation == bt) {
return beanTypeToReplace.isAssignableFrom(bt);
} else {
return beanTypeToReplace == bt;
}
}
return beanTypeToReplace != Object.class && beanTypeToReplace.isAssignableFrom(bt);
}
When the default bean is AOP-proxied, getBeanType() returns the generated proxy subclass
($DefaultGreeter$Definition$Intercepted), so defaultImplementation == bt is false even
though the definition is the default implementation. The code then falls into the strict
branch beanTypeToReplace == bt, which can never be true when beanTypeToReplace is an
interface. So the default bean is not filtered out.
In Micronaut 4, the equivalent logic in DefaultBeanContext#checkIfTypeMatches used
getCanonicalBeanType(...), which unwrapped AdvisedBeanType (via getInterceptedType())
and ProxyBeanDefinition (via getTargetType()) before comparing, so the proxied default
bean matched and was replaced.
The bug requires both conditions: the interface is annotated @DefaultImplementation,
and the default bean is AOP-proxied. Without @DefaultImplementation the final
isAssignableFrom fallback still matches the proxy subclass; without the proxy,
defaultImplementation == bt holds. Only the replaced bean needs to be proxied —
the replacement bean can be a plain singleton.
Steps To Reproduce
import io.micronaut.context.annotation.DefaultImplementation;
import io.micronaut.context.annotation.Replaces;
import io.micronaut.scheduling.annotation.Async;
import jakarta.inject.Singleton;
@DefaultImplementation(DefaultGreeter.class)
public interface Greeter {
String greet(String name);
}
@Singleton
class DefaultGreeter implements Greeter {
@Override
public String greet(String name) {
return "Hello " + name;
}
@Async // any AOP advice: this makes the bean definition an intercepted proxy
public void warmUp() {
}
}
@Singleton
@Replaces(Greeter.class)
class GreeterReplacement implements Greeter {
@Override
public String greet(String name) {
return "Hola " + name;
}
}
import io.micronaut.context.ApplicationContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
class ReplacesProxiedDefaultImplementationTest {
@Test
void replacingAnInterfaceWithDefaultImplementationWorksWhenDefaultBeanIsProxied() {
try (ApplicationContext ctx = ApplicationContext.run()) {
// Micronaut 5: throws NonUniqueBeanException
// Micronaut 4: returns GreeterReplacement
assertInstanceOf(GreeterReplacement.class, ctx.getBean(Greeter.class));
}
}
}
Removing the @Async method from DefaultGreeter (so it is not proxied), or removing
@DefaultImplementation from Greeter, makes the test pass — confirming the interaction
described above.
Real-world impact: any library exposing an API interface with @DefaultImplementation
whose default bean uses @Async/validation/other around advice can no longer be replaced
by downstream applications; the failure is a NonUniqueBeanException at injection time
rather than a compile-time or startup warning.
Removing the @Async method from DefaultGreeter (so it is not proxied), or removing
@DefaultImplementation from Greeter, makes the test pass — confirming the interaction
described above.
Real-world impact: any library exposing an API interface with @DefaultImplementation
whose default bean uses @Async/validation/other around advice can no longer be replaced
by downstream applications; the failure is a NonUniqueBeanException at injection time
rather than a compile-time or startup warning.
Environment Information
- Micronaut platform 5.0.4 (resolves
micronaut-inject 5.0.5; bug also present on the current 5.0.x branch)
- JDK 25 (also reproduced on JDK 21)
- macOS (darwin/arm64)
Possible fix
Unwrap the proxy before comparing, as Micronaut 4 did — e.g. resolve the canonical bean type
(AdvisedBeanType#getInterceptedType() / ProxyBeanDefinition#getTargetType()) in
DefaultReplacesDefinition.checkIfTypeMatches before the defaultImplementation == bt check.
Actual Behaviour
No response
Steps To Reproduce
No response
Environment Information
No response
Example Application
No response
Version
5.0.4
Expected Behavior
A bean annotated
@Replaces(SomeInterface.class), whereSomeInterfaceis annotated@DefaultImplementation(DefaultImpl.class), should replace the default implementation —including when
DefaultImplis AOP-proxied (e.g. it has an@Asyncmethod or usesmethod validation). This worked in Micronaut Framework 4.
Actual Behaviour
Since Micronaut Framework 5, the
@Replacesis silently ignored when the default bean isAOP-proxied, and both beans remain candidates. Injecting or looking up the interface fails with:
Root cause
DefaultReplacesDefinition.checkIfTypeMatchescompares the class declared in@DefaultImplementationagainstdefinitionToBeReplaced.getBeanType():https://github.com/micronaut-projects/micronaut-core/blob/5.0.x/inject/src/main/java/io/micronaut/context/DefaultReplacesDefinition.java#L120-L132
When the default bean is AOP-proxied,
getBeanType()returns the generated proxy subclass(
$DefaultGreeter$Definition$Intercepted), sodefaultImplementation == btisfalseeventhough the definition is the default implementation. The code then falls into the strict
branch
beanTypeToReplace == bt, which can never betruewhenbeanTypeToReplaceis aninterface. So the default bean is not filtered out.
In Micronaut 4, the equivalent logic in
DefaultBeanContext#checkIfTypeMatchesusedgetCanonicalBeanType(...), which unwrappedAdvisedBeanType(viagetInterceptedType())and
ProxyBeanDefinition(viagetTargetType()) before comparing, so the proxied defaultbean matched and was replaced.
The bug requires both conditions: the interface is annotated
@DefaultImplementation,and the default bean is AOP-proxied. Without
@DefaultImplementationthe finalisAssignableFromfallback still matches the proxy subclass; without the proxy,defaultImplementation == btholds. Only the replaced bean needs to be proxied —the replacement bean can be a plain singleton.
Steps To Reproduce
Removing the
@Asyncmethod fromDefaultGreeter(so it is not proxied), or removing@DefaultImplementationfromGreeter, makes the test pass — confirming the interactiondescribed above.
Real-world impact: any library exposing an API interface with
@DefaultImplementationwhose default bean uses
@Async/validation/other around advice can no longer be replacedby downstream applications; the failure is a
NonUniqueBeanExceptionat injection timerather than a compile-time or startup warning.
Removing the
@Asyncmethod fromDefaultGreeter(so it is not proxied), or removing@DefaultImplementationfromGreeter, makes the test pass — confirming the interactiondescribed above.
Real-world impact: any library exposing an API interface with
@DefaultImplementationwhose default bean uses
@Async/validation/other around advice can no longer be replacedby downstream applications; the failure is a
NonUniqueBeanExceptionat injection timerather than a compile-time or startup warning.
Environment Information
micronaut-inject5.0.5; bug also present on the current5.0.xbranch)Possible fix
Unwrap the proxy before comparing, as Micronaut 4 did — e.g. resolve the canonical bean type
(
AdvisedBeanType#getInterceptedType()/ProxyBeanDefinition#getTargetType()) inDefaultReplacesDefinition.checkIfTypeMatchesbefore thedefaultImplementation == btcheck.Actual Behaviour
No response
Steps To Reproduce
No response
Environment Information
No response
Example Application
No response
Version
5.0.4