diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
index 90b17377..5cc0758f 100644
--- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java
@@ -25,6 +25,7 @@
package org.javahelpers.simple.builders.core.annotations;
import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@@ -83,6 +84,21 @@
* }
* }
*
+ *
The annotation is {@link Repeatable}: several declarations may be placed on the same holder
+ * (class or {@code package-info.java}) when the listed types need different {@link #options()}, for
+ * example a different {@code packageName} for the generated builders:
+ *
+ *
{@code
+ * @SimpleBuilderFor(
+ * value = ExternalUser.class,
+ * options = @SimpleBuilder.Options(packageName = "com.example.users"))
+ * @SimpleBuilderFor(
+ * value = ExternalOrder.class,
+ * options = @SimpleBuilder.Options(packageName = "com.example.orders"))
+ * public class ExternalBuildersProvider {
+ * }
+ * }
+ *
* Configuration uses the existing {@link SimpleBuilder.Options} model and may be overridden via
* compiler options:
*
@@ -103,6 +119,7 @@
*/
@Target({ElementType.TYPE, ElementType.PACKAGE})
@Retention(RetentionPolicy.CLASS)
+@Repeatable(SimpleBuilderFors.class)
public @interface SimpleBuilderFor {
/**
diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java
new file mode 100644
index 00000000..b015d3a0
--- /dev/null
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java
@@ -0,0 +1,49 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.core.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Container annotation holding several {@link SimpleBuilderFor} declarations on the same element.
+ * It is applied implicitly when {@link SimpleBuilderFor} is repeated and is not meant to be used
+ * directly.
+ *
+ * @see SimpleBuilderFor
+ */
+@Target({ElementType.TYPE, ElementType.PACKAGE})
+@Retention(RetentionPolicy.CLASS)
+public @interface SimpleBuilderFors {
+
+ /**
+ * The repeated {@link SimpleBuilderFor} declarations.
+ *
+ * @return the contained {@link SimpleBuilderFor} annotations
+ */
+ SimpleBuilderFor[] value();
+}
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 2c595c04..1b6469da 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -189,6 +189,19 @@ The generated builders are placed in the annotated package - or in the package o
The target type must be constructible through accessible Java APIs from the builder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. The target type's own annotations are not consulted - the explicit declaration wins, so `@Ignore4BuilderGeneration` on the target does not suppress generation either. `@SimpleBuilderFor` is not `@Inherited`, and the provider class or package itself never gets a builder.
+`@SimpleBuilderFor` is `@Repeatable`: several declarations may be placed on the same holder or `package-info.java` when the listed types need different options - for example a different `packageName` per declaration:
+
+```java
+@SimpleBuilderFor(
+ value = ExternalUser.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.users"))
+@SimpleBuilderFor(
+ value = ExternalOrder.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.orders"))
+public class ExternalBuildersProvider {
+}
+```
+
## Compiler Options
Set project-wide defaults via compiler options. These apply to all builders unless overridden by annotations.
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
index 6fcb5bed..ecf98650 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
@@ -53,6 +53,7 @@
import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template;
import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors;
import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver;
import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser;
import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator;
@@ -207,10 +208,13 @@ private void generateJacksonModules(PerformanceTracker tracker) {
/**
* Collects all elements annotated with {@code @SimpleBuilderFor} in this round. These are holder
- * classes declaring external types a builder is generated for.
+ * classes declaring external types a builder is generated for. Repeated declarations arrive
+ * wrapped in the {@code @SimpleBuilderFors} container, so both are collected.
*/
private Set collectExternalTypeHolders(RoundEnvironment roundEnv) {
- return new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class));
+ Set holders = new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class));
+ holders.addAll(roundEnv.getElementsAnnotatedWith(SimpleBuilderFors.class));
+ return holders;
}
/**
@@ -311,9 +315,11 @@ private Optional planGenerationOfAnnotatedElement(
}
/**
- * Expands a {@code @SimpleBuilderFor} holder into the external types listed in its {@code value}
- * attribute and plans a builder for each of them. The generated builder is placed in the
- * configured {@code packageName} or the holder's package.
+ * Expands a {@code @SimpleBuilderFor} holder into the external types listed in the {@code value}
+ * attributes of its annotation instances and plans a builder for each of them. Each instance
+ * resolves its own configuration, so repeated declarations may place their generated builders
+ * into different packages via {@code packageName}; without a configured package the holder's
+ * package is used.
*/
private List planGenerationOfTypeByHolder(
Element holder,
@@ -322,24 +328,30 @@ private List planGenerationOfTypeByHolder(
PerformanceTracker tracker)
throws BuilderException {
Objects.requireNonNull(holder, "holder must not be null");
- List targets = extractExternalTargetTypes(holder);
- if (targets.isEmpty()) {
- context.warning(
- holder,
- "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate",
- holder.getSimpleName());
- return List.of();
- }
-
- tracker.startPhase();
- BuilderConfiguration config = reader.resolveHolderConfiguration(holder);
- tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION);
+ List annotationInstances =
+ JavaLangAnalyser.findAnnotationInstances(
+ holder, SimpleBuilderFor.class, SimpleBuilderFors.class, context);
- String builderPackage = effectiveBuilderPackage(holder, config);
List results = new ArrayList<>();
- for (TypeElement target : targets) {
- planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders)
- .ifPresent(results::add);
+ for (AnnotationMirror annotationInstance : annotationInstances) {
+ List targets = extractExternalTargetTypes(holder, annotationInstance);
+ if (targets.isEmpty()) {
+ context.warning(
+ holder,
+ "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate",
+ holder.getSimpleName());
+ continue;
+ }
+
+ tracker.startPhase();
+ BuilderConfiguration config = reader.resolveHolderConfiguration(holder, annotationInstance);
+ tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION);
+
+ String builderPackage = effectiveBuilderPackage(holder, config);
+ for (TypeElement target : targets) {
+ planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders)
+ .ifPresent(results::add);
+ }
}
return results;
}
@@ -380,17 +392,13 @@ private Optional planExternalTarget(
}
/**
- * Reads the {@code value} attribute of the {@code @SimpleBuilderFor} annotation on the holder and
- * resolves each entry to the {@link TypeElement} the builder is generated for.
+ * Reads the {@code value} attribute of one {@code @SimpleBuilderFor} annotation instance on the
+ * holder and resolves each entry to the {@link TypeElement} the builder is generated for.
*/
- private List extractExternalTargetTypes(Element holder) throws BuilderException {
- Optional mirror =
- JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class);
- if (mirror.isEmpty()) {
- return List.of();
- }
+ private List extractExternalTargetTypes(
+ Element holder, AnnotationMirror annotationInstance) throws BuilderException {
Optional valueAttribute =
- JavaLangAnalyser.findAnnotationAttribute(mirror.get(), "value", context);
+ JavaLangAnalyser.findAnnotationAttribute(annotationInstance, "value", context);
List results = new ArrayList<>();
// For an array-valued attribute javac always delivers a list, even for a single entry.
if (valueAttribute.isEmpty() || !(valueAttribute.get().getValue() instanceof List> items)) {
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
index a7f8caa8..560f5628 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
@@ -29,6 +29,7 @@
import static javax.lang.model.type.TypeKind.VOID;
import java.lang.annotation.Annotation;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -232,6 +233,47 @@ public static Optional findAnnotation(
return Optional.empty();
}
+ /**
+ * Finds all instances of a repeatable annotation on an element: direct mirrors plus the entries
+ * wrapped in its container annotation, in declaration order.
+ *
+ * @param element Element to be checked
+ * @param annotationClass the repeatable annotation to be checked
+ * @param containerClass the container annotation wrapping repeats of {@code annotationClass}
+ * @param context the processing context providing element utilities
+ * @return the list of {@code javax.lang.model.element.AnnotationMirror} instances of
+ * annotationClass found on the element
+ */
+ public static List findAnnotationInstances(
+ Element element,
+ Class extends Annotation> annotationClass,
+ Class extends Annotation> containerClass,
+ ProcessingContext context) {
+ List instances = new ArrayList<>();
+ if (element == null) {
+ return instances;
+ }
+ for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) {
+ String annotationName = annotationMirror.getAnnotationType().toString();
+ if (annotationName.equals(annotationClass.getCanonicalName())) {
+ instances.add(annotationMirror);
+ } else if (annotationName.equals(containerClass.getCanonicalName())) {
+ Optional containerValue =
+ findAnnotationAttribute(annotationMirror, "value", context);
+ if (containerValue.isPresent()
+ && containerValue.get().getValue() instanceof List> entries) {
+ for (Object entry : entries) {
+ if (entry instanceof AnnotationValue entryValue
+ && entryValue.getValue() instanceof AnnotationMirror entryMirror) {
+ instances.add(entryMirror);
+ }
+ }
+ }
+ }
+ }
+ return instances;
+ }
+
/**
* Reads the value of one attribute of an annotation mirror, including default values.
*
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java
index f25ae6c5..ca2ea1db 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java
@@ -37,9 +37,7 @@
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
-import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
import org.javahelpers.simple.builders.core.enums.AccessModifier;
-import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser;
import org.javahelpers.simple.builders.processor.exceptions.BuilderException;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
@@ -151,21 +149,22 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder
* are considered - the type is typically external and must not be modified, so its annotations
* (if any) do not participate in configuration. The configuration is composed of the built-in
* defaults, the global compiler arguments, and the {@code options()} of the
- * {@code @SimpleBuilderFor} annotation found on the given element.
+ * {@code @SimpleBuilderFor} annotation instance found on the given element.
*
* @param element the {@code @SimpleBuilderFor} holder (used for validation messages)
+ * @param annotationInstance one {@code @SimpleBuilderFor} annotation instance on the element;
+ * repeated declarations each resolve their own configuration
* @return the fully resolved configuration with all sources merged
*/
- public BuilderConfiguration resolveHolderConfiguration(Element element) throws BuilderException {
+ public BuilderConfiguration resolveHolderConfiguration(
+ Element element, AnnotationMirror annotationInstance) throws BuilderException {
Objects.requireNonNull(element, "element must not be null");
+ Objects.requireNonNull(annotationInstance, "annotationInstance must not be null");
String elementName = element.getSimpleName().toString();
logger.debugStartOperation(
"Resolving configuration for @SimpleBuilderFor target: %s", elementName);
- BuilderConfiguration optionsConfig =
- JavaLangAnalyser.findAnnotation(element, SimpleBuilderFor.class)
- .map(this::extractOptionsFromAnnotationMirror)
- .orElse(null);
+ BuilderConfiguration optionsConfig = extractOptionsFromAnnotationMirror(annotationInstance);
BuilderConfiguration result =
BuilderConfiguration.DEFAULT.merge(globalConfiguration).merge(optionsConfig);
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java
index b89982e7..bc99e235 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java
@@ -411,6 +411,92 @@ void packageName_GlobalCompilerArgumentIsIgnored() {
ProcessorAsserts.assertContaining(generated, "package test;");
}
+ @Test
+ void repeatable_GeneratesBuildersIntoDifferentPackages() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(
+ value = ext.ExternalUser.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.users"))
+ @SimpleBuilderFor(
+ value = ext.ExternalOrder.class,
+ options = @SimpleBuilder.Options(packageName = "com.example.orders"))
+ public class Builders {}
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(externalDto(), externalOrderDto(), holder);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "users/ExternalUserBuilder"),
+ "package com.example.users;");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "orders/ExternalOrderBuilder"),
+ "package com.example.orders;");
+ }
+
+ @Test
+ void repeatable_OnPackageInfo_GeneratesBuildersIntoAnnotatedPackage() {
+ JavaFileObject packageInfo =
+ JavaFileObjects.forSourceLines(
+ "test.package-info",
+ "@SimpleBuilderFor(ext.ExternalUser.class)",
+ "@SimpleBuilderFor(ext.ExternalOrder.class)",
+ "package test;",
+ "",
+ "import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(externalDto(), externalOrderDto(), packageInfo);
+
+ assertThat(compilation).succeededWithoutWarnings();
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"),
+ "package test;");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder"),
+ "package test;");
+ }
+
+ @Test
+ void repeatable_DuplicateType_SecondDeclarationSkippedWithWarning() {
+ JavaFileObject holder =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;
+ @SimpleBuilderFor(ext.ExternalUser.class)
+ @SimpleBuilderFor(ext.ExternalUser.class)
+ public class Builders {}
+ """);
+
+ Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder);
+
+ assertThat(compilation).succeeded();
+ assertThat(compilation).hadWarningContaining("already generated elsewhere");
+ // Exactly one ExternalUserBuilder was generated in package test
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder");
+ ProcessorAsserts.assertContaining(generated, "package test;");
+ }
+
+ private static JavaFileObject externalOrderDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package ext;
+ public class ExternalOrder {
+ private String id;
+ public ExternalOrder() {}
+ public String getId() { return id; }
+ public void setId(String id) { this.id = id; }
+ }
+ """);
+ }
+
private static JavaFileObject externalDto() {
return ProcessorTestUtils.forSource(
"""