Skip to content

@Replaces on an interface annotated with @DefaultImplementation is ignored when the default bean is AOP-proxied #12794

Description

@sdelamo

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

Metadata

Metadata

Assignees

Labels

type: bugSomething isn't working

Type

No type

Projects

Status
Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions