Repository: avaje/avaje-inject (java)

Issue:
Dependency information for qualified beans does not include qualified name, resulting in incorrect circular dependency detection
Given the following code:
```java
// test/IncorrectCircularDependency.java
package test;

import io.avaje.inject.Bean;
import io.avaje.inject.Factory;
import jakarta.inject.Named;
import jakarta.inject.Singleton;

@Singleton
@Factory
public class IncorrectCircularDependency {
    @Bean
    @Named("parent") // for clarity, not necessary
    public String parent(@Named("child") String child) {
        throw new AssertionError("Method body unimportant");
    }

    @Bean
    @Named("child")
    public String child() {
        throw new AssertionError("Method body unimportant");
    }
}
```
```java
// test/Test.java
package test;

import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
public class Test {
    private final String parent;

    @Inject
    public Test(@Named("parent") String parent) {
        this.parent = parent;
    }

    public String getParent() {
        return parent;
    }
}
```

Despite the clearly non-circular dependency from `parent` to `child`, due to the dependency of `parent` incorrectly not using the `child` qualifier and depending on itself, avaje-inject spits out this error:
```
error: Circular dependencies detected with beans [java.lang.String:parent, test.Test]  To handle circular dependencies consider using field injection rather than constructor injection on one of the dependencies. 
   See https://avaje.io/inject/#circular
error: Circular dependency - java.lang.String:parent dependsOn java.lang.String:parent for java.lang.String
error: Circular dependency - test.Test dependsOn java.lang.String:parent for java.lang.String
```

This should not be an error and should compile cleanly.

Pull request description:
Validate Qualifiers at compile time
It seems previously we only validated that all the bean _types_ were in the scope at compile time without looking at the qualifiers.

- Change generated code to append qualifiers to dependency metadata
- now will confirm that all qualified beans are in the scope when compiling

resolves #644 

Patch:
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
index d0110d1c..45fc12e5 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
@@ -1,6 +1,15 @@
 package io.avaje.inject.generator;
 
 import static io.avaje.inject.generator.APContext.logError;
+import static java.util.stream.Collectors.toList;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
 
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ExecutableElement;
@@ -9,9 +18,6 @@ import javax.lang.model.type.TypeKind;
 
 import io.avaje.inject.generator.MethodReader.MethodParam;
 
-import java.util.*;
-import java.util.stream.Stream;
-
 
 final class BeanReader {
 
@@ -188,11 +194,11 @@ final class BeanReader {
   }
 
   List<String> provides() {
-    return typeReader.provides();
+    return Util.addQualifierSuffix(typeReader.provides(), name);
   }
 
   List<String> autoProvides() {
-    return typeReader.autoProvides();
+    return Util.addQualifierSuffix(typeReader.autoProvides(), name);
   }
 
   String providesAspect() {
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Dependency.java b/inject-generator/src/main/java/io/avaje/inject/generator/Dependency.java
index 952afe82..a57a4600 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Dependency.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Dependency.java
@@ -9,26 +9,30 @@ final class Dependency {
   private boolean softDependency;
   private final boolean conditionalDependency;
 
-  Dependency(String name) {
+  Dependency(String type) {
+    this(type, "");
+  }
+
+  Dependency(String type, String qualifier) {
     String nameStr;
-    if (name.startsWith(SOFT_DEPENDENCY)) {
+    if (type.startsWith(SOFT_DEPENDENCY)) {
       this.softDependency = true;
       this.conditionalDependency = false;
-      nameStr = ProcessorUtils.trimAnnotations(name.substring(5));
-    } else if (name.startsWith(CONDITIONAL_DEPENDENCY)) {
+      nameStr = ProcessorUtils.trimAnnotations(type.substring(5));
+    } else if (type.startsWith(CONDITIONAL_DEPENDENCY)) {
       this.softDependency = true;
       this.conditionalDependency = true;
-      nameStr = ProcessorUtils.trimAnnotations(name.substring(4));
+      nameStr = ProcessorUtils.trimAnnotations(type.substring(4));
     } else {
       this.softDependency = false;
       this.conditionalDependency = false;
-      nameStr = ProcessorUtils.trimAnnotations(name);
+      nameStr = ProcessorUtils.trimAnnotations(type);
     }
-    this.name = nameStr.replace(", ", ",");
+    this.name = Util.addQualifierSuffixTrim(qualifier, nameStr);
   }
 
-  Dependency(String name, boolean softDependency) {
-    this.name = ProcessorUtils.trimAnnotations(name).replace(", ", ",");
+  Dependency(String name, String qualifier, boolean softDependency) {
+    this.name = Util.addQualifierSuffixTrim(qualifier, ProcessorUtils.trimAnnotations(name));
     this.softDependency = softDependency;
     this.conditionalDependency = false;
   }
@@ -64,7 +68,9 @@ final class Dependency {
     return toString();
   }
 
-  /** External dependency */
+  /**
+   * External dependency
+   */
   void markExternal() {
     softDependency = true;
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/EventPublisherWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/EventPublisherWriter.java
index b073c83b..a4b953be 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/EventPublisherWriter.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/EventPublisherWriter.java
@@ -46,25 +46,26 @@ final class EventPublisherWriter {
   private EventPublisherWriter(Element element) {
     final var asType = element.asType();
     this.utype = UType.parse(asType).param0();
-
-    this.packageName =
-        APContext.elements()
-                .getPackageOf(APContext.typeElement(utype.mainType()))
-                .getQualifiedName()
-                .toString()
-                .replaceFirst("java.", "")
-            + ".events";
+    this.packageName = APContext.elements()
+      .getPackageOf(APContext.typeElement(utype.mainType()))
+      .getQualifiedName()
+      .toString()
+      .replaceFirst("java.", "")
+      + ".events";
     qualifier = Optional.ofNullable(Util.getNamed(element)).orElse("");
     var className =
-        packageName
-            + "."
-            + (qualifier.isEmpty() ? "" : "Qualified")
-            + Util.shortName(utype).replace(".", "_")
-            + "$Publisher";
+      packageName
+        + "."
+        + (qualifier.isEmpty() ? "" : "Qualified")
+        + Util.shortName(utype).replace(".", "_")
+        + "$Publisher";
 
     originName = getUniqueClassName(className, 0);
 
     if (GENERATED_PUBLISHERS.containsKey(originName)) {
+      //in super niche situations when compiling the same module, we need to tell avaje that these types already exist
+      //got this when running both my eclipse compiler and then the terminal build
+      ProcessingContext.addOptionalType(UType.parse(asType).fullWithoutAnnotations(), Util.getNamed(element));
       return;
     }
     importTypes.addAll(utype.importTypes());
@@ -73,13 +74,10 @@ final class EventPublisherWriter {
   }
 
   private String getUniqueClassName(String className, Integer recursiveIndex) {
-
-    Optional.ofNullable(APContext.typeElement(className))
-        .ifPresent(
-            e ->
-                GENERATED_PUBLISHERS.put(
-                    e.getQualifiedName().toString(),
-                    Optional.ofNullable(Util.getNamed(e)).orElse("")));
+    Optional.ofNullable(APContext.typeElement(className)).ifPresent(e ->
+      GENERATED_PUBLISHERS.put(
+        e.getQualifiedName().toString(),
+        Optional.ofNullable(Util.getNamed(e)).orElse("")));
 
     if (Optional.ofNullable(GENERATED_PUBLISHERS.get(className))
         .filter(not(qualifier::equals))
@@ -99,18 +97,14 @@ final class EventPublisherWriter {
   void write() {
     try {
       var writer = new Append(createFileWriter());
-
       final var shortType = utype.shortWithoutAnnotations();
       var typeString = utype.isGeneric() ? getGenericType() : shortType + ".class";
 
       var name = qualifier.isBlank() ? "" : "@Named(\"" + qualifier + "\")\n";
       var className = originName.replace(packageName + ".", "");
-      writer.append(
-          MessageFormat.format(
-              TEMPLATE, packageName, imports(), name, className, shortType, typeString, qualifier));
+      writer.append(MessageFormat.format(TEMPLATE, packageName, imports(), name, className, shortType, typeString, qualifier));
       writer.close();
     } catch (Exception e) {
-      e.printStackTrace();
       logError("Failed to write EventPublisher class " + e);
     }
   }
@@ -125,7 +119,6 @@ final class EventPublisherWriter {
       importTypes.add(NamedPrism.PRISM_TYPE);
     }
     if (utype.isGeneric()) {
-
       importTypes.add("io.avaje.inject.spi.GenericType");
     }
 
@@ -146,13 +139,11 @@ final class EventPublisherWriter {
     return sb.toString();
   }
 
-  private void writeGenericType(
-      UType type, Map<String, String> seenShortNames, StringBuilder writer) {
+  private void writeGenericType(UType type, Map<String, String> seenShortNames, StringBuilder writer) {
     final var typeShortName = Util.shortName(type.mainType());
     final var mainType = seenShortNames.computeIfAbsent(typeShortName, k -> type.mainType());
     if (type.isGeneric()) {
-      final var shortName =
-          Objects.equals(type.mainType(), mainType) ? typeShortName : type.mainType();
+      final var shortName = Objects.equals(type.mainType(), mainType) ? typeShortName : type.mainType();
       writer.append(shortName);
       writer.append("<");
       boolean first = true;
@@ -167,8 +158,7 @@ final class EventPublisherWriter {
       }
       writer.append(">");
     } else {
-      final var shortName =
-          Objects.equals(type.mainType(), mainType) ? typeShortName : type.mainType();
+      final var shortName = Objects.equals(type.mainType(), mainType) ? typeShortName : type.mainType();
       writer.append(shortName);
     }
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/FieldReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/FieldReader.java
index 17629e7e..b300bd07 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/FieldReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/FieldReader.java
@@ -27,7 +27,7 @@ final class FieldReader {
     this.type = utype.toUType();
     this.assisted = AssistedPrism.isPresent(element);
     if (nullable || element.asType().toString().startsWith("java.util.Optional<")) {
-      ProcessingContext.addOptionalType(fieldType);
+      ProcessingContext.addOptionalType(fieldType, name);
     }
     if (type.fullWithoutAnnotations().startsWith("io.avaje.inject.events.Event")) {
       EventPublisherWriter.write(element);
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/InjectProcessor.java b/inject-generator/src/main/java/io/avaje/inject/generator/InjectProcessor.java
index 59b8a498..79e87beb 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/InjectProcessor.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/InjectProcessor.java
@@ -15,7 +15,6 @@ import javax.lang.model.util.ElementFilter;
 import javax.lang.model.util.Elements;
 
 import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toSet;
 
 import java.io.IOException;
 import java.nio.file.Files;
@@ -156,11 +155,12 @@ public final class InjectProcessor extends AbstractProcessor {
 
     maybeElements(roundEnv, ExternalPrism.PRISM_TYPE).stream()
       .flatMap(Set::stream)
-      .map(Element::asType)
-      .map(UType::parse)
-      .map(u -> "java.util.List".equals(u.mainType()) ? u.param0() : u)
-      .map(UType::fullWithoutAnnotations)
-      .forEach(ProcessingContext::addOptionalType);
+      .forEach(e -> {
+        var type = UType.parse(e.asType());
+        type = "java.util.List".equals(type.mainType()) ? type.param0() : type;
+        ProcessingContext.addOptionalType(type.fullWithoutAnnotations(), Util.getNamed(e));
+        ProcessingContext.addOptionalType(type.fullWithoutAnnotations(), null);
+      });
 
     maybeElements(roundEnv, "io.avaje.spi.ServiceProvider").ifPresent(this::registerSPI);
     allScopes.readBeans(roundEnv);
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/MetaData.java b/inject-generator/src/main/java/io/avaje/inject/generator/MetaData.java
index a4c774a3..4481cf16 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/MetaData.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/MetaData.java
@@ -1,9 +1,10 @@
 package io.avaje.inject.generator;
 
 import static io.avaje.inject.generator.APContext.typeElement;
-
+import static java.util.stream.Collectors.toList;
 import java.util.*;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 
 /**
@@ -46,9 +47,9 @@ final class MetaData {
     this.shortType = Util.shortName(type);
     this.method = meta.method();
     this.providesAspect = meta.providesAspect();
-    this.provides = meta.provides();
-    this.dependsOn = meta.dependsOn().stream().map(Dependency::new).collect(Collectors.toList());
-    this.autoProvides = meta.autoProvides();
+    this.dependsOn = meta.dependsOn().stream().map(d -> new Dependency(d, name)).collect(Collectors.toList());
+    this.provides = Util.addQualifierSuffix(meta.provides(), name);
+    this.autoProvides = Util.addQualifierSuffix(meta.autoProvides(), name);
     this.importedComponent = meta.importedComponent();
   }
 
@@ -249,6 +250,10 @@ final class MetaData {
   }
 
   private void appendProvides(Append sb, String attribute, List<String> types) {
+    if (!"dependsOn".equals(attribute)) {
+      types.removeIf(s -> s.contains(":"));
+    }
+
     sb.append(",").eol().append("      ").append(attribute).append(" = {");
     final var size = types.size();
     if (size > 1) {
@@ -256,14 +261,15 @@ final class MetaData {
     }
     var seen = new HashSet<String>();
     for (int i = 0; i < types.size(); i++) {
-      if (!seen.add(types.get(i))) {
+      final var depType = types.get(i);
+      if (!seen.add(depType)) {
         continue;
       }
       if (i > 0) {
         sb.append(",").eol().append("        ");
       }
       sb.append("\"");
-      sb.append(types.get(i));
+      sb.append(depType);
       sb.append("\"");
     }
     if (size > 1) {
@@ -276,7 +282,7 @@ final class MetaData {
     this.provides = provides;
   }
 
-  void setDependsOn(List<String> dependsOn) {
+  void setDependsOn(List<String> dependsOn, String name) {
     this.dependsOn = dependsOn.stream().map(Dependency::new).collect(Collectors.toList());
   }
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/MetaDataOrdering.java b/inject-generator/src/main/java/io/avaje/inject/generator/MetaDataOrdering.java
index 0c4d175b..87566684 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/MetaDataOrdering.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/MetaDataOrdering.java
@@ -6,6 +6,9 @@ import static io.avaje.inject.generator.ProcessingContext.elementMaybe;
 import static io.avaje.inject.generator.ProcessingContext.externallyProvided;
 
 import javax.lang.model.element.TypeElement;
+
+import static java.util.stream.Collectors.toList;
+
 import java.util.*;
 
 final class MetaDataOrdering {
@@ -34,6 +37,7 @@ final class MetaDataOrdering {
         queue.add(metaData);
       }
       // register into map keyed by provider
+      providerAdd(metaData.toString()).add(metaData);
       providerAdd(metaData.type()).add(metaData);
       for (String provide : metaData.provides()) {
         providerAdd(provide).add(metaData);
@@ -106,7 +110,7 @@ final class MetaDataOrdering {
 
   private MetaData findCircularDependency(List<MetaData> remainder, Dependency dependency) {
     for (MetaData metaData : remainder) {
-      if (metaData.type().equals(dependency.name())) {
+      if (metaData.toString().contains(dependency.name())) {
         return metaData;
       }
       final List<String> provides = metaData.provides();
@@ -155,13 +159,14 @@ final class MetaDataOrdering {
    */
   private void warnOnDependencies() {
     if (!missingDependencyTypes.isEmpty()) {
-      logError("Dependencies %s are not provided - missing @Singleton, @Component, @Factory/@Bean or specify external dependency via @InjectModule requires attribute", missingDependencyTypes);
-    } else {
-      if (!queue.isEmpty()) {
-        logWarn("There are " + queue.size() + " beans with unsatisfied dependencies (assuming external dependencies)");
-        for (MetaData m : queue) {
-          logWarn("Unsatisfied dependencies on %s dependsOn %s", m, m.dependsOn());
-        }
+      var missingMessage = missingDependencyTypes.stream()
+        .map(s -> s.replaceFirst(":", " with qualifier: "))
+        .collect(toList());
+      logError("Dependencies %s are not provided - there are no @Singleton, @Component, @Factory/@Bean that currently provide this type. If this is an external dependency consider specifying via @External", missingMessage);
+    } else if (!queue.isEmpty()) {
+      logWarn("There are " + queue.size() + " beans with unsatisfied dependencies (assuming external dependencies)");
+      for (MetaData m : queue) {
+        logWarn("Unsatisfied dependencies on %s dependsOn %s", m, m.dependsOn());
       }
     }
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
index d7ccedcd..7685128c 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
@@ -3,10 +3,14 @@ package io.avaje.inject.generator;
 import javax.lang.model.element.*;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
+
+import static java.util.stream.Collectors.toList;
+
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Stream;
 
 import static io.avaje.inject.generator.ProcessingContext.asElement;
 import static io.avaje.inject.generator.Constants.*;
@@ -157,12 +161,20 @@ final class MethodReader {
       .filter(t -> !t.equals(returnTypeRaw))
       .map(t -> CONDITIONAL_DEPENDENCY + t)
       .forEach(dependsOn::add);
+
     for (final MethodParam param : params) {
-      dependsOn.add(Util.trimWildcard(param.paramType));
-    }
-    metaData.setDependsOn(dependsOn);
-    metaData.setProvides(typeReader == null ? Collections.emptyList() : typeReader.provides());
-    metaData.setAutoProvides(typeReader == null ? List.of() : typeReader.autoProvides());
+      var dep = Util.addQualifierSuffix(param.named, Util.trimWildcard(param.paramType));
+      dependsOn.add(dep);
+    }
+    metaData.setDependsOn(dependsOn, name);
+    metaData.setProvides(
+      typeReader == null
+        ? Collections.emptyList()
+        : Util.addQualifierSuffix(typeReader.provides(), name));
+    metaData.setAutoProvides(
+      typeReader == null
+        ? List.of()
+        : Util.addQualifierSuffix(typeReader.autoProvides(), name));
     metaData.setProvidesAspect(typeReader == null ? "" : typeReader.providesAspect());
     return metaData;
   }
@@ -495,7 +507,7 @@ final class MethodReader {
       this.isObserveEvent = ObservesPrism.isPresent(param);
 
       if (nullable || param.asType().toString().startsWith("java.util.Optional<")) {
-        ProcessingContext.addOptionalType(paramType);
+        ProcessingContext.addOptionalType(paramType, named);
       }
 
       if (fullUType.fullWithoutAnnotations().startsWith("io.avaje.inject.events.Event")) {
@@ -554,7 +566,7 @@ final class MethodReader {
     }
 
     Dependency dependsOn() {
-      return new Dependency(paramType, utilType.isCollection());
+      return new Dependency(paramType, named, utilType.isCollection());
     }
 
     void addImports(ImportTypeMap importTypes) {
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java b/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
index b1e2b98a..886b8806 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ProcessingContext.java
@@ -127,9 +127,9 @@ final class ProcessingContext {
     return CTX.get().providedTypes.contains(type) || CTX.get().optionalTypes.contains(type);
   }
 
-  static void addOptionalType(String paramType) {
+  static void addOptionalType(String paramType, String name) {
     if (!CTX.get().providedTypes.contains(paramType)) {
-      CTX.get().optionalTypes.add(paramType);
+      CTX.get().optionalTypes.add(Util.addQualifierSuffixTrim(name, paramType));
     }
   }
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java b/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
index a14d6cf0..19853a43 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ScopeInfo.java
@@ -413,6 +413,11 @@ final class ScopeInfo {
     writer.append("  public %s[] %s() {", arrayType, fieldName).eol();
     writer.append("    return new %s[] {", arrayType).eol();
     for (final String rawType : types) {
+
+      if (rawType.contains(":")) {
+        continue;
+      }
+
       if (rawType.contains("<")) {
         writer.append("      new GenericType<%s>(){},", rawType).eol();
       } else {
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 c66cbdc1..95b8a4f6 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
@@ -5,9 +5,14 @@ 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 static java.util.function.Predicate.not;
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
 import java.util.Optional;
-import java.util.function.Function;
 import java.util.regex.Pattern;
+import java.util.stream.Stream;
 
 final class Util {
   static final String ASPECT_PROVIDER_PREFIX = "io.avaje.inject.aop.AspectProvider<";
@@ -349,4 +354,25 @@ final class Util {
   static String trimAnnotationString(String input) {
     return ANNOTATION_TYPE_PATTERN.matcher(input).replaceAll("@");
   }
+
+  static String addQualifierSuffixTrim(String named, String type) {
+    return addQualifierSuffix(named, type).replace(", ", ",");
+  }
+
+  static String addQualifierSuffix(String named, String type) {
+    return type +
+      Optional.ofNullable(named)
+        .filter(not(String::isBlank))
+        .map(s -> ":" + s)
+        .orElse("");
+  }
+
+  static List<String> addQualifierSuffix(List<String> provides, String name) {
+    return Stream.concat(
+        provides.stream().map(s -> Util.addQualifierSuffix(name, s)),
+        provides.stream())
+      .distinct()
+      .collect(toList());
+  }
+
 }


Test patch:
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Radio.java b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Radio.java
index c3a9188b..f6fde625 100644
--- a/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Radio.java
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/assist/droid/Radio.java
@@ -1,6 +1,8 @@
 package org.example.myapp.assist.droid;
 
+import jakarta.inject.Named;
 import jakarta.inject.Singleton;
 
 @Singleton
+@Named("red")
 public class Radio {}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierCircularDependency.java b/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierCircularDependency.java
new file mode 100644
index 00000000..f621d90e
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierCircularDependency.java
@@ -0,0 +1,26 @@
+package org.example.myapp.other;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.Factory;
+import jakarta.inject.Named;
+
+@Factory
+public class QualifierCircularDependency {
+
+  @Bean
+  @Named("parent") // for clarity, not necessary
+  public String parent(@Named("child") String child) {
+    return "parent-"+ child;
+  }
+
+  @Bean
+  @Named("child")
+  public String child() {
+    return "child";
+  }
+
+//  @Bean compilation rightly fails when this is uncommented
+//  public String child1(@Named("child2") String child) {
+//    throw new AssertionError("Method body unimportant");
+//  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierConsumerComponent.java b/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierConsumerComponent.java
new file mode 100644
index 00000000..6197fb23
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/other/QualifierConsumerComponent.java
@@ -0,0 +1,26 @@
+package org.example.myapp.other;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.inject.Singleton;
+
+@Singleton
+final class QualifierConsumerComponent {
+
+  private final String parent;
+  private final String child;
+
+  @Inject
+  QualifierConsumerComponent(@Named("parent") String parent, @Named("child") String child) {
+    this.parent = parent;
+    this.child = child;
+  }
+
+  String parent() {
+    return parent;
+  }
+
+  String child() {
+    return child;
+  }
+}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/other/QualifierConsumerComponentTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/other/QualifierConsumerComponentTest.java
new file mode 100644
index 00000000..4ded4988
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/other/QualifierConsumerComponentTest.java
@@ -0,0 +1,19 @@
+package org.example.myapp.other;
+
+import io.avaje.inject.BeanScope;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class QualifierConsumerComponentTest {
+
+  @Test
+  void doStuff() {
+    try (BeanScope beanScope = BeanScope.builder().build()) {
+      var component = beanScope.get(QualifierConsumerComponent.class);
+
+      assertThat(component.parent()).isEqualTo("parent-child");
+      assertThat(component.child()).isEqualTo("child");
+    }
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/DependencyTest.java b/inject-generator/src/test/java/io/avaje/inject/generator/DependencyTest.java
index e7bac312..d39f4ba3 100644
--- a/inject-generator/src/test/java/io/avaje/inject/generator/DependencyTest.java
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/DependencyTest.java
@@ -8,13 +8,13 @@ class DependencyTest {
 
   @Test
   void dependsOn_soft() {
-    Dependency dependency = new Dependency("org.foo.Bar", true);
+    Dependency dependency = new Dependency("org.foo.Bar",null, true);
 
     assertThat(dependency.isSoftDependency()).isTrue();
     assertThat(dependency.name()).isEqualTo("org.foo.Bar");
     assertThat(dependency.dependsOn()).isEqualTo("soft:org.foo.Bar");
 
-    Dependency dependencyFromMeta = new Dependency("soft:org.foo.Bar");
+    Dependency dependencyFromMeta = new Dependency("soft:org.foo.Bar", null);
     assertThat(dependencyFromMeta.name()).isEqualTo("org.foo.Bar");
     assertThat(dependencyFromMeta.isSoftDependency()).isTrue();
     assertThat(dependencyFromMeta.name()).isEqualTo(dependency.name());
@@ -23,10 +23,19 @@ class DependencyTest {
 
   @Test
   void dependsOn_notSoft() {
-    Dependency dependency = new Dependency("org.foo.Bar");
+    Dependency dependency = new Dependency("org.foo.Bar", null);
 
     assertThat(dependency.dependsOn()).isEqualTo("org.foo.Bar");
     assertThat(dependency.isSoftDependency()).isFalse();
     assertThat(dependency.name()).isEqualTo("org.foo.Bar");
   }
+
+  @Test
+  void dependsOn_qualifier() {
+    Dependency dependency = new Dependency("org.foo.Bar", "sus");
+
+    assertThat(dependency.dependsOn()).isEqualTo("org.foo.Bar:sus");
+    assertThat(dependency.isSoftDependency()).isFalse();
+    assertThat(dependency.name()).isEqualTo("org.foo.Bar:sus");
+  }
 }
diff --git a/inject-test/src/main/java/module-info.java b/inject-test/src/main/java/module-info.java
index 36e90979..956aef2c 100644
--- a/inject-test/src/main/java/module-info.java
+++ b/inject-test/src/main/java/module-info.java
@@ -3,8 +3,6 @@ module io.avaje.inject.test {
   exports io.avaje.inject.test;
 
   requires transitive io.avaje.inject;
-  requires transitive io.avaje.inject.aop;
-  requires transitive io.avaje.inject.events;
   requires transitive org.junit.jupiter.engine;
   requires transitive org.junit.jupiter.api;
   requires static org.apiguardian.api; // needed for javadoc
@@ -13,6 +11,8 @@ module io.avaje.inject.test {
 //  requires transitive org.assertj.core;
 //  requires transitive net.bytebuddy;
   requires static java.net.http; // for testing only
+  requires static io.avaje.inject.aop;
+  requires static io.avaje.inject.events;
 
   uses io.avaje.inject.test.TestModule;
   uses io.avaje.inject.test.Plugin;
diff --git a/inject-test/src/test/java/org/example/autonamed/MyAutoNameFactory.java b/inject-test/src/test/java/org/example/autonamed/MyAutoNameFactory.java
index c1ae74a3..a41eb457 100644
--- a/inject-test/src/test/java/org/example/autonamed/MyAutoNameFactory.java
+++ b/inject-test/src/test/java/org/example/autonamed/MyAutoNameFactory.java
@@ -58,6 +58,7 @@ public class MyAutoNameFactory {
   }
 
   @Bean
+  @Named("one")
   AutoIface one() {
     return new DAutoIface("one");
   }
diff --git a/inject-test/src/test/java/org/example/coffee/secondary/WidgetSecondary.java b/inject-test/src/test/java/org/example/coffee/secondary/WidgetSecondary.java
index 012f14f4..ec438b5b 100644
--- a/inject-test/src/test/java/org/example/coffee/secondary/WidgetSecondary.java
+++ b/inject-test/src/test/java/org/example/coffee/secondary/WidgetSecondary.java
@@ -1,11 +1,12 @@
 package org.example.coffee.secondary;
 
 import io.avaje.inject.Secondary;
-
+import jakarta.inject.Named;
 import jakarta.inject.Singleton;
 
 @Singleton
 @Secondary
+@Named("doesNotExist")
 public class WidgetSecondary implements Widget {
 
   @Override