Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6368cee
Add MapStruct BuilderProvider for generated builders
devin-ai-integration[bot] Oct 4, 2026
6515416
Hide generated helper methods from MapStruct bean mapping
devin-ai-integration[bot] Oct 4, 2026
37aae7b
Rename MapStruct SPI classes to carry the framework name
devin-ai-integration[bot] Oct 4, 2026
e4dbd44
Add usingMapStructIntegration opt-out for the MapStruct SPIs
devin-ai-integration[bot] Oct 4, 2026
6d373c0
Reduce per-bean work in the MapStruct SPI lookups
devin-ai-integration[bot] Oct 4, 2026
50a258b
Wire usingMapStructIntegration through BuilderProcessor and share pla…
devin-ai-integration[bot] Oct 4, 2026
854a62a
Claim only builders this generator emitted
devin-ai-integration[bot] Oct 4, 2026
01c11fb
Claim builders exclusively through the published registry
devin-ai-integration[bot] Oct 4, 2026
4da0050
Guard shared state with a per-compilation lifecycle
devin-ai-integration[bot] Oct 4, 2026
8a28134
Rename MapStructIntegration to SpiIntegration in processing
devin-ai-integration[bot] Oct 4, 2026
c278bbf
Rename SpiIntegration to SimpleBuildersSpiIntegration
devin-ai-integration[bot] Oct 4, 2026
588e013
Publish resolved builders so SPIs read the registry, not elements
devin-ai-integration[bot] Oct 4, 2026
b6fb24a
Resolve MapStruct SPI review round 2 and add in-javac probe test
devin-ai-integration[bot] Oct 4, 2026
4fd7246
Bound SPI deferral by lifecycle state instead of marker alone
devin-ai-integration[bot] Oct 4, 2026
9b3e449
MapStruct SPI: marked-only deferral, analyser helpers, test cleanup
devin-ai-integration[bot] Oct 4, 2026
6feebfe
Pin SPI state to its compilation via Elements identity
devin-ai-integration[bot] Oct 4, 2026
38de9b9
Fix Sonar findings: AtomicReference, logger, NPE guard
devin-ai-integration[bot] Oct 4, 2026
0845b87
Defer marked beans in every non-final state
devin-ai-integration[bot] Oct 4, 2026
50c3d03
Drop TARGETS_REGISTERED — keep INIT/PROCESSING/FINISHED
devin-ai-integration[bot] Oct 4, 2026
111c006
Generate builders in exactly one round and defer all misses until the…
devin-ai-integration[bot] Oct 4, 2026
071e71e
Gate the generating round on the finished state and document the neve…
devin-ai-integration[bot] Oct 4, 2026
252d607
Read the integration switch from the SPI options, flatten PublishedBu…
devin-ai-integration[bot] Oct 5, 2026
e01ec66
Read the integration switch on the SPI side and bake staleness into t…
devin-ai-integration[bot] Oct 5, 2026
3cc0114
Scope every SPI lookup to the caller's compilation
devin-ai-integration[bot] Oct 5, 2026
a5a4014
Review cleanup: drop redundant findFields, stale docs and javadoc
devin-ai-integration[bot] Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ A zero-reflection Java annotation processor that generates fluent, type-safe bui
- [Advanced Features](#advanced-features)
- [External Type Builder Example](#external-type-builder-example)
- [Builder Scoping Example](#builder-scoping-example)
- [MapStruct Example](#mapstruct-example)
- [Performance Measurement](#performance-measurement)
- [Contributing](#contributing)
- [License](#license)
Expand Down Expand Up @@ -76,6 +77,7 @@ Value semantics (`equals`, `hashCode`, `toString`) and generating brand-new immu
- **Annotation Preservation**: Validation annotations are automatically copied to builder methods
- **With Interface Pattern**: Type-safe object modifications using generated With interfaces
- **Jackson Support**: Supporting Jackson deserialization via `@JsonPOJOBuilder` and optional generation of `SimpleModule`s (one per package) (both need to be enabled)
- **MapStruct Support**: Generated builders are automatically detected by [MapStruct](https://mapstruct.org/) via a `BuilderProvider` SPI when simple-builders-processor and mapstruct-processor share the annotation processor path
- **External Type Builders**: `@SimpleBuilderFor` generates builders for types that cannot be annotated - for example classes from third-party libraries
- **JavaDoc Usage Examples**: Generated builder methods include auto-generated usage examples in their JavaDoc (per-method fluent snippets plus a class-level example), so IDE tooltips show exactly how to use each builder

Expand Down Expand Up @@ -489,6 +491,14 @@ A runnable example demonstrating package-scoped builder generation and usage:
- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows the consumer overload for `trusted` and plain setters for `library` and `sponsor`
- **Tests**: [`ScopedOwnerDtoBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java) - Asserts the generated API shape

### MapStruct Example

A runnable example of the MapStruct `BuilderProvider` integration - no configuration needed beyond putting mapstruct-processor on the same annotation processor path. A bundled `AccessorNamingStrategy` marks the generated helper methods (`add2*`, `*Update`, `Supplier`/`Consumer`/`format` overloads, `conditional(...)`) as non-setters, honoring per-bean naming configuration like `setterSuffix`, so only direct property setters participate in bean mapping - no phantom "unmapped target property" warnings.

- **Mapper**: [`PersonDtoMapper.java`](example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java) - Plain `@Mapper` interface; MapStruct resolves `PersonDtoBuilder` automatically
- **Generated implementation**: [`PersonDtoMapperImpl.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java) - Uses `PersonDtoBuilder.create()`, the plain setter overloads, and `build()`
- **Tests**: [`MapStructIntegrationTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java) - Verifies end-to-end mapping through the generated builder

These examples serve as both documentation and integration tests for the annotation processor.

## Performance Measurement
Expand Down
22 changes: 22 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,27 @@ This is highly recommended to ensure deterministic output location and avoid spl

---

#### `usingMapStructIntegration`

**Default**: `ENABLED` | **Compiler Option**: `-Asimplebuilder.usingMapStructIntegration=ENABLED|DISABLED`

Controls the MapStruct SPI adapters bundled in the processor jar (`MapStructBuilderProvider` and `MapStructAccessorNamingStrategy`, registered via `META-INF/services`). They make MapStruct auto-detect generated builders when `simple-builders-processor` and `mapstruct-processor` share the annotation processor path — no annotation attribute exists because the SPIs are discovered globally by MapStruct itself.

The SPIs read this option from the options map MapStruct hands them, with the same `-D` > `-A` > bare-option precedence as every option. MapStruct only forwards declared options into SPI environments, so the processor jar also ships an `AdditionalSupportedOptionsProvider` declaring both spellings — `-Asimplebuilder.usingMapStructIntegration` and the bare `-AusingMapStructIntegration` reach the SPIs, and `-D` works too since system properties are JVM-global (the only mechanism when only the SPI jar is on the processor path).

**When DISABLED**: The provider returns no builder candidates and the naming strategy keeps the stock MapStruct behaviour, so generated builders are treated like ordinary classes.

**Scope note**: `BuilderProcessor` generates in exactly one round — the first round carrying its annotations. Beans whose types first appear in a later round (emitted by other processors after that round) get no builder, and only beans the processor plans in that round are paired with their builder through the SPIs; builders produced by earlier compilations are not discovered.

**Example**:
```xml
<compilerArgs>
<arg>-Asimplebuilder.usingMapStructIntegration=DISABLED</arg>
</compilerArgs>
```

---

### Documentation

#### `generateJavaDoc`
Expand Down Expand Up @@ -1692,6 +1713,7 @@ methodAccess = AccessModifier.PRIVATE
-Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED
-Asimplebuilder.generateJacksonModule=ENABLED|DISABLED
-Asimplebuilder.jacksonModulePackage=com.your.package
-Asimplebuilder.usingMapStructIntegration=ENABLED|DISABLED

# Documentation
-Asimplebuilder.generateJavaDoc=ENABLED|DISABLED
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.javahelpers.simple.builders.example;

import java.util.ArrayList;
import java.util.List;
import javax.annotation.processing.Generated;

@Generated(
value = "org.mapstruct.ap.MappingProcessor"
)
public class PersonDtoMapperImpl implements PersonDtoMapper {

@Override
public PersonDto copy(PersonDto source) {
if ( source == null ) {
return null;
}

PersonDtoBuilder personDto = PersonDtoBuilder.create();

personDto.birthdate( source.getBirthdate() );
personDto.mannschaft( source.getMannschaft() );
personDto.name( source.getName() );
List<String> list = source.getNickNames();
if ( list != null ) {
personDto.nickNames( new ArrayList<String>( list ) );
}

return personDto.build();
}
}
16 changes: 16 additions & 0 deletions example/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<commons-lang.version>3.21.0</commons-lang.version>
<junit-jupiter.version>6.1.3</junit-jupiter.version>
<jackson-databind.version>2.22.3</jackson-databind.version>
<mapstruct.version>1.6.3</mapstruct.version>

<plugin.maven.compiler.version>3.16.0</plugin.maven.compiler.version>
<plugin.maven.deploy.version>3.2.0</plugin.maven.deploy.version>
Expand Down Expand Up @@ -64,6 +65,13 @@
<version>${jackson-databind.version}</version>
</dependency>

<!-- MapStruct annotations for the MapStruct integration example -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

</dependencies>


Expand Down Expand Up @@ -114,9 +122,17 @@
<artifactId>example-custom-generator</artifactId>
<version>${project.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Averbose=${simplebuilder.verbose}</arg>
<!-- Keep the committed PersonDtoMapperImpl deterministic for CI's regen check -->
<arg>-Amapstruct.suppressGeneratorTimestamp=true</arg>
<arg>-Amapstruct.suppressGeneratorVersionInfoComment=true</arg>
</compilerArgs>
</configuration>
</execution>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 with 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.example;

import org.mapstruct.Mapper;

/** Maps {@link PersonDto} using the generated {@code PersonDtoBuilder} discovered by MapStruct. */
@Mapper
public interface PersonDtoMapper {

PersonDto copy(PersonDto source);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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 with 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.example;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;

/**
* Verifies that MapStruct discovers and uses the generated {@code PersonDtoBuilder} through the
* {@code MapStructBuilderProvider} SPI shipped in simple-builders-processor.
*/
class MapStructIntegrationTest {

private static final Path GENERATED_MAPPER_IMPL =
Path.of(
"generated-example-builder",
"org",
"javahelpers",
"simple",
"builders",
"example",
"PersonDtoMapperImpl.java");

@Test
void shouldMapPersonDtoUsingGeneratedBuilder() {
PersonDto source = PersonDtoBuilder.create()
.name("Alice")
.birthdate(LocalDate.of(1990, 1, 1))
.build();

PersonDto copy = Mappers.getMapper(PersonDtoMapper.class).copy(source);

assertNotNull(copy);
assertEquals("Alice", copy.getName());
assertEquals(LocalDate.of(1990, 1, 1), copy.getBirthdate());
}

@Test
void shouldGenerateMapperUsingPersonDtoBuilder() throws IOException {
String mapperImpl = Files.readString(GENERATED_MAPPER_IMPL);
assertTrue(
mapperImpl.contains("PersonDtoBuilder.create()"),
"MapStruct mapper implementation should use PersonDtoBuilder.create()");
assertTrue(
mapperImpl.contains(".build()"),
"MapStruct mapper implementation should finish via build()");
}
}
18 changes: 18 additions & 0 deletions processor/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
<junit-jupiter.version>6.1.3</junit-jupiter.version>
<google-compile-testing.version>0.23.0</google-compile-testing.version>
<jackson-databind.version>2.22.3</jackson-databind.version>
<mapstruct.version>1.6.3</mapstruct.version>

<!-- Java and Compiler Properties -->
<java.version>17</java.version>
Expand Down Expand Up @@ -125,6 +126,15 @@
<version>${roaster.version}</version>
<scope>runtime</scope>
</dependency>
<!-- MapStruct SPI (BuilderProvider) - only needed to compile the SPI implementation;
loaded by mapstruct-processor at annotation processing time, never at runtime -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>

<!-- Test Dependencies -->
<dependency>
Expand Down Expand Up @@ -154,6 +164,14 @@
<scope>test</scope>
</dependency>

<!-- MapStruct annotations for compiling test mappers against the SPI integration -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
<scope>test</scope>
</dependency>

<!-- Jackson Annotations for testing Jackson support -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
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.analysis.JavaLangMapper;
import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator;
import org.javahelpers.simple.builders.processor.exceptions.BuilderException;
import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator;
Expand Down Expand Up @@ -109,6 +110,8 @@ public synchronized void init(ProcessingEnvironment processingEnv) {
BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger);
logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig);

SimpleBuildersSpiIntegration.initCompilation(processingEnv.getElementUtils());

this.context = new ProcessingContext(logger, globalConfig, processingEnv);
this.codeGenerator = new RoasterCodeGenerator(context, processingEnv);
this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig);
Expand All @@ -131,11 +134,14 @@ public synchronized void init(ProcessingEnvironment processingEnv) {
}

/**
* Always returns {@code false}, so this method intentionally never claims annotations (hence the
* {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and {@link
* SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template annotations;
* claiming would therefore hide every annotation in the round — including foreign ones like
* MapStruct's {@code @Mapper} — from later processors.
* Generates all builders in exactly one round — the first round carrying simple-builders
* annotations — and marks the SPI registry final when that round ends.
*
* <p>Always returns {@code false}, so this method intentionally never claims annotations (hence
* the {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and
* {@link SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template
* annotations; claiming would therefore hide every annotation in the round — including foreign
* ones like MapStruct's {@code @Mapper} — from later processors.
*/
@Override
@SuppressWarnings("java:S3516")
Comment thread
AndreasIgel marked this conversation as resolved.
Comment thread
AndreasIgel marked this conversation as resolved.
Expand All @@ -147,10 +153,10 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment

// Generate Jackson Module if processing is over and feature is enabled
if (roundEnv.processingOver()) {
SimpleBuildersSpiIntegration.finishCompilation();
generateJacksonModules(context.getPerformanceTracker());
return false;
}

PerformanceTracker tracker = context.getPerformanceTracker();
context.info("simple-builders: PROCESSING ROUND START");

Expand Down Expand Up @@ -182,6 +188,13 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
context.debug(
"simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.",
elementsToGenerate.size(), sortedElements.size());
if (SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(
processingEnv.getElementUtils())
|| (sortedElements.isEmpty() && sortedHolders.isEmpty())) {
// simple-builders generates in exactly one round — the first round carrying its
// annotations; elements first appearing in later rounds get no builder.
return false;
}
registerGeneratedTypes(elementsToGenerate);

int successfulGenerations = generateBuilders(elementsToGenerate, tracker);
Expand All @@ -195,6 +208,8 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment

// Reset indentation level at the end of each processing round to prevent cascading errors
context.resetIndentation();
// The generating round is done — the registry is final for SPI adapters from here on.
SimpleBuildersSpiIntegration.finishCompilation();
return false;
}

Expand Down Expand Up @@ -522,13 +537,20 @@ private void registerGeneratedTypes(List<ElementToGenerate> elementsToGenerate)
if (!(elementToGenerate.element() instanceof TypeElement targetType)) {
continue;
}
scopeResolver.registerGeneratedBuilder(
new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()),
TypeName beanType = JavaLangMapper.mapToTypeName(targetType, context);
TypeName builderType =
builderTypeName(
targetType,
effectiveBuilderPackage(
elementToGenerate.reportingElement(), elementToGenerate.config()),
elementToGenerate.config()));
elementToGenerate.config());
scopeResolver.registerGeneratedBuilder(beanType, builderType);
scopeResolver
.resolveGeneratedBuilder(targetType)
.ifPresent(
resolvedBuilder ->
SimpleBuildersSpiIntegration.registerBuilder(
beanType, resolvedBuilder, elementToGenerate.config().getSetterSuffix()));
}
}

Expand Down
Loading
Loading