Repository: avaje/avaje-inject
Pull request #488 (2024-01-24 02:13:59)

Support qualifiers with members
Can now use a qualifier's members to help distinguish beans:

So given:
```java
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface TempQualifier {
  Scale[] value();

  String someOtherString();

  int defaultVal() default 0;

  NestedAnnotation[] inject() default {@NestedAnnotation()};

  enum Scale {
    CELSIUS,
    FAHRENHEIT,
  }

  @interface NestedAnnotation {

    Inject[] inject() default {};
  }
}

@Singleton
class Meters {

  Thermometer imperial;
  Thermometer metric;

  public Meters(
      @TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far") Thermometer imperial,
      @TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi") Thermometer metric) {
    this.imperial = imperial;
    this.metric = metric;
  }
}
```

this will generate

```java
public final class Meters$DI  {

  /**
   * Create and register Meters.
   */
  public static void build(Builder builder) {
    if (builder.isAddBeanFor(Meters.class)) {
      var bean = new Meters(builder.get(Thermometer.class,"@tempqualifier(defaultval=0, inject={@nestedannotation(inject={})}, someotherstring=\"far\", value={fahrenheit})"), builder.get(Thermometer.class,"@tempqualifier(defaultval=0, inject={@nestedannotation(inject={})}, someotherstring=\"celsi\", value={celsius})"));
      builder.register(bean);
    }
  }
}
```


SentryMan: Curious, it seems JDK 11 has some weird issue that prevents this from working.
SentryMan: It seems that in JDK 11, the qualifier code generates differently, it'll wire, but the ability to use test mocking is impaired.
SentryMan: the problem here seems to be that there's a discrepancy with how the annotation are rendered at runtime with `Annotation#toString()`, might have to only support a single `value` member at this time.
SentryMan: I managed to get it working with regex, with the only limitation being that qualifiers with annotation members are only supported if the nested annotation type has only a single member.

Patch:
diff --git a/.github/workflows/jdk-ea.yml b/.github/workflows/jdk-ea.yml
index 867df077..812d10ae 100644
--- a/.github/workflows/jdk-ea.yml
+++ b/.github/workflows/jdk-ea.yml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        java_version: [GA,22-ea,EA] ## valhalla (fails javadoc)
+        java_version: [GA,EA] ## valhalla (fails javadoc)
         os: [ubuntu-latest]
 
     steps:
diff --git a/README.md b/README.md
index 20e6215a..cc6f7688 100644
--- a/README.md
+++ b/README.md
@@ -101,19 +101,19 @@ module org.example {
 
 ## Spring DI comparison
 
-| Spring | Avaje
+|  Avaje | Spring
 | :--- | :---  |
-| @Component, @Service, @Repository | [@Singleton](https://avaje.io/inject/#singleton) |
-| FactoryBean&lt;T> | [Provider&lt;T>](https://avaje.io/inject/#provider) |
-| @Inject, @Autowired | [@Inject](https://avaje.io/inject/#inject) |
-| @Autowired(required=false) |[@Inject @Nullable](https://avaje.io/inject/#nullable) or [@Inject Optional&lt;T>](https://avaje.io/inject/#optional) |
-| @PostConstruct| [@PostConstruct](https://avaje.io/inject/#post-construct) |
-| @PreDestroy| [@PreDestroy](https://avaje.io/inject/#pre-destroy) |
-| @Configuration and @Bean | [@Factory and @Bean](https://avaje.io/inject/#factory) |
-| @Conditional | [@RequiresBean and @RequiresProperty](https://avaje.io/inject/#conditional) |
-| @Primary | [@Primary](https://avaje.io/inject/#primary) |
-| @Secondary | [@Secondary](https://avaje.io/inject/#secondary) |
-| @Profile | [@Profile](https://avaje.io/inject/#profile) |
+| [@Singleton](https://avaje.io/inject/#singleton) | @Component, @Service, @Repository |
+| [Provider&lt;T>](https://avaje.io/inject/#provider) | FactoryBean&lt;T> |
+| [@Inject](https://avaje.io/inject/#inject) | @Inject, @Autowired |
+| [@Inject @Nullable](https://avaje.io/inject/#nullable) or [@Inject Optional&lt;T>](https://avaje.io/inject/#optional) | @Autowired(required=false) |
+| [@PostConstruct](https://avaje.io/inject/#post-construct) | @PostConstruct|
+| [@PreDestroy](https://avaje.io/inject/#pre-destroy) | @PreDestroy |
+| [@Factory and @Bean](https://avaje.io/inject/#factory) | @Configuration and @Bean |
+| [@RequiresBean and @RequiresProperty](https://avaje.io/inject/#conditional) | @Conditional |
+| [@Primary](https://avaje.io/inject/#primary) | @Primary |
+| [@Secondary](https://avaje.io/inject/#secondary) | @Secondary |
+| [@AssistFactory](https://avaje.io/inject/#assistInject) | - |
 
 ## Generated Code
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AnnotationCopier.java b/inject-generator/src/main/java/io/avaje/inject/generator/AnnotationCopier.java
index bb950f30..c26c5670 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/AnnotationCopier.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AnnotationCopier.java
@@ -1,40 +1,31 @@
 package io.avaje.inject.generator;
 
+import static java.util.stream.Collectors.toList;
+
 import java.util.List;
+import java.util.Map.Entry;
 
 import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.AnnotationValue;
 import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.VariableElement;
 
 final class AnnotationCopier {
   private AnnotationCopier() {}
 
-  public static void copyAnnotations(Append writer, Element element, boolean newLines) {
+  static void copyAnnotations(Append writer, Element element, boolean newLines) {
     copyAnnotations(writer, element, "", newLines);
   }
 
-  public static void copyAnnotations(Append writer, Element element, String indent, boolean newLines) {
+  static void copyAnnotations(Append writer, Element element, String indent, boolean newLines) {
     for (final AnnotationMirror annotationMirror : element.getAnnotationMirrors()) {
       final var type = annotationMirror.getAnnotationType().asElement().asType().toString();
       if (type.startsWith("io.avaje.inject.Assist")) {
         continue;
       }
-      final String annotationName = annotationMirror.getAnnotationType().toString();
-      final StringBuilder sb =  new StringBuilder(indent).append("@").append(annotationName).append("(");
-      boolean first = true;
-
-      for (final var entry : annotationMirror.getElementValues().entrySet()) {
-        if (!first) {
-          sb.append(", ");
-        }
-        sb.append(entry.getKey().getSimpleName()).append("=");
-        writeVal(sb, entry.getValue());
-        first = false;
-      }
 
-      sb.append(")");
-      final String annotationString = sb.toString();
+      final String annotationString = toAnnotationString(indent, annotationMirror, false);
       writer.append(annotationString);
 
       if (newLines) {
@@ -45,8 +36,41 @@ final class AnnotationCopier {
     }
   }
 
+  static String toSimpleAnnotationString(AnnotationMirror annotationMirror) {
+    return Util.trimAnnotationString(toAnnotationString("", annotationMirror, true)).substring(1);
+  }
+
+  static String toAnnotationString(String indent, AnnotationMirror annotationMirror, boolean simpleEnums) {
+    final String annotationName = annotationMirror.getAnnotationType().toString();
+
+    final StringBuilder sb = new StringBuilder(indent).append("@").append(annotationName).append("(");
+    boolean first = true;
+
+    for (final var entry : sortedValues(annotationMirror)) {
+      if (!first) {
+        sb.append(", ");
+      }
+      sb.append(entry.getKey().getSimpleName()).append("=");
+      writeVal(sb, entry.getValue(), simpleEnums);
+      first = false;
+    }
+
+    return sb.append(")").toString().replace("()", "");
+  }
+
+  private static List<Entry<? extends ExecutableElement, ? extends AnnotationValue>> sortedValues(AnnotationMirror annotationMirror) {
+    return APContext.elements().getElementValuesWithDefaults(annotationMirror).entrySet().stream()
+      .sorted(AnnotationCopier::compareBySimpleName)
+      .collect(toList());
+  }
+
+  private static int compareBySimpleName(Entry<? extends ExecutableElement, ? extends AnnotationValue> entry1,
+                                         Entry<? extends ExecutableElement, ? extends AnnotationValue> entry2) {
+    return entry1.getKey().getSimpleName().toString().compareTo(entry2.getKey().getSimpleName().toString());
+  }
+
   @SuppressWarnings("unchecked")
-  private static void writeVal(final StringBuilder sb, final AnnotationValue annotationValue) {
+  private static void writeVal(final StringBuilder sb, final AnnotationValue annotationValue, boolean simpleEnums) {
     final var value = annotationValue.getValue();
     if (value instanceof List) {
       // handle array values
@@ -56,7 +80,7 @@ final class AnnotationCopier {
         if (!first) {
           sb.append(", ");
         }
-        writeVal(sb, listValue);
+        writeVal(sb, listValue, simpleEnums);
         first = false;
       }
       sb.append("}");
@@ -65,7 +89,8 @@ final class AnnotationCopier {
       // Handle enum values
       final var element = (VariableElement) value;
       final var type = element.asType();
-      sb.append(type.toString() + "." + element);
+      final var str = simpleEnums ? element : type.toString() + "." + element;
+      sb.append(str);
 
     } else if (value instanceof AnnotationMirror) {
       // handle annotation values
@@ -74,12 +99,12 @@ final class AnnotationCopier {
       sb.append("@").append(annotationName).append("(");
       boolean first = true;
 
-      for (final var entry : mirror.getElementValues().entrySet()) {
+      for (final var entry : sortedValues(mirror)) {
         if (!first) {
           sb.append(", ");
         }
         sb.append(entry.getKey().getSimpleName()).append("=");
-        writeVal(sb, entry.getValue());
+        writeVal(sb, entry.getValue(), simpleEnums);
         first = false;
       }
       sb.append(")");
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
index 7a5c5995..49d2bcb6 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
@@ -1,12 +1,17 @@
 package io.avaje.inject.generator;
 
+import io.avaje.inject.generator.MethodReader.MethodParam;
+
+import javax.lang.model.element.*;
+import javax.lang.model.util.ElementFilter;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
-import javax.lang.model.element.*;
-
-import io.avaje.inject.generator.MethodReader.MethodParam;
+import static java.util.function.Predicate.not;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
 
 final class AssistBeanReader {
 
@@ -23,7 +28,7 @@ final class AssistBeanReader {
   private final TypeReader typeReader;
   private final TypeElement targetType;
   private final String qualifierName;
-  private ExecutableElement factoryMethod;
+  private final ExecutableElement factoryMethod;
 
   AssistBeanReader(TypeElement beanType) {
     this.beanType = beanType;
@@ -38,14 +43,7 @@ final class AssistBeanReader {
     this.constructor = typeReader.constructor();
 
     AssistFactoryPrism instanceOn = AssistFactoryPrism.getInstanceOn(beanType);
-    targetType = APContext.asTypeElement(instanceOn.value());
-    validateTarget(targetType);
-
-    for (Element enclosedElement : targetType.getEnclosedElements()) {
-      if (enclosedElement.getKind() == ElementKind.METHOD) {
-        factoryMethod = (ExecutableElement) enclosedElement;
-      }
-    }
+    var factoryType = APContext.asTypeElement(instanceOn.value());
 
     constructor.params().stream()
       .filter(MethodParam::assisted)
@@ -61,12 +59,65 @@ final class AssistBeanReader {
       .filter(MethodParam::assisted)
       .map(MethodParam::element)
       .forEach(assistedElements::add);
+
+    factoryMethod =
+      ElementFilter.methodsIn(factoryType.getEnclosedElements()).stream()
+        .filter(e -> e.getModifiers().contains(Modifier.ABSTRACT))
+        .findFirst()
+        .orElse(null);
+
+    validateTarget(factoryType);
+    this.targetType = factoryType;
   }
 
   private void validateTarget(TypeElement t) {
-    if (t.getKind() != ElementKind.INTERFACE || !t.getModifiers().contains(Modifier.ABSTRACT)) {
-      APContext.logError(type, "@AssistFactory targets must be abstract");
+    var methods = ElementFilter.methodsIn(t.getEnclosedElements());
+    if (!APContext.elements().isFunctionalInterface(t)) {
+      if (!t.getModifiers().contains(Modifier.ABSTRACT)) {
+        APContext.logError(type, "@AssistFactory targets must be abstract");
+      } else if (checkAbstractMethodCount(methods)) {
+        APContext.logError(type, "@AssistFactory targets must have only one abstract method");
+      }
     }
+    var sb = new StringBuilder(String.format("@AssistFactory targets for type %s must have an abstract method with form '%s <methodName>(", shortName(), shortName()));
+    var assistNames = new ArrayList<String>();
+    for (var iterator = assistedElements.iterator(); iterator.hasNext(); ) {
+      var element = iterator.next();
+      var typeName = UType.parse(element.asType());
+      sb.append(String.format("%s %s", typeName.shortWithoutAnnotations(), element.getSimpleName()));
+      if (iterator.hasNext()) {
+        sb.append(", ");
+      }
+      assistNames.add(String.format("%s %s", typeName.shortWithoutAnnotations(), element.getSimpleName()));
+    }
+    var errorMsg = sb.append(")' method.").toString();
+
+    Optional.ofNullable(factoryMethod).stream()
+      .map(ExecutableElement::getParameters)
+      .findAny()
+      .ifPresentOrElse(params -> {
+          var mismatched = params.size() != assistedElements.size();
+          if (mismatched) {
+            APContext.logError(t, errorMsg);
+            return;
+          }
+
+          var paramTypes = params.stream()
+            .map(v -> String.format("%s %s", UType.parse(v.asType()).shortWithoutAnnotations(), v.getSimpleName()))
+            .collect(toSet());
+
+          var missingParams = assistNames.stream().filter(not(paramTypes::contains)).collect(joining(", "));
+          if (!missingParams.isBlank()) {
+            APContext.logError(factoryMethod, "factory method missing required parameters: %s", missingParams);
+          }
+        },
+        () -> APContext.logError(t, errorMsg));
+  }
+
+  private static boolean checkAbstractMethodCount(List<ExecutableElement> methods) {
+    return methods.stream()
+      .filter(e -> e.getModifiers().contains(Modifier.ABSTRACT))
+      .count() != 1;
   }
 
   @Override
@@ -90,7 +141,8 @@ final class AssistBeanReader {
 
   private Set<String> importTypes() {
     importTypes.add("io.avaje.inject.AssistFactory");
-    importTypes.add(targetType.getQualifiedName().toString());
+    Optional.ofNullable(targetType).ifPresent(t -> importTypes.add(t.getQualifiedName().toString()));
+
     if (Util.validImportType(type)) {
       importTypes.add(type);
     }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java b/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
index 1e828b01..316a63aa 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Processor.java
@@ -8,6 +8,7 @@ import javax.lang.model.SourceVersion;
 import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.util.ElementFilter;
 import javax.lang.model.util.Elements;
@@ -35,7 +36,8 @@ import static io.avaje.inject.generator.ProcessingContext.*;
   Constants.TESTSCOPE,
   Constants.CONTROLLER,
   ImportPrism.PRISM_TYPE,
-  AspectImportPrism.PRISM_TYPE
+  AspectImportPrism.PRISM_TYPE,
+  QualifierPrism.PRISM_TYPE
 })
 public final class Processor extends AbstractProcessor {
 
@@ -103,6 +105,11 @@ public final class Processor extends AbstractProcessor {
 
     readBeans(delayedElements());
     addImportedAspects(importedAspects(roundEnv));
+    maybeElements(roundEnv, QualifierPrism.PRISM_TYPE).stream()
+      .flatMap(Set::stream)
+      .flatMap(e -> ElementFilter.methodsIn(e.getEnclosedElements()).stream())
+      .forEach(this::validateQualifier);
+
     maybeElements(roundEnv, ScopePrism.PRISM_TYPE).ifPresent(this::readScopes);
     maybeElements(roundEnv, FactoryPrism.PRISM_TYPE).ifPresent(this::readFactories);
 
@@ -128,6 +135,19 @@ public final class Processor extends AbstractProcessor {
     return false;
   }
 
+  private void validateQualifier(ExecutableElement method) {
+    var type = APContext.asTypeElement(method.getReturnType());
+    if (type == null || type.getKind() != ElementKind.ANNOTATION_TYPE) {
+      return;
+    }
+
+    var enclosedMethods = ElementFilter.methodsIn(type.getEnclosedElements());
+    if (enclosedMethods.size() > 1) {
+      APContext.logError(method, "Qualifier annotation members can only have a single attribute");
+    }
+    enclosedMethods.forEach(this::validateQualifier);
+  }
+
   // Optional because these annotations are not guaranteed to exist
   private static Optional<? extends Set<? extends Element>> maybeElements(RoundEnvironment round, String name) {
     return Optional.ofNullable(typeElement(name)).map(round::getElementsAnnotatedWith);
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
index 413794bd..c05f3a7c 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/SimpleAssistWriter.java
@@ -1,19 +1,20 @@
 package io.avaje.inject.generator;
 
-import static io.avaje.inject.generator.APContext.createSourceFile;
-import static java.util.function.Predicate.not;
+import io.avaje.inject.generator.MethodReader.MethodParam;
 
+import javax.lang.model.element.*;
+import javax.tools.JavaFileObject;
 import java.io.IOException;
 import java.io.Writer;
 import java.util.List;
 import java.util.stream.Collectors;
 
-import javax.lang.model.element.*;
-import javax.tools.JavaFileObject;
-
-import io.avaje.inject.generator.MethodReader.MethodParam;
+import static io.avaje.inject.generator.APContext.createSourceFile;
+import static java.util.function.Predicate.not;
 
-/** Write the source code for the bean. */
+/**
+ * Write the source code for the bean.
+ */
 final class SimpleAssistWriter {
 
   private static final String CODE_COMMENT = "/**\n * Generated source - Factory for %s.\n */";
@@ -25,14 +26,20 @@ final class SimpleAssistWriter {
   private final String suffix;
   private Append writer;
   private final List<Element> assistedElements;
+  private final boolean hasNoConstructorParams;
 
-    SimpleAssistWriter(AssistBeanReader beanReader) {
+  SimpleAssistWriter(AssistBeanReader beanReader) {
     this.beanReader = beanReader;
     this.packageName = beanReader.packageName();
     this.shortName = beanReader.shortName();
     this.suffix = "$AssistFactory";
     this.assistedElements = beanReader.assistElements();
     this.originName = packageName + "." + shortName;
+    this.hasNoConstructorParams =
+      beanReader.constructor().params().stream()
+        .filter(not(MethodParam::assisted))
+        .findAny()
+        .isEmpty();
   }
 
   private Writer createFileWriter() throws IOException {
@@ -110,7 +117,9 @@ final class SimpleAssistWriter {
       var type = UType.parse(element.asType());
       writer.append("  %s %s$field;", type.shortType(), field.fieldName()).eol().eol();
     }
-    writer.eol();
+    if (beanReader.injectMethods().isEmpty() && hasNoConstructorParams) {
+      writer.eol();
+    }
   }
 
   private void writeMethodFields() {
@@ -120,15 +129,13 @@ final class SimpleAssistWriter {
     beanReader.injectMethods().stream()
       .flatMap(m -> m.params().stream())
       .filter(not(MethodParam::assisted))
-      .forEach(
-        p -> {
-          var element = p.element();
-          writer
-            .append("  private %s %s$method;", UType.parse(element.asType()).shortType(), p.simpleName())
-            .eol()
-            .eol();
-        });
-    writer.eol();
+      .forEach(p -> {
+        var element = p.element();
+        writer.append("  private %s %s$method;", UType.parse(element.asType()).shortType(), p.simpleName()).eol();
+      });
+    if (hasNoConstructorParams) {
+      writer.eol();
+    }
   }
 
   private void writeConstructor() {
@@ -146,7 +153,7 @@ final class SimpleAssistWriter {
     if (beanReader.beanType().getNestingKind().isNested()) {
       shortName = shortName.replace(".", "$");
     }
-    writer.append("  ").append(shortName).append(suffix).append("(");
+    writer.eol().append("  ").append(shortName).append(suffix).append("(");
 
     for (var iterator = injectParams.iterator(); iterator.hasNext(); ) {
       var p = iterator.next();
@@ -170,7 +177,7 @@ final class SimpleAssistWriter {
     for (MethodParam p : injectParams) {
       var element = p.element();
       var type = UType.parse(element.asType()).shortType();
-      writer.append("  private final %s %s;", type, p.simpleName()).eol().eol();
+      writer.append("  private final %s %s;", type, p.simpleName()).eol();
     }
   }
 
@@ -229,9 +236,13 @@ final class SimpleAssistWriter {
       writer.indent("    ").append("bean.%s = %s;", fieldName, getDependency).eol();
     }
 
-    for (var field : assistedElements) {
-      writer.indent("    ").append("bean.%s = %s;", field.getSimpleName(), field.getSimpleName()).eol();
-    }
+    assistedElements.stream()
+      .filter(e -> e.getKind() == ElementKind.FIELD)
+      .forEach(field ->
+        writer
+          .indent("    ")
+          .append("bean.%s = %s;", field.getSimpleName(), field.getSimpleName())
+          .eol());
   }
 
   private void injectMethods() {
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeAnnotationReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeAnnotationReader.java
index d2dba6e6..1109700c 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeAnnotationReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeAnnotationReader.java
@@ -1,30 +1,23 @@
 package io.avaje.inject.generator;
 
-import static io.avaje.inject.generator.APContext.logWarn;
-
-import java.util.ArrayList;
-import java.util.List;
-
 import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.DeclaredType;
 
+import static io.avaje.inject.generator.APContext.logWarn;
+
 /**
  * Read the annotations on the type.
  */
 final class TypeAnnotationReader {
 
   private final TypeElement beanType;
-  private final List<String> annotationTypes = new ArrayList<>();
   private String qualifierName;
 
   TypeAnnotationReader(TypeElement beanType) {
     this.beanType = beanType;
   }
 
-  List<String> annotationTypes() {
-    return annotationTypes;
-  }
 
   boolean hasQualifierName() {
     return qualifierName != null;
@@ -38,13 +31,16 @@ final class TypeAnnotationReader {
     for (AnnotationMirror annotationMirror : beanType.getAnnotationMirrors()) {
       DeclaredType annotationType = annotationMirror.getAnnotationType();
       String annType = annotationType.toString();
-      
+
       if (QualifierPrism.isPresent(annotationType.asElement())) {
-        qualifierName = Util.shortName(annType).toLowerCase();
+        var shortName = Util.shortName(annotationType.toString());
+        qualifierName = AnnotationCopier.toSimpleAnnotationString(annotationMirror)
+          .replaceFirst(annotationType.toString(), shortName)
+          .replace("\"", "\\\"")
+          .toLowerCase();
+
       } else if (annType.indexOf('.') == -1) {
         logWarn("skip when no package on annotation " + annType);
-      } else if (IncludeAnnotations.include(annType)) {
-        annotationTypes.add(annType);
       }
     }
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
index 822c3864..5e8c87e4 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeExtendsReader.java
@@ -1,11 +1,8 @@
 package io.avaje.inject.generator;
 
 import static io.avaje.inject.generator.APContext.logWarn;
-import static io.avaje.inject.generator.APContext.typeElement;
 import static io.avaje.inject.generator.APContext.types;
 import static io.avaje.inject.generator.ProcessingContext.asElement;
-import static java.util.stream.Collectors.toList;
-
 import java.lang.annotation.Annotation;
 import java.util.ArrayList;
 import java.util.List;
@@ -33,7 +30,6 @@ final class TypeExtendsReader {
   private final List<UType> interfaceTypes = new ArrayList<>();
   private final List<UType> providesTypes = new ArrayList<>();
   private final String beanSimpleName;
-  private final String baseTypeRaw;
   private final boolean baseTypeIsInterface;
   private final boolean publicAccess;
   private final boolean autoProvide;
@@ -50,7 +46,6 @@ final class TypeExtendsReader {
     this.baseType = baseType;
     this.extendsInjection = new TypeExtendsInjection(baseType, factory, importTypes);
     this.beanSimpleName = baseType.getSimpleName().toString();
-    this.baseTypeRaw = Util.unwrapProvider(baseUType.toString());
     this.baseTypeIsInterface = baseType.getKind() == ElementKind.INTERFACE;
     this.publicAccess = baseType.getModifiers().contains(Modifier.PUBLIC);
     this.autoProvide = autoProvide();
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Util.java b/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
index b5b6ba4c..c94e48ea 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
@@ -1,12 +1,12 @@
 package io.avaje.inject.generator;
 
-import java.util.Optional;
-
 import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.Element;
 import javax.lang.model.type.DeclaredType;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
+import java.util.Optional;
+import java.util.regex.Pattern;
 
 final class Util {
   static final String ASPECT_PROVIDER_PREFIX = "io.avaje.inject.aop.AspectProvider<";
@@ -74,6 +74,7 @@ final class Util {
     pos = cls.lastIndexOf('.', pos - 1);
     return (pos == -1) ? "" : cls.substring(0, pos);
   }
+
   static String unwrapProvider(String maybeProvider) {
     if (isProvider(maybeProvider)) {
       return extractProviderType(maybeProvider);
@@ -116,8 +117,8 @@ final class Util {
       for (final String part : fullType.split("\\.")) {
         char firstChar = part.charAt(0);
         if (foundClass
-            || Character.isUpperCase(firstChar)
-            || (!Character.isAlphabetic(firstChar) && Character.isJavaIdentifierStart(firstChar))) {
+          || Character.isUpperCase(firstChar)
+          || (!Character.isAlphabetic(firstChar) && Character.isJavaIdentifierStart(firstChar))) {
           foundClass = true;
           result += (result.isEmpty() ? "" : ".") + part;
         }
@@ -203,7 +204,9 @@ final class Util {
     return UtilType.of(rawType.toString(), rawType);
   }
 
-  /** Trim off generic wildcard from the raw type if present. */
+  /**
+   * Trim off generic wildcard from the raw type if present.
+   */
   static String trimWildcard(String rawType) {
     if (rawType.endsWith("<?>")) {
       return rawType.substring(0, rawType.length() - 3);
@@ -212,7 +215,9 @@ final class Util {
     }
   }
 
-  /** Trim off generic type parameters. */
+  /**
+   * Trim off generic type parameters.
+   */
   static String trimGenericParams(String rawType) {
     int start = rawType.indexOf('<');
     // no package for any generic parameter types
@@ -242,7 +247,9 @@ final class Util {
     return Constants.ASPECT_PROVIDER + "<" + aspect + ">";
   }
 
-  /** Return the common parent package. */
+  /**
+   * Return the common parent package.
+   */
   static String commonParent(String currentTop, String aPackage) {
     if (aPackage == null) return currentTop;
     if (currentTop == null) return aPackage;
@@ -263,8 +270,10 @@ final class Util {
     return currentTop;
   }
 
-  /** Return the name via <code>@Named</code> or a Qualifier annotation. */
-  public static String getNamed(Element p) {
+  /**
+   * Return the name via <code>@Named</code> or a Qualifier annotation.
+   */
+  static String getNamed(Element p) {
     final NamedPrism named = NamedPrism.getInstanceOn(p);
     if (named != null) {
       return named.value().toLowerCase();
@@ -273,14 +282,21 @@ final class Util {
       final DeclaredType annotationType = annotationMirror.getAnnotationType();
       final var hasQualifier = QualifierPrism.isPresent(annotationType.asElement());
       if (hasQualifier) {
-        return Util.shortName(annotationType.toString()).toLowerCase();
+        var shortName = Util.shortName(annotationType.toString());
+
+        return AnnotationCopier.toSimpleAnnotationString(annotationMirror)
+          .replaceFirst(annotationType.toString(), shortName)
+          .replace("\"", "\\\"")
+          .toLowerCase();
       }
     }
     return null;
   }
 
-  /** Return true if the element has a Nullable annotation. */
-  public static boolean isNullable(Element p) {
+  /**
+   * Return true if the element has a Nullable annotation.
+   */
+  static boolean isNullable(Element p) {
     for (final AnnotationMirror mirror : p.getAnnotationMirrors()) {
       if (NULLABLE.equals(shortName(mirror.getAnnotationType().toString()))) {
         return true;
@@ -289,7 +305,7 @@ final class Util {
     return false;
   }
 
-  public static Optional<DeclaredType> getNullableAnnotation(Element p) {
+  static Optional<DeclaredType> getNullableAnnotation(Element p) {
     for (final AnnotationMirror mirror : p.getAnnotationMirrors()) {
       if (NULLABLE.equals(shortName(mirror.getAnnotationType().toString()))) {
         return Optional.of(mirror.getAnnotationType());
@@ -298,14 +314,20 @@ final class Util {
     return Optional.empty();
   }
 
-  public static String addForInterface(String interfaceType) {
+  static String addForInterface(String interfaceType) {
     if (interfaceType.contains("<")) {
       return null;
     }
     return shortName(interfaceType);
   }
 
-  public static String trimMethod(String method) {
+  static String trimMethod(String method) {
     return shortMethod(method).replace('.', '_').replace(Constants.DI, "");
   }
+
+  private static final Pattern ANNOTATION_TYPE_PATTERN = Pattern.compile("@([\\w.]+)\\.");
+
+  static String trimAnnotationString(String input) {
+    return ANNOTATION_TYPE_PATTERN.matcher(input).replaceAll("@");
+  }
 }
diff --git a/inject/src/main/java/io/avaje/inject/AssistFactory.java b/inject/src/main/java/io/avaje/inject/AssistFactory.java
index b0e263cc..e10e1473 100644
--- a/inject/src/main/java/io/avaje/inject/AssistFactory.java
+++ b/inject/src/main/java/io/avaje/inject/AssistFactory.java
@@ -6,42 +6,37 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Annotate a bean which we want avaje-inject to generate the factory for.
- * <p>
- * The bean will have some properties that are normal dependency injection
- * components that will be injected and others that will be parameters
- * to the method on the factory - these parameters are annotated with
+ * Annotate a bean for which we want avaje-inject to generate a factory.
+ *
+ * <p>The bean will have some properties of normal dependency injection components and others that
+ * will be parameters to the method on the factory - these parameters are annotated with
  * {@code @Assisted}.
  *
  * <h3>Example</h3>
- * <p>
- * Have an interface which we want avaje-inject to generate an implementation.
- * We desire this because the factory has some dependencies that are components
- * that avaje-inject should inject for us and some other dependencies that are
- * provided by the application (and annotated with {@code Assisted}).
- * <p>
- * The factory interface must only have 1 method defined.
  *
- * <pre>{@code
+ * <p>Create an interface which we want avaje-inject to generate an implementation. We desire this
+ * because the factory has some managed dependencies that should be injected for us and some
+ * dependencies that are provided by the application (and annotated with {@code Assisted}).
  *
+ * <p>The factory supertype must be a functional interface, or an abstract class with only one
+ * abstract method defined.
+ *
+ * <pre>{@code
  * public interface CssFactory {
  *
  *   Scanner scanner(Path myPath);
  * }
  *
  * }</pre>
- * <p>
- * Have a bean annotated with {@code @AssistFactory} that specifies the factory.
- * Any dependencies that are annotated with {@code Assisted} will be parameters
- * of the factory method, the other dependencies are normal components that
- * will be injected.
- * <p>
- * The {@code Assisted} parameters must match the factory method parameters
- * by name and type. In this example {@code Path myPath} match in both CssScanner
- * and in CssFactory.
  *
- * <pre>{@code
+ * <p>Create a bean annotated with {@code @AssistFactory} that specifies the factory. Any
+ * dependencies that are annotated with {@code Assisted} will be parameters of the factory method,
+ * the other dependencies will be managed and injected by avaje-inject.
+ *
+ * <p>The {@code Assisted} parameters must match the factory method parameters by name and type. In
+ * this example, {@code Path myPath} match in both CssScanner and in CssFactory.
  *
+ * <pre>{@code
  * @AssistFactory(CssFactory.class)
  * class CssScanner implements Scanner {
  *
@@ -62,8 +57,6 @@ import java.lang.annotation.Target;
 @Retention(RetentionPolicy.RUNTIME)
 public @interface AssistFactory {
 
-  /**
-   * Specify the factory interface for which the implementation will be generated.
-   */
+  /** Specify the factory interface for which the implementation will be generated. */
   Class<?> value();
 }
diff --git a/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java b/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
index f2c5622b..d3fb9194 100644
--- a/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
+++ b/inject/src/main/java/io/avaje/inject/spi/DBeanMap.java
@@ -139,8 +139,15 @@ final class DBeanMap {
     if (parent == null) {
       return map(type);
     }
-    Map<String, Object> result = parent.map(type);
-    result.putAll(map(type));
+    Map<String, Object> parentMap = parent.map(type);
+    Map<String, Object> localMap = map(type);
+    if (parentMap.isEmpty()) {
+      return localMap;
+    } else if (localMap.isEmpty()) {
+      return parentMap;
+    }
+    Map<String, Object> result = new LinkedHashMap<>(parentMap);
+    result.putAll(localMap);
     return result;
   }
 


Test patch:
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java
new file mode 100644
index 00000000..03ad6b78
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACar.java
@@ -0,0 +1,30 @@
+package org.example.myapp.assist.droid;
+
+import java.util.List;
+
+import io.avaje.inject.AssistFactory;
+import io.avaje.inject.Assisted;
+import io.avaje.lang.Nullable;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+@Named("tomato")
+@AssistFactory(ACarFactory.class)
+public class ACar {
+
+  final Paint paint;
+  final Engine engine;
+  @Assisted List<String> type;
+  @Inject Wheel wheel;
+  Radio radio;
+
+  public ACar(@Assisted Paint paint, @Nullable Engine engine) {
+    this.paint = paint;
+    this.engine = engine;
+  }
+
+  @Inject
+  void injectMethod(@Assisted int size, Radio radio) {
+    this.radio = radio;
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java
new file mode 100644
index 00000000..3a848d9e
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarFactory.java
@@ -0,0 +1,10 @@
+package org.example.myapp.assist.droid;
+
+import java.util.List;
+
+public abstract class ACarFactory {
+
+  public abstract ACar construct(Paint paint, int size, List<String> type);
+
+  public void nonFactory() {}
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java
new file mode 100644
index 00000000..f821b0d6
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/ACarThing.java
@@ -0,0 +1,19 @@
+package org.example.myapp.assist.droid;
+
+import jakarta.inject.Singleton;
+
+import java.util.List;
+
+@Singleton
+class ACarThing {
+
+  final ACarFactory factory;
+
+  ACarThing(ACarFactory factory) {
+    this.factory = factory;
+  }
+
+  ACar doIt(Paint paint, int size, List<String> other) {
+    return factory.construct(paint, size, other);
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
index 00fadb5c..e32b4056 100644
--- a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/DroidFactory.java
@@ -4,6 +4,8 @@ public interface DroidFactory {
 
   Droid createDroid(int personality, Model model);
 
+  default void defaultMethod() {}
+
   interface Droid {
 
     int personality();
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java
new file mode 100644
index 00000000..dc983973
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Wheel.java
@@ -0,0 +1,6 @@
+package org.example.myapp.assist.droid;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+public class Wheel {}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java
new file mode 100644
index 00000000..a13c0a6d
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/assist/droid/ACarTest.java
@@ -0,0 +1,27 @@
+package org.example.myapp.assist.droid;
+
+import io.avaje.inject.BeanScope;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ACarTest {
+
+  @Test
+  void test() {
+    try (BeanScope testScope = BeanScope.builder().build()) {
+      ACarThing carThing = testScope.get(ACarThing.class);
+      Paint p = new Paint() {};
+      List<String> stringList = List.of("a");
+      ACar aCar = carThing.doIt(p, 42, stringList);
+
+      assertThat(aCar.paint).isSameAs(p);
+      assertThat(aCar.type).isSameAs(stringList);
+      assertThat(aCar.engine).isNotNull();
+      assertThat(aCar.wheel).isNotNull();
+      assertThat(aCar.radio).isNotNull();
+    }
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/ImperialMeter.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/ImperialMeter.java
new file mode 100644
index 00000000..329444a2
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/ImperialMeter.java
@@ -0,0 +1,14 @@
+package io.avaje.inject.generator.models.valid.qualifier;
+
+import io.avaje.inject.generator.models.valid.qualifier.TempQualifier.Scale;
+import jakarta.inject.Singleton;
+
+@Singleton
+@TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far")
+public class ImperialMeter implements Thermometer {
+
+  @Override
+  public boolean freezing(float temp) {
+    return temp <= 32;
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Meters.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Meters.java
new file mode 100644
index 00000000..8ae4272a
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Meters.java
@@ -0,0 +1,19 @@
+package io.avaje.inject.generator.models.valid.qualifier;
+
+import io.avaje.inject.generator.models.valid.qualifier.TempQualifier.Scale;
+import jakarta.inject.Singleton;
+
+@Singleton
+public class Meters {
+
+  Thermometer imperial;
+
+  Thermometer metric;
+
+  public Meters(
+      @TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far") Thermometer imperial,
+      @TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi") Thermometer metric) {
+    this.imperial = imperial;
+    this.metric = metric;
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/MetricMeter.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/MetricMeter.java
new file mode 100644
index 00000000..f1a66289
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/MetricMeter.java
@@ -0,0 +1,14 @@
+package io.avaje.inject.generator.models.valid.qualifier;
+
+import io.avaje.inject.generator.models.valid.qualifier.TempQualifier.Scale;
+import jakarta.inject.Singleton;
+
+@Singleton
+@TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi")
+public class MetricMeter implements Thermometer {
+
+  @Override
+  public boolean freezing(float temp) {
+    return temp <= 0;
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/TempQualifier.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/TempQualifier.java
new file mode 100644
index 00000000..09f69980
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/TempQualifier.java
@@ -0,0 +1,29 @@
+package io.avaje.inject.generator.models.valid.qualifier;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Qualifier;
+
+@Qualifier
+@Retention(RetentionPolicy.RUNTIME)
+public @interface TempQualifier {
+  Scale[] value();
+
+  String someOtherString();
+
+  int defaultVal() default 0;
+
+  NestedAnnotation[] inject() default {@NestedAnnotation()};
+
+  enum Scale {
+    CELSIUS,
+    FAHRENHEIT,
+  }
+
+  @interface NestedAnnotation {
+
+    Inject[] inject() default {};
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Thermometer.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Thermometer.java
new file mode 100644
index 00000000..bc1a8d80
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/qualifier/Thermometer.java
@@ -0,0 +1,6 @@
+package io.avaje.inject.generator.models.valid.qualifier;
+
+public interface Thermometer {
+
+  boolean freezing(float temp);
+}
diff --git a/inject-test/src/main/java/io/avaje/inject/test/AnnotationReader.java b/inject-test/src/main/java/io/avaje/inject/test/AnnotationReader.java
new file mode 100644
index 00000000..3b310769
--- /dev/null
+++ b/inject-test/src/main/java/io/avaje/inject/test/AnnotationReader.java
@@ -0,0 +1,73 @@
+package io.avaje.inject.test;
+
+import java.util.Collections;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+final class AnnotationReader {
+  private AnnotationReader() {
+  }
+
+  private static final Pattern ANNOTATION_TYPE_PATTERN = Pattern.compile("@([\\w.]+)\\.");
+
+  private static final Pattern ANNOTATION_TYPE_PATTERN$ = Pattern.compile("@[\\w.]+\\$");
+
+  private static final Pattern VALUE_ATTRIBUTE_PATTERN = Pattern.compile("(\\w+)=([\\w.]+)");
+
+  static String simplifyAnnotation(String input) {
+    String result = ANNOTATION_TYPE_PATTERN$.matcher(input).replaceAll("@");
+    result = ANNOTATION_TYPE_PATTERN.matcher(result).replaceAll("@");
+
+    Matcher valueAttributeMatcher = VALUE_ATTRIBUTE_PATTERN.matcher(result);
+
+    // Replace package names in any attribute value with only the class name
+    StringBuilder sb = new StringBuilder();
+    while (valueAttributeMatcher.find()) {
+      valueAttributeMatcher.appendReplacement(
+        sb, valueAttributeMatcher.group(1) + "=" + shortType(valueAttributeMatcher.group(2)));
+    }
+    valueAttributeMatcher.appendTail(sb);
+    return rearrangeAnnotations(sb.toString());
+  }
+
+  static String shortType(String fqn) {
+    final int dotIndex = fqn.lastIndexOf('.');
+    final int $index = fqn.lastIndexOf('$');
+
+    if (dotIndex == -1 && $index == -1) {
+      return fqn;
+    } else if ($index != -1) {
+      return fqn.substring($index + 1);
+    }
+    return fqn.substring(dotIndex + 1);
+  }
+
+  static String rearrangeAnnotations(String input) {
+    final var indexOfParanthesis = input.indexOf('(');
+    if (indexOfParanthesis == -1) {
+      return input;
+    }
+    // Extracting annotation content
+    var annotationContent = input.substring(indexOfParanthesis + 1, input.lastIndexOf(')'));
+
+    // Splitting annotations
+    String[] annotations = annotationContent.split(",\\s*");
+
+    // Creating a TreeMap to store annotations sorted by key
+    var sortedkeys = new TreeSet<String>();
+
+    // Sorting by key
+    Collections.addAll(sortedkeys, annotations);
+
+    // Constructing the sorted annotation string
+    StringBuilder sortedOutput = new StringBuilder(input.substring(0, indexOfParanthesis + 1));
+    for (var key : sortedkeys) {
+      sortedOutput.append(key).append(", ");
+    }
+    // Removing the trailing comma and space
+    sortedOutput.setLength(sortedOutput.length() - 2);
+    sortedOutput.append(")");
+    return sortedOutput.toString();
+  }
+}
diff --git a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
index aae30e77..24824be4 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/GlobalTestScope.java
@@ -18,9 +18,9 @@ final class GlobalTestScope implements ExtensionContext.Store.CloseableResource
 
   private final ReentrantLock lock = new ReentrantLock();
   private boolean started;
-  private BeanScope globalBeanScope;
+  private Pair globalBeanScope;
 
-  BeanScope obtain(ExtensionContext context) {
+  Pair obtain(ExtensionContext context) {
     lock.lock();
     try {
       if (!started) {
@@ -34,11 +34,9 @@ final class GlobalTestScope implements ExtensionContext.Store.CloseableResource
   }
 
   private void initialise(ExtensionContext context) {
-    globalBeanScope = TestBeanScope.init(false);
-    if (globalBeanScope != null) {
-      log.log(TRACE, "register global test BeanScope with beans {0}", globalBeanScope);
-      context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL).put(InjectExtension.class.getCanonicalName(), this);
-    }
+    globalBeanScope = TSBuild.initialise(false);
+    log.log(TRACE, "register global test BeanScope with beans {0}", globalBeanScope);
+    context.getRoot().getStore(ExtensionContext.Namespace.GLOBAL).put(InjectExtension.class.getCanonicalName(), this);
   }
 
   /**
@@ -57,4 +55,55 @@ final class GlobalTestScope implements ExtensionContext.Store.CloseableResource
     }
   }
 
+  /**
+   * The pair of BeanScopes that can be used for InjectTests.
+   */
+  static final class Pair {
+
+    /**
+     * Entire application wired (with testScope as parent replacing those beans).
+     * This can be used when a test only injects beans and there are no mocks,
+     * spies, or setup methods.
+     */
+    private final BeanScope allScope;
+
+    /**
+     * The TestScope beans, used as the parent scope when a new BeanScope
+     * needs to be wired for a test (due to mocks, spies or setup methods).
+     */
+    private final BeanScope baseScope;
+
+    Pair(BeanScope allScope, BeanScope baseScope) {
+      this.allScope = allScope;
+      this.baseScope = baseScope;
+    }
+
+    void close() {
+      if (allScope != null) {
+        allScope.close();
+      }
+      if (baseScope != null) {
+        baseScope.close();
+      }
+    }
+
+    BeanScope allScope() {
+      return allScope;
+    }
+
+    BeanScope baseScope() {
+      return baseScope;
+    }
+
+    Pair newPair(BeanScope newAllScope) {
+      return new Pair(newAllScope, baseScope);
+    }
+
+    @Override
+    public String toString() {
+      return "All[" + allScope + "] Test[" + baseScope + "]";
+    }
+
+  }
+
 }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java b/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
index a01c71fd..52bbca2e 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/InjectExtension.java
@@ -21,7 +21,7 @@ public final class InjectExtension implements BeforeAllCallback, AfterAllCallbac
   private static final String META = "META";
   private static final GlobalTestScope GLOBAL = new GlobalTestScope();
 
-  private BeanScope globalBeanScope;
+  private GlobalTestScope.Pair globalBeanScope;
 
   @Override
   public void beforeAll(ExtensionContext context) {
@@ -64,9 +64,8 @@ public final class InjectExtension implements BeforeAllCallback, AfterAllCallbac
     if (metaInfo.hasInstanceInjection()) {
 
       // if (static fields) then (class scope) else (globalTestScope)
-      final BeanScope parent = metaInfo.hasStaticInjection() ? getClassScope(context) : globalBeanScope;
-
-      AutoCloseable beanScope = metaInfo.buildForInstance(parent, context.getRequiredTestInstance());
+      final GlobalTestScope.Pair pair = metaInfo.hasStaticInjection() ? globalBeanScope.newPair(getClassScope(context)) : globalBeanScope;
+      AutoCloseable beanScope = metaInfo.buildForInstance(pair, context.getRequiredTestInstance());
 
       // put method level test scope
       Method testMethod = context.getRequiredTestMethod();
diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java
index 02d2ae63..7c785dc9 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/MetaInfo.java
@@ -27,38 +27,48 @@ final class MetaInfo {
   /**
    * Build for static fields class level scope.
    */
-  Scope buildForClass(BeanScope globalTestScope) {
+  Scope buildForClass(GlobalTestScope.Pair globalTestScope) {
     return buildSet(globalTestScope, null);
   }
 
   /**
    * Build test instance per test scope.
    */
-  Scope buildForInstance(BeanScope globalTestScope, Object testInstance) {
+  Scope buildForInstance(GlobalTestScope.Pair globalTestScope, Object testInstance) {
     return buildSet(globalTestScope, testInstance);
   }
 
-  private Scope buildSet(BeanScope parent, Object testInstance) {
-    final BeanScopeBuilder builder = BeanScope.builder();
-    if (parent != null) {
-      builder.parent(parent, false);
+  private Scope buildSet(GlobalTestScope.Pair parent, Object testInstance) {
+    // wiring profiles
+    String[] profiles = Optional.ofNullable(testInstance)
+      .map(Object::getClass)
+      .map(c -> c.getAnnotation(InjectTest.class))
+      .map(InjectTest::profiles)
+      .orElse(new String[0]);
+
+    boolean newScope = false;
+    final BeanScope beanScope;
+    if (profiles.length > 0 || reader.hasMocksOrSpies(testInstance)) {
+      // need to build a BeanScope for this using testScope() as the parent
+      final BeanScopeBuilder builder = BeanScope.builder();
+      if (parent != null) {
+        builder.parent(parent.baseScope(), false);
+        if (profiles.length > 0) {
+          builder.profiles(profiles);
+        }
+      }
+      // register mocks and spies local to this test
+      reader.build(builder, testInstance);
+      // wire with local mocks, spies, and globalTestScope
+      beanScope = builder.build();
+      newScope = true;
+    } else {
+      // just use the all scope
+      beanScope = parent.allScope();
     }
 
-    //set wiring profile
-    Optional.ofNullable(testInstance)
-        .map(Object::getClass)
-        .map(c -> c.getAnnotation(InjectTest.class))
-        .map(InjectTest::profiles)
-        .ifPresent(builder::profiles);
-
-    // register mocks and spies local to this test
-    reader.build(builder, testInstance);
-
-    // wire with local mocks, spies, and globalTestScope
-    final BeanScope beanScope = builder.build();
-
     // set inject, spy, mock fields from beanScope
-    return reader.setFromScope(beanScope, testInstance);
+    return reader.setFromScope(beanScope, testInstance, newScope);
   }
 
   /**
@@ -69,10 +79,12 @@ final class MetaInfo {
 
     private final BeanScope beanScope;
     private final Plugin.Scope pluginScope;
+    private final boolean newScope;
 
-    Scope(BeanScope beanScope, Plugin.Scope pluginScope) {
+    Scope(BeanScope beanScope, Plugin.Scope pluginScope, boolean newScope) {
       this.beanScope = beanScope;
       this.pluginScope = pluginScope;
+      this.newScope = newScope;
     }
 
     BeanScope beanScope() {
@@ -81,7 +93,9 @@ final class MetaInfo {
 
     @Override
     public void close() {
-      beanScope.close();
+      if (newScope) {
+        beanScope.close();
+      }
       if (pluginScope != null) {
         pluginScope.close();
       }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
index 0cf0e066..a1bc5a7d 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
@@ -48,6 +48,33 @@ final class MetaReader {
     }
   }
 
+  boolean hasMocksOrSpies(Object testInstance) {
+    if (testInstance == null) {
+      return hasStaticMocksOrSpies() || methodFinder.hasStaticMethods();
+    } else {
+      return hasInstanceMocksOrSpies(testInstance) || methodFinder.hasInstanceMethods();
+    }
+  }
+
+  private boolean hasInstanceMocksOrSpies(Object testInstance) {
+    return !mocks.isEmpty() || !spies.isEmpty() || hasInjectMock(injection, testInstance);
+  }
+
+  private boolean hasStaticMocksOrSpies() {
+    return !staticMocks.isEmpty() || !staticSpies.isEmpty() || hasInjectMock(staticInjection, null);
+  }
+
+  private boolean hasInjectMock(List<FieldTarget> fields, Object testInstance) {
+    for (FieldTarget target : fields) {
+      Object existingValue = target.get(testInstance);
+      if (existingValue != null) {
+        // an assigned injection field is a mock
+        return true;
+      }
+    }
+    return false;
+  }
+
   private static LinkedList<Class<?>> typeHierarchy(Class<?> testClass) {
     var hierarchy = new LinkedList<Class<?>>();
     var analyzedClass = testClass;
@@ -133,24 +160,28 @@ final class MetaReader {
       return named.value().toLowerCase();
     }
     for (Annotation annotation : field.getAnnotations()) {
-      for (Annotation metaAnnotation : annotation.annotationType().getAnnotations()) {
+      final var annotationType = annotation.annotationType();
+      for (Annotation metaAnnotation : annotationType.getAnnotations()) {
         if (metaAnnotation.annotationType().equals(Qualifier.class)) {
-          return annotation.annotationType().getSimpleName().toLowerCase();
+          return AnnotationReader.simplifyAnnotation(annotation.toString())
+            .replaceFirst(annotationType.getCanonicalName(), annotationType.getSimpleName())
+            .replace("()", "").substring(1)
+            .toLowerCase();
         }
       }
     }
     return null;
   }
 
-  MetaInfo.Scope setFromScope(BeanScope beanScope, Object testInstance) {
+  MetaInfo.Scope setFromScope(BeanScope beanScope, Object testInstance, boolean newScope) {
     if (testInstance != null) {
-      return setForInstance(beanScope, testInstance);
+      return setForInstance(beanScope, testInstance, newScope);
     } else {
-      return setForStatics(beanScope);
+      return setForStatics(beanScope, newScope);
     }
   }
 
-  private MetaInfo.Scope setForInstance(BeanScope beanScope, Object testInstance) {
+  private MetaInfo.Scope setForInstance(BeanScope beanScope, Object testInstance, boolean newScope) {
     try {
       Plugin.Scope pluginScope = instancePlugin ? plugin.createScope(beanScope) : null;
 
@@ -171,14 +202,14 @@ final class MetaReader {
           target.setFromScope(beanScope, testInstance);
         }
       }
-      return new MetaInfo.Scope(beanScope, pluginScope);
+      return new MetaInfo.Scope(beanScope, pluginScope, newScope);
 
     } catch (IllegalAccessException e) {
       throw new RuntimeException(e);
     }
   }
 
-  private MetaInfo.Scope setForStatics(BeanScope beanScope) {
+  private MetaInfo.Scope setForStatics(BeanScope beanScope, boolean newScope) {
     try {
       Plugin.Scope pluginScope = staticPlugin ? plugin.createScope(beanScope) : null;
 
@@ -196,7 +227,7 @@ final class MetaReader {
           target.setFromScope(beanScope, null);
         }
       }
-      return new MetaInfo.Scope(beanScope, pluginScope);
+      return new MetaInfo.Scope(beanScope, pluginScope, newScope);
     } catch (IllegalAccessException e) {
       throw new RuntimeException(e);
     }
diff --git a/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java b/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
index f889dd1e..ecbc1332 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/TSBuild.java
@@ -21,7 +21,7 @@ import java.util.concurrent.locks.ReentrantLock;
 final class TSBuild {
 
   private static final ReentrantLock lock = new ReentrantLock();
-  private static BeanScope SCOPE;
+  private static GlobalTestScope.Pair SCOPE;
 
   private final boolean shutdownHook;
 
@@ -30,19 +30,18 @@ final class TSBuild {
    * time this method is called.
    */
   @Nullable
-  static BeanScope create(boolean shutdownHook) {
+  static BeanScope createTestBaseScope(boolean shutdownHook) {
     return new TSBuild(shutdownHook).build();
   }
 
   /**
    * Return the test BeanScope only creating once.
    */
-  @Nullable
-  static BeanScope initialise(boolean shutdownHook) {
+  static GlobalTestScope.Pair initialise(boolean shutdownHook) {
     lock.lock();
     try {
       if (SCOPE == null) {
-        SCOPE = create(shutdownHook);
+        SCOPE = createScopes(shutdownHook);
       }
       return SCOPE;
     } finally {
@@ -54,6 +53,18 @@ final class TSBuild {
     this.shutdownHook = shutdownHook;
   }
 
+  private static GlobalTestScope.Pair createScopes(boolean shutdownHook) {
+    BeanScope testBaseScope = createTestBaseScope(shutdownHook);
+    BeanScope testAllScope = createTestAllScope(testBaseScope);
+    return new GlobalTestScope.Pair(testAllScope, testBaseScope);
+  }
+
+  private static BeanScope createTestAllScope(BeanScope testBaseScope) {
+      return BeanScope.builder()
+        .parent(testBaseScope, false)
+        .build();
+  }
+
   @Nullable
   private BeanScope build() {
     List<TestModule> testModules = new ArrayList<>();
diff --git a/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java b/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
index 767115af..6c7a698a 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/TestBeanScope.java
@@ -38,8 +38,7 @@ public abstract class TestBeanScope {
    * @return A new test BeanScope with the global "test scope" as its parent.
    */
   public static BeanScopeBuilder builder() {
-    BeanScope globalTestScope = init(true);
-    return BeanScope.builder().parent(globalTestScope, false);
+    return BeanScope.builder().parent(initialise(), false);
   }
 
   /**
@@ -60,7 +59,7 @@ public abstract class TestBeanScope {
    */
   @Nullable
   public static BeanScope initialise() {
-    return init(true);
+    return TSBuild.initialise(true).baseScope();
   }
 
   /**
@@ -74,12 +73,7 @@ public abstract class TestBeanScope {
    */
   @Nullable
   public static BeanScope create(boolean shutdownHook) {
-    return TSBuild.create(shutdownHook);
-  }
-
-  @Nullable
-  static BeanScope init(boolean shutdownHook) {
-    return TSBuild.initialise(shutdownHook);
+    return TSBuild.createTestBaseScope(shutdownHook);
   }
 
 }
diff --git a/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java b/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
index 321dbe67..8b28c025 100644
--- a/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
+++ b/inject-test/src/test/java/io/avaje/inject/test/MetaReaderTest.java
@@ -39,7 +39,7 @@ class MetaReaderTest {
     assertThat(metaReader.instancePlugin).isTrue();
 
     HelloBean helloBean = new HelloBean();
-    metaReader.setFromScope(Mockito.mock(BeanScope.class), helloBean);
+    metaReader.setFromScope(Mockito.mock(BeanScope.class), helloBean, true);
 
     assertThat(helloBean.client).isNotNull();
   }
diff --git a/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java b/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
index 283a2556..91dd0e47 100644
--- a/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
+++ b/inject-test/src/test/java/org/example/coffee/BeanScopeBuilderAddTest.java
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.verify;
 
-class BeanScopeBuilderAddTest {
+public class BeanScopeBuilderAddTest {
 
   @Test
   void withModules_excludingThisOne() {
@@ -28,7 +28,7 @@ class BeanScopeBuilderAddTest {
     }
   }
 
-  static class SillyModule implements Module {
+  public static class SillyModule implements Module {
 
     @Override
     public Class<?>[] requires() {
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java b/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
index 769ecc11..1dde3bfc 100644
--- a/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/StoreManagerWithNamedTest.java
@@ -2,6 +2,7 @@ package org.example.coffee.qualifier;
 
 import io.avaje.inject.BeanScope;
 import org.example.autonamed.MyAutoB2;
+import org.example.coffee.BeanScopeBuilderAddTest;
 import org.junit.jupiter.api.Test;
 
 import java.util.Map;
@@ -31,4 +32,25 @@ class StoreManagerWithNamedTest {
       assertThat(mapWithUnnamed).hasSize(1);
     }
   }
+
+  @Test
+  void mapParent() {
+    try (BeanScope parent = BeanScope.builder()
+      .bean(SomeStore.class, new LocalStore())
+      .modules(new BeanScopeBuilderAddTest.SillyModule())
+      .build()) {
+
+      try (BeanScope beanScope = BeanScope.builder().parent(parent).build()) {
+        Map<String, SomeStore> stores = beanScope.map(SomeStore.class);
+        assertThat(stores).hasSize(3);
+      }
+    }
+  }
+
+  static final class LocalStore implements SomeStore {
+    @Override
+    public String store() {
+      return "foo";
+    }
+  }
 }
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/ImperialMeter.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/ImperialMeter.java
new file mode 100644
index 00000000..b52293cc
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/ImperialMeter.java
@@ -0,0 +1,15 @@
+package org.example.coffee.qualifier.members;
+
+import org.example.coffee.qualifier.members.TempQualifier.Scale;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+@TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far")
+public class ImperialMeter implements Thermometer {
+
+  @Override
+  public boolean freezing(float temp) {
+    return temp <= 32;
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/Meters.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/Meters.java
new file mode 100644
index 00000000..5fb9daf7
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/Meters.java
@@ -0,0 +1,20 @@
+package org.example.coffee.qualifier.members;
+
+import org.example.coffee.qualifier.members.TempQualifier.Scale;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+public class Meters {
+
+  Thermometer imperial;
+
+  Thermometer metric;
+
+  public Meters(
+      @TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far") Thermometer imperial,
+      @TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi") Thermometer metric) {
+    this.imperial = imperial;
+    this.metric = metric;
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/MetricMeter.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/MetricMeter.java
new file mode 100644
index 00000000..de3297a9
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/MetricMeter.java
@@ -0,0 +1,15 @@
+package org.example.coffee.qualifier.members;
+
+import org.example.coffee.qualifier.members.TempQualifier.Scale;
+
+import jakarta.inject.Singleton;
+
+@Singleton
+@TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi")
+public class MetricMeter implements Thermometer {
+
+  @Override
+  public boolean freezing(float temp) {
+    return temp <= 0;
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/QualifiedMemberTest.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/QualifiedMemberTest.java
new file mode 100644
index 00000000..797ca5d2
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/QualifiedMemberTest.java
@@ -0,0 +1,20 @@
+package org.example.coffee.qualifier.members;
+
+import io.avaje.inject.test.InjectTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@InjectTest
+class QualifiedMemberTest {
+
+  @Inject
+  Meters meters;
+
+  @Test
+  void test() {
+    assertThat(meters.imperial).isInstanceOf(ImperialMeter.class);
+    assertThat(meters.metric).isInstanceOf(MetricMeter.class);
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/SpyMeterTest.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/SpyMeterTest.java
new file mode 100644
index 00000000..edeac9e8
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/SpyMeterTest.java
@@ -0,0 +1,66 @@
+package org.example.coffee.qualifier.members;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+
+import org.example.coffee.qualifier.members.TempQualifier.Scale;
+import org.junit.jupiter.api.Test;
+import org.mockito.Spy;
+
+import io.avaje.inject.BeanScope;
+import io.avaje.inject.test.InjectTest;
+import jakarta.inject.Inject;
+
+@InjectTest
+class SpyMeterTest {
+
+  private static final String METRIC =
+      "TempQualifier(defaultVal=0, inject={@NestedAnnotation(inject={})}, someOtherString=\"celsi\", value={CELSIUS})";
+  private static final String IMPERIAL =
+      "TempQualifier(defaultVal=0, inject={@NestedAnnotation(inject={})}, someOtherString=\"far\", value={FAHRENHEIT})";
+
+  @Spy
+  @TempQualifier(value = Scale.FAHRENHEIT, someOtherString = "far")
+  Thermometer imperial;
+
+  @Spy
+  @TempQualifier(value = Scale.CELSIUS, someOtherString = "celsi")
+  Thermometer metric;
+
+  @Inject Meters meters;
+
+  @Test
+  void spy_verify() {
+
+    assertThat(meters.imperial.freezing(32)).isTrue();
+
+    verify(imperial).freezing(32);
+
+    assertThat(meters.metric.freezing(32)).isFalse();
+
+    verify(metric).freezing(32);
+  }
+
+  @Test
+  void test() {
+
+    try (BeanScope beanScope =
+        BeanScope.builder()
+            .forTesting()
+            .spy(Thermometer.class, IMPERIAL)
+            .spy(Thermometer.class, METRIC)
+            .build()) {
+
+      final var meters = beanScope.get(Meters.class);
+      assertThat(meters.imperial.freezing(32)).isTrue();
+
+      final Thermometer imperial = beanScope.get(Thermometer.class, IMPERIAL);
+      verify(imperial).freezing(32);
+
+      assertThat(meters.metric.freezing(32)).isFalse();
+      final Thermometer metric = beanScope.get(Thermometer.class, METRIC);
+
+      verify(metric).freezing(32);
+    }
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/TempQualifier.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/TempQualifier.java
new file mode 100644
index 00000000..093d52a0
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/TempQualifier.java
@@ -0,0 +1,29 @@
+package org.example.coffee.qualifier.members;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Qualifier;
+
+@Qualifier
+@Retention(RetentionPolicy.RUNTIME)
+public @interface TempQualifier {
+  Scale[] value();
+
+  String someOtherString();
+
+  int defaultVal() default 0;
+
+  NestedAnnotation[] inject() default {@NestedAnnotation()};
+
+  enum Scale {
+    CELSIUS,
+    FAHRENHEIT,
+  }
+
+  @interface NestedAnnotation {
+
+    Inject[] inject() default {};
+  }
+}
diff --git a/inject-test/src/test/java/org/example/coffee/qualifier/members/Thermometer.java b/inject-test/src/test/java/org/example/coffee/qualifier/members/Thermometer.java
new file mode 100644
index 00000000..745e7172
--- /dev/null
+++ b/inject-test/src/test/java/org/example/coffee/qualifier/members/Thermometer.java
@@ -0,0 +1,6 @@
+package org.example.coffee.qualifier.members;
+
+public interface Thermometer {
+
+  boolean freezing(float temp);
+}