Repository: avaje/avaje-inject
Pull request #377 (2023-07-20 22:43:25)

Add support for `@Profile` annotation - conditional wiring via profiles
Adds a `@Profile` annotation for conditional wiring. Solves #304 from a certain point of view.

sonatype-lift[bot]: ## Sonatype Lift is retiring
Sonatype Lift will be retiring on <b>Sep 12, 2023</b>, with its analysis stopping on Aug 12, 2023. We understand that this news may come as a disappointment, and Sonatype is committed to helping you transition off it seamlessly. If you’d like to retain your data, please export your issues from the web console.
We are extremely grateful and thank you for your support over the years.

<b>[📖 Read about the impacts and timeline](https://www.sonatype.com/lift-retirement)</b>
SentryMan: hmm
SentryMan: Okay I'm good with this

Patch:
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/BeanConditions.java b/inject-generator/src/main/java/io/avaje/inject/generator/BeanConditions.java
index fb0b454d..8cb1ae40 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/BeanConditions.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/BeanConditions.java
@@ -14,6 +14,9 @@ final class BeanConditions {
   final Set<String> qualifierNames = new HashSet<>();
   final Set<String> containsProps = new HashSet<>();
   final Set<String> missingProps = new HashSet<>();
+  final Set<String> orProfiles = new HashSet<>();
+  final Set<String> andProfiles = new HashSet<>();
+  final Set<String> notProfiles = new HashSet<>();
   final Map<String, String> propertyEquals = new HashMap<>();
   final Map<String, String> propertyNotEquals = new HashMap<>();
 
@@ -25,16 +28,11 @@ final class BeanConditions {
   private void readAllDirect(Element element) {
     RequiresBeanPrism.getAllInstancesOn(element).forEach(this::read);
     RequiresBeanContainerPrism.getOptionalOn(element)
-        .ifPresent(
-            container -> {
-              container.value().forEach(this::read);
-            });
+        .ifPresent(container -> container.value().forEach(this::read));
     RequiresPropertyPrism.getAllInstancesOn(element).forEach(this::read);
     RequiresPropertyContainerPrism.getOptionalOn(element)
-        .ifPresent(
-            container -> {
-              container.value().forEach(this::read);
-            });
+        .ifPresent(container -> container.value().forEach(this::read));
+    ProfilePrism.getOptionalOn(element).ifPresent(this::read);
   }
 
   private void readMetaAnnotations(Element element) {
@@ -46,6 +44,13 @@ final class BeanConditions {
     RequiresPropertyContainerPrism.getAllOnMetaAnnotations(element).stream()
         .flatMap(e -> e.value().stream())
         .forEach(this::read);
+    ProfilePrism.getAllOnMetaAnnotations(element).forEach(this::read);
+  }
+
+  private void read(ProfilePrism prism) {
+    orProfiles.addAll(prism.value());
+    andProfiles.addAll(prism.all());
+    notProfiles.addAll(prism.none());
   }
 
   private void read(RequiresBeanPrism prism) {
@@ -70,15 +75,22 @@ final class BeanConditions {
   void addImports(ImportTypeMap importTypes) {
     requireTypes.forEach(importTypes::add);
     missingTypes.forEach(importTypes::add);
+
+    if (!orProfiles.isEmpty() || !andProfiles.isEmpty() || !notProfiles.isEmpty()) {
+      importTypes.add("java.util.List");
+    }
   }
 
   boolean isEmpty() {
-    return requireTypes.isEmpty()
-      && missingTypes.isEmpty()
-      && qualifierNames.isEmpty()
-      && containsProps.isEmpty()
-      && missingProps.isEmpty()
-      && propertyEquals.isEmpty()
-      && propertyNotEquals.isEmpty();
+    return orProfiles.isEmpty()
+        && andProfiles.isEmpty()
+        && notProfiles.isEmpty()
+        && requireTypes.isEmpty()
+        && missingTypes.isEmpty()
+        && qualifierNames.isEmpty()
+        && containsProps.isEmpty()
+        && missingProps.isEmpty()
+        && propertyEquals.isEmpty()
+        && propertyNotEquals.isEmpty();
   }
 }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ConditionalWriter.java b/inject-generator/src/main/java/io/avaje/inject/generator/ConditionalWriter.java
index 4b2ca31c..48af0459 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ConditionalWriter.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ConditionalWriter.java
@@ -1,5 +1,7 @@
 package io.avaje.inject.generator;
 
+import static java.util.stream.Collectors.joining;
+
 final class ConditionalWriter {
   private final Append writer;
   private final BeanConditions conditions;
@@ -17,6 +19,25 @@ final class ConditionalWriter {
 
     writer.append("    if (");
 
+    if (!conditions.orProfiles.isEmpty()) {
+      prefix();
+      writer.append(
+          "!builder.containsProfiles(List.of(%s\"))",
+          conditions.orProfiles.stream().map(s -> "\"" + s).collect(joining("\",")));
+    }
+    if (!conditions.andProfiles.isEmpty()) {
+      prefix();
+      writer.append(
+          "!builder.containsAllProfiles(List.of(%s\"))",
+          conditions.andProfiles.stream().map(s -> "\"" + s).collect(joining("\",")));
+    }
+
+    if (!conditions.notProfiles.isEmpty()) {
+      prefix();
+      writer.append(
+          "builder.containsProfiles(List.of(%s\"))",
+          conditions.notProfiles.stream().map(s -> "\"" + s).collect(joining("\",")));
+    }
     for (final var requireType : conditions.requireTypes) {
       prefix();
       writer.append("!builder.contains(%s.class)", Util.shortName(requireType));
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java b/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
index 949fb290..7100b43d 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
@@ -21,6 +21,7 @@
 @GeneratePrism(RequiresProperty.class)
 @GeneratePrism(value = RequiresBean.Container.class, name = "RequiresBeanContainerPrism")
 @GeneratePrism(value = RequiresProperty.Container.class, name = "RequiresPropertyContainerPrism")
+@GeneratePrism(Profile.class)
 package io.avaje.inject.generator;
 
 import io.avaje.inject.*;
diff --git a/inject/src/main/java/io/avaje/inject/Profile.java b/inject/src/main/java/io/avaje/inject/Profile.java
new file mode 100644
index 00000000..84293feb
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/Profile.java
@@ -0,0 +1,60 @@
+package io.avaje.inject;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.*;
+
+/**
+ * Expresses a requirement for a bean to be wired/registered into the {@link BeanScope}.
+ *
+ * <pre>{@code
+ * @Factory
+ * public class MyAutoConfiguration {
+ *
+ *   @Bean
+ *   @Profile("test")
+ *   public MyService myService() {
+ *       ...
+ *   }
+ *
+ * }
+ *
+ * }</pre>
+ *
+ * <p>In the sample above, the MyService bean will get wired only if <code>avaje.profiles</code> is
+ * set to "test" in the {@link io.avaje.inject.spi.PropertyRequiresPlugin}.
+ *
+ * <p>Avaje Config provides an implementation and if it is included in the classpath then Avaje
+ * Config will be used to test the property conditions.
+ *
+ * <p>If no PropertyRequiresPlugin is found then the default implementation is used which uses
+ * {@link System#getProperty(String)} and {@link System#getenv(String)}.
+ */
+@Retention(RUNTIME)
+@Target({TYPE, METHOD, ANNOTATION_TYPE})
+public @interface Profile {
+
+  /**
+   * Expresses that any of the given profiles must be set for the bean to load.
+   *
+   * @return the property to check
+   */
+  String[] value() default {};
+
+  /**
+   * Expresses that all of the given profiles must be set for the bean to load.
+   *
+   * @return the property to check
+   */
+  String[] all() default {};
+
+  /**
+   * Expresses that none of the given profiles must be set for the bean to load.
+   *
+   * @return the properties to check
+   */
+  String[] none() default {};
+}
diff --git a/inject/src/main/java/io/avaje/inject/spi/Builder.java b/inject/src/main/java/io/avaje/inject/spi/Builder.java
index 21aa2c71..b760e514 100644
--- a/inject/src/main/java/io/avaje/inject/spi/Builder.java
+++ b/inject/src/main/java/io/avaje/inject/spi/Builder.java
@@ -250,6 +250,12 @@ public interface Builder {
    */
   boolean containsQualifier(String name);
 
+  /** Return true if the builder contains the given profile */
+  boolean containsProfiles(List<String> type);
+
+  /** Return true if the builder contains all of the given profile */
+  boolean containsAllProfiles(List<String> type);
+
   /**
    * Return the plugin for required properties.
    */
@@ -259,4 +265,6 @@ public interface Builder {
    * Build and return the bean scope.
    */
   BeanScope build(boolean withShutdownHook, long start);
+
+
 }
diff --git a/inject/src/main/java/io/avaje/inject/spi/DBuilder.java b/inject/src/main/java/io/avaje/inject/spi/DBuilder.java
index 5a79aa93..a6505ec8 100644
--- a/inject/src/main/java/io/avaje/inject/spi/DBuilder.java
+++ b/inject/src/main/java/io/avaje/inject/spi/DBuilder.java
@@ -4,6 +4,8 @@ import io.avaje.inject.BeanEntry;
 import io.avaje.inject.BeanScope;
 import jakarta.inject.Provider;
 
+import static java.util.stream.Collectors.toSet;
+
 import java.lang.reflect.Type;
 import java.util.*;
 import java.util.function.Consumer;
@@ -13,40 +15,36 @@ import static io.avaje.inject.spi.DBeanScope.combine;
 class DBuilder implements Builder {
 
   private final PropertyRequiresPlugin propertyRequires;
-  /**
-   * List of Lifecycle methods.
-   */
+  private final Set<String> profiles;
+  /** List of Lifecycle methods. */
   private final List<Runnable> postConstruct = new ArrayList<>();
+
   private final List<Consumer<BeanScope>> postConstructConsumers = new ArrayList<>();
   private final List<AutoCloseable> preDestroy = new ArrayList<>();
-  /**
-   * List of field injection closures.
-   */
+  /** List of field injection closures. */
   private final List<Consumer<Builder>> injectors = new ArrayList<>();
-  /**
-   * The beans created and added to the scope during building.
-   */
+  /** The beans created and added to the scope during building. */
   protected final DBeanMap beanMap = new DBeanMap();
+
   protected final BeanScope parent;
   protected final boolean parentOverride;
-  /**
-   * Bean provided by the parent scope that we are not overriding.
-   */
+  /** Bean provided by the parent scope that we are not overriding. */
   protected Object parentMatch;
-  /**
-   * Debug of the current bean being wired - used in injection errors.
-   */
+  /** Debug of the current bean being wired - used in injection errors. */
   private Type injectTarget;
-  /**
-   * Flag set when we are running post construct injection.
-   */
+  /** Flag set when we are running post construct injection. */
   private boolean runningPostConstruct;
+
   private DBeanScopeProxy beanScopeProxy;
 
   DBuilder(PropertyRequiresPlugin propertyRequires, BeanScope parent, boolean parentOverride) {
     this.propertyRequires = propertyRequires;
     this.parent = parent;
     this.parentOverride = parentOverride;
+    this.profiles =
+        propertyRequires.get("avaje.profiles").map(p -> p.split(",")).stream()
+            .flatMap(Arrays::stream)
+            .collect(toSet());
   }
 
   @Override
@@ -72,9 +70,9 @@ class DBuilder implements Builder {
 
   /**
    * Return the types without any annotation types.
-   * <p>
-   * For the purposes of supplied beans (typically test doubles) we are not
-   * interested in annotation types.
+   *
+   * <p>For the purposes of supplied beans (typically test doubles) we are not interested in
+   * annotation types.
    */
   protected final Type[] removeAnnotations(Type[] source) {
     for (int i = 1, end = source.length; i < end; i++) {
@@ -144,16 +142,14 @@ class DBuilder implements Builder {
   }
 
   private <T> T getMaybe(Type type, String name) {
-    T bean = beanMap.get(type, name);
+    final T bean = beanMap.get(type, name);
     if (bean != null) {
       return bean;
     }
     return parent == null ? null : parent.<T>getOptional(type, name).orElse(null);
   }
 
-  /**
-   * Return the bean to register potentially with spy enhancement.
-   */
+  /** Return the bean to register potentially with spy enhancement. */
   protected <T> T enrich(T bean, DBeanMap.NextBean next) {
     // only enriched by DBuilderExtn
     return bean;
@@ -215,7 +211,7 @@ class DBuilder implements Builder {
   @Override
   public final void addAutoClosable(Object maybeAutoCloseable) {
     if (maybeAutoCloseable instanceof AutoCloseable) {
-      preDestroy.add((AutoCloseable)maybeAutoCloseable);
+      preDestroy.add((AutoCloseable) maybeAutoCloseable);
     }
   }
 
@@ -293,13 +289,13 @@ class DBuilder implements Builder {
       return obtainProvider(type, name);
     }
     // use injectors to delay obtaining the provider until end of build
-    ProviderPromise<T> promise = new ProviderPromise<>(type, name, this);
+    final ProviderPromise<T> promise = new ProviderPromise<>(type, name, this);
     injectors.add(promise);
     return promise;
   }
 
   <T> Provider<T> obtainProvider(Type type, String name) {
-    Provider<T> provider = beanMap.provider(type, name);
+    final Provider<T> provider = beanMap.provider(type, name);
     if (provider != null) {
       return provider;
     }
@@ -316,7 +312,12 @@ class DBuilder implements Builder {
       if (bean != null) {
         return bean;
       }
-      String msg = "Unable to inject an instance for generic type " + type + " usually provided by " + cls + "?";
+      final String msg =
+          "Unable to inject an instance for generic type "
+              + type
+              + " usually provided by "
+              + cls
+              + "?";
       throw new IllegalStateException(msg);
     };
   }
@@ -341,6 +342,20 @@ class DBuilder implements Builder {
     return getBean(type, name);
   }
 
+  @Override
+  public boolean containsProfiles(List<String> type) {
+
+    return !Collections.disjoint(profiles, type);
+  }
+
+  @Override
+  public boolean containsAllProfiles(List<String> type) {
+    for (final var string : type) {
+      if (!profiles.contains(string)) return false;
+    }
+    return true;
+  }
+
   @Override
   public boolean contains(String type) {
     return beanMap.contains(type);
@@ -365,7 +380,7 @@ class DBuilder implements Builder {
     if (BeanScope.class.equals(type)) {
       return injectBeanScope();
     }
-    T bean = getMaybe(type, name);
+    final T bean = getMaybe(type, name);
     if (bean == null) {
       throw new IllegalStateException(errorInjectingNull(type, name));
     }
@@ -381,23 +396,26 @@ class DBuilder implements Builder {
   }
 
   private <T> String errorInjectingNull(Type type, String name) {
-    String msg = "Injecting null for " + type.getTypeName();
+    final StringBuilder msg = new StringBuilder("Injecting null for ").append(type.getTypeName());
     if (name != null) {
-      msg += " name:" + name;
+      msg.append(" name:").append(name);
     }
-    List<T> beanList = list(type);
-    msg += " when creating " + injectTarget + " - potential beans to inject: " + beanList;
+    final List<T> beanList = list(type);
+    msg.append(" when creating ")
+        .append(injectTarget)
+        .append(" - potential beans to inject: ")
+        .append(beanList);
     if (!beanList.isEmpty()) {
-      msg += ". Check @Named or Qualifier being used";
+      msg.append(". Check @Named or Qualifier being used");
     }
-    msg += ". Check for missing module? [ missing beanScopeBuilder.modules() ]";
-    msg += ". If it is expected to be externally provided, missing beanScopeBuilder.bean() ?";
-    return msg;
+    msg.append(". Check for missing module? [ missing beanScopeBuilder.modules() ]");
+    msg.append(". If it is expected to be externally provided, missing beanScopeBuilder.bean() ?");
+    return msg.toString();
   }
 
   private void runInjectors() {
     runningPostConstruct = true;
-    for (Consumer<Builder> injector : injectors) {
+    for (final Consumer<Builder> injector : injectors) {
       injector.accept(this);
     }
   }
@@ -405,7 +423,9 @@ class DBuilder implements Builder {
   @Override
   public final BeanScope build(boolean withShutdownHook, long start) {
     runInjectors();
-    final var scope = new DBeanScope(withShutdownHook, preDestroy, postConstruct, postConstructConsumers, beanMap, parent);
+    final var scope =
+        new DBeanScope(
+            withShutdownHook, preDestroy, postConstruct, postConstructConsumers, beanMap, parent);
     if (beanScopeProxy != null) {
       beanScopeProxy.inject(scope);
     }
diff --git a/inject/src/main/java/io/avaje/inject/spi/PropertyRequiresPlugin.java b/inject/src/main/java/io/avaje/inject/spi/PropertyRequiresPlugin.java
index f9c47cc8..a173210c 100644
--- a/inject/src/main/java/io/avaje/inject/spi/PropertyRequiresPlugin.java
+++ b/inject/src/main/java/io/avaje/inject/spi/PropertyRequiresPlugin.java
@@ -3,7 +3,7 @@ package io.avaje.inject.spi;
 import java.util.Optional;
 
 /**
- * Plugin interface used with {@link io.avaje.inject.RequiresProperty}.
+ * Plugin interface which contains the application properties used for wiring. Used with {@link io.avaje.inject.RequiresProperty} and {@link io.avaje.inject.Profile}.
  * <p>
  * The plugin is loaded via ServiceLoader and defaults to an implementation
  * that uses {@link System#getProperty(String)} and {@link System#getenv(String)}.


Test patch:
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/profile/Bettas.java b/blackbox-test-inject/src/main/java/org/example/myapp/profile/Bettas.java
new file mode 100644
index 00000000..c106a533
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/profile/Bettas.java
@@ -0,0 +1,6 @@
+package org.example.myapp.profile;
+
+import io.avaje.inject.Profile;
+
+@Profile("betta-time")
+public @interface Bettas {}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/profile/Fish.java b/blackbox-test-inject/src/main/java/org/example/myapp/profile/Fish.java
new file mode 100644
index 00000000..e1f2e0bd
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/profile/Fish.java
@@ -0,0 +1,28 @@
+package org.example.myapp.profile;
+
+public interface Fish {
+
+  public class CardinalTetra implements Fish {
+
+    @Override
+    public String toString() {
+      return "CardinalTetra";
+    }
+  }
+
+  public class Discus implements Fish {
+
+    @Override
+    public String toString() {
+      return "Discus";
+    }
+  }
+
+  public class Betta implements Fish {
+
+    @Override
+    public String toString() {
+      return "Betta";
+    }
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishFactory.java
new file mode 100644
index 00000000..ce2f6d8c
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishFactory.java
@@ -0,0 +1,34 @@
+package org.example.myapp.profile;
+
+import org.example.myapp.profile.Fish.Betta;
+import org.example.myapp.profile.Fish.CardinalTetra;
+import org.example.myapp.profile.Fish.Discus;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.Factory;
+import io.avaje.inject.Profile;
+import io.avaje.inject.Secondary;
+
+@Factory
+@FishProfile
+@Profile(value = "factory", all = "testRepeatable", none = "neverExisted")
+public class FishFactory {
+
+  @Bean
+  @Profile(none = "betta-time")
+  public CardinalTetra tetra() {
+    return new CardinalTetra();
+  }
+
+  @Bean
+  @Secondary
+  public Discus plate() {
+    return new Discus();
+  }
+
+  @Bean
+  @Bettas
+  public Betta sweetPrince() {
+    return new Betta();
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishProfile.java b/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishProfile.java
new file mode 100644
index 00000000..10ff0cf8
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/profile/FishProfile.java
@@ -0,0 +1,6 @@
+package org.example.myapp.profile;
+
+import io.avaje.inject.Profile;
+
+@Profile({"Fish", "Fishy"})
+public @interface FishProfile {}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/ConditionalTests.java b/blackbox-test-inject/src/test/java/org/example/myapp/conditional/ConditionalTests.java
similarity index 99%
rename from blackbox-test-inject/src/test/java/org/example/myapp/ConditionalTests.java
rename to blackbox-test-inject/src/test/java/org/example/myapp/conditional/ConditionalTests.java
index 3e1ab6ae..efc7f779 100644
--- a/blackbox-test-inject/src/test/java/org/example/myapp/ConditionalTests.java
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/conditional/ConditionalTests.java
@@ -1,4 +1,4 @@
-package org.example.myapp;
+package org.example.myapp.conditional;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/profile/ProfileTests.java b/blackbox-test-inject/src/test/java/org/example/myapp/profile/ProfileTests.java
new file mode 100644
index 00000000..9f939089
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/profile/ProfileTests.java
@@ -0,0 +1,54 @@
+package org.example.myapp.profile;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.example.myapp.conditional.Bird;
+import org.example.myapp.conditional.Bird.BlueJay;
+import org.example.myapp.conditional.Bird.StrawberryFinch;
+import org.example.myapp.conditional.BirdFactory;
+import org.example.myapp.conditional.BirdWatcher;
+import org.example.myapp.conditional.QualifiedBirdWatcher;
+import org.example.myapp.profile.Fish.CardinalTetra;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.avaje.config.Config;
+import io.avaje.inject.BeanScope;
+
+class ProfileTests {
+
+  @BeforeEach
+  void clearConfig() {
+
+    Config.clearProperty("avaje.profiles");
+  }
+
+  @Test
+  void basic() throws IOException {
+    // just wire everything with no test scope, mocks etc
+    final BeanScope beanScope = BeanScope.builder().build();
+    assertTrue(beanScope.getOptional(Fish.class).isEmpty());
+  }
+
+  @Test
+  void tetra() throws IOException {
+
+    Config.setProperty("avaje.profiles", "Fish,testRepeatable");
+
+    final BeanScope beanScope = BeanScope.builder().build();
+
+    assertTrue(beanScope.getOptional(FishFactory.class).isPresent());
+    assertTrue(beanScope.getOptional(CardinalTetra.class).isPresent());
+  }
+
+  @Test
+  void metaMetaAnnotationTest() throws IOException {
+    Config.setProperty("avaje.profiles", "factory,betta-time,testRepeatable");
+
+    final BeanScope beanScope = BeanScope.builder().build();
+    assertEquals("Betta", beanScope.get(Fish.class).toString());
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Bettas.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Bettas.java
new file mode 100644
index 00000000..7bc31fa1
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Bettas.java
@@ -0,0 +1,6 @@
+package io.avaje.inject.generator.models.valid.profile;
+
+import io.avaje.inject.Profile;
+
+@Profile("betta-time")
+public @interface Bettas {}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Fish.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Fish.java
new file mode 100644
index 00000000..f57f1c4d
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/Fish.java
@@ -0,0 +1,28 @@
+package io.avaje.inject.generator.models.valid.profile;
+
+public interface Fish {
+
+  public class CardinalTetra implements Fish {
+
+    @Override
+    public String toString() {
+      return "CardinalTetra";
+    }
+  }
+
+  public class Discus implements Fish {
+
+    @Override
+    public String toString() {
+      return "Discus";
+    }
+  }
+
+  public class Betta implements Fish {
+
+    @Override
+    public String toString() {
+      return "Betta";
+    }
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishFactory.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishFactory.java
new file mode 100644
index 00000000..6c01a55a
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishFactory.java
@@ -0,0 +1,33 @@
+package io.avaje.inject.generator.models.valid.profile;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.Factory;
+import io.avaje.inject.Profile;
+import io.avaje.inject.Secondary;
+import io.avaje.inject.generator.models.valid.profile.Fish.Betta;
+import io.avaje.inject.generator.models.valid.profile.Fish.CardinalTetra;
+import io.avaje.inject.generator.models.valid.profile.Fish.Discus;
+
+@Factory
+@FishProfile
+@Profile(value = "factory", all = "testRepeatable", none = "neverExisted")
+public class FishFactory {
+
+  @Bean
+  @Profile(none = "betta-time")
+  public CardinalTetra tetra() {
+    return new CardinalTetra();
+  }
+
+  @Bean
+  @Secondary
+  public Discus plate() {
+    return new Discus();
+  }
+
+  @Bean
+  @Bettas
+  public Betta sweetPrince() {
+    return new Betta();
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishProfile.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishProfile.java
new file mode 100644
index 00000000..fd5ede4b
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/profile/FishProfile.java
@@ -0,0 +1,6 @@
+package io.avaje.inject.generator.models.valid.profile;
+
+import io.avaje.inject.Profile;
+
+@Profile({"Fish", "Fishy"})
+public @interface FishProfile {}