Skip to content

feat: auto-detect generated builders in MapStruct via BuilderProvider SPI (#300) - #312

Merged
AndreasIgel merged 31 commits into
java-helpers:mainfrom
igel-devin-ai:devin/1790806270-mapstruct-builder-provider
Oct 11, 2026
Merged

AndreasIgel merged 31 commits into
java-helpers:mainfrom
igel-devin-ai:devin/1790806270-mapstruct-builder-provider

Conversation

@igel-devin-ai

@igel-devin-ai igel-devin-ai commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #300.

When simple-builders-processor and mapstruct-processor share the annotation processor path, MapStruct now automatically uses the generated builders — the same drop-in integration Jackson already has. Two SPI adapters are registered via @AutoService inside the processor jar:

  • MapStructBuilderProvider (org.mapstruct.ap.spi.BuilderProvider) supplies create()/build() for beans whose builders the processor plans.
  • MapStructAccessorNamingStrategy (org.mapstruct.ap.spi.AccessorNamingStrategy, extending DefaultAccessorNamingStrategy) classifies generated helper methods (add2*, *Update, conditional, Supplier/Consumer/format/varargs overloads) as OTHER, so only the direct property setter counts as a write accessor — its expected name is derived with the generators' own generateBuilderMethodName (setterSuffix applied as a prefix, e.g. withName), and the expected property set mirrors BuilderDefinitionCreator (bean setter methods plus the selected constructor's parameters) — no ambiguous-overload dependency and no phantom Unmapped target property warnings.

How beans are paired with builders

Exclusively through the registry BuilderProcessor publishes. While planning generation, the processor records a PublishedBuilder descriptor per bean — TypeNames of bean and builder, the creation/build method names, and the resolved setterSuffix — in SimpleBuildersSpiIntegration (a static holder in the processor package; mutators are package-private so only BuilderProcessor writes state). The published contract comes from BuilderScopeResolver.resolveGeneratedBuilder — the same StaticFactoryCall("create") + constructor-copy contract resolve() trusts for generated builders — flattened to names so SPIs never touch the resolution model: the registry never fabricates or trims contract information. Both SPIs only consult those descriptors: the provider resolves create/build by the published names instead of scanning candidates, the naming strategy reads bean type and setterSuffix off the descriptor instead of bean annotation mirrors. No name conventions, no annotation-derived claiming — a type is claimed only if it is a builder this processor is emitting in the current compilation; foreign builders (Lombok, Immutables, hand-written) can never be claimed or shadowed.

Compilation lifecycle

BuilderProcessor generates in exactly one round — the first round carrying simple-builders annotations — and marks the registry final when that round ends (processingOver stays as the catch-all for compiles with no content). The SPI state is therefore INIT → PROCESSING → FINISHED, where FINISHED already means "the generating round ran".

  • PROCESSING is published by BuilderProcessor.init(), FINISHED at the end of the generating round (or processingOver for content-free compiles).
  • SPI adapters never write state. Every read (builderFor, builderByName, isSimpleBuildersFinishedForIntegration) takes the caller's Elements — javac hands every processor of one compilation the same instance and a different one per compilation, so the holder answers empty/not-finished for foreign compilations (reused JVMs: Gradle daemon, in-process javac). The staleness guard lives inside the API; isCurrentCompilation stays an internal detail.
  • A registry miss while the state is not FINISHED defers the mapper via TypeHierarchyErroneousException for every bean — no bean-side marker is needed, so @SimpleBuilderFor targets resolve too.
  • Once FINISHED, a miss is definitive: a bean marked for generation (@SimpleBuilder/template, not @Ignore4BuilderGeneration) that was never registered (skipped by planning: outside builderGenerationPackages, usingExistingBuilders, …) is reported as a warning and maps without a builder; unmarked beans get null — nothing foreign is ever claimed. The marker check only serves that warning, never deferral or claiming.
  • A published builder whose type is not emitted yet always defers — generated types materialize in the round after the generating round.
  • The -A/-D switch is read by the SPIs themselves from their own options — a pure current-compile read needing no staleness guard.

Consequences by design:

  • Builders produced by earlier compilations (committed sources, other modules) are not discovered — only beans the processor plans in the generating round get builder mapping.
  • Elements first appearing in a later round (emitted by other processors after the generating round) get no builder — documented in docs/CONFIGURATION.md.

Configuration

-Asimplebuilder.usingMapStructIntegration=DISABLED switches the whole integration off (fallback to setter/ctor mapping). The SPIs resolve the switch themselves from the options map MapStruct hands them — MapStruct only forwards declared options, so the shipped MapStructAdditionalSupportedOptionsProvider declares both spellings — through static CompilerArgumentsReader overloads sharing the usual -D > -A > bare precedence with the processor. SimpleBuildersSpiIntegration stays pure registry plus lifecycle state. Documented in docs/CONFIGURATION.md like the Jackson options.

Verification

MapStructSpiIntegrationTest compiles a DTO + @Mapper in-process with both processors and asserts the generated MapperImpl uses PersonDtoBuilder.create() … .build() with zero unmapped-target warnings; dedicated cases cover reversed processor ordering and the -A…=DISABLED switch end-to-end. MapStructSpiProbeTest drives both SPIs from a probe processor ahead of BuilderProcessor inside javac — deferral for every bean while the registry is unpublished, published-but-unemitted deferral, null for foreign/opted-out beans once final, a scoped-out marked bean deferred then warned at FINISHED, SETTER/OTHER classification, getNoType — covering paths MapStruct 1.6.3 itself never invokes. SimpleBuildersSpiIntegrationTest covers the lifecycle, the Elements-identity staleness rules and the precedence rules directly.

Depends on the setter-last ordering merged as b31a55c (#325) for last-wins robustness of the direct setter.

Written by Devin

@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1790806270-mapstruct-builder-provider branch 10 times, most recently from f181c5f to 014940e Compare October 4, 2026 11:32
@codecov

codecov Bot commented Oct 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Registers SimpleBuildersBuilderProvider via META-INF/services so
MapStruct auto-detects @SimpleBuilder-generated builders when both
processors share the annotation processor path. Resolution locates the
builder via the @BuilderImplementation annotation (handles custom
package/suffix configuration), falls back to the <Bean><suffix> name
convention, and defers a round via TypeHierarchyErroneousException when
the builder is not generated yet.

BuilderProcessor.process() now returns false: it claims all annotation
types (*) and returning true starved other processors - MapStruct's
MappingProcessor never saw @Mapper.

Includes an example PersonDtoMapper plus an integration test asserting
the generated mapper uses PersonDtoBuilder.create() and build(). The
suppressGeneratorTimestamp/VersionInfoComment processor args keep the
committed generated mapper deterministic for the regen check.

Implements java-helpers#300.
Adds SimpleBuildersAccessorNamingStrategy (a DefaultAccessorNamingStrategy
subclass registered via META-INF/services): for methods declared on a
simple-builders builder, only the direct property setter - <field>
<setterSuffix> taking a single argument of the field type - stays a write
accessor. add2*, *Update, conditional(...) and the Supplier/Consumer/
format overloads return OTHER, eliminating phantom 'Unmapped target
property' warnings. Per-bean naming configuration is resolved from the
bean's @SimpleBuilder/@SimpleBuilder.Template options mirrors plus the
-Asimplebuilder.* processor options; all other types keep the default
strategy behavior.

Shared annotation-mirror helpers move to package-private AnnotationSupport,
now also used by SimpleBuildersBuilderProvider.
MapStructBuilderProvider and MapStructAccessorNamingStrategy make the
SPI purpose visible without reading the implementation.
MapStruct does not forward foreign annotation processor options to SPI
environments, so the switch follows the simplebuilder.* convention and
prefers the JVM system property: -Dsimplebuilder.usingMapStructIntegration
=false restores stock MapStruct behaviour (builder candidates stay hidden
and the naming strategy defers to the default). The same option() helper
now also makes builderSuffix/setterSuffix reachable inside the SPI.
findBuilderInfo runs for every type MapStruct inspects, per mapper and
per round, with no caching on MapStruct's side. Resolve builders by
conventional name first (a getTypeElement per candidate package/suffix)
and run the @BuilderImplementation package scan only as a last resort
for beans marked for generation. Naming candidates are resolved in a
single options pass, positive BuilderInfo results are cached per bean,
and annotation values are read via getElementValues (explicit values
only) instead of materializing defaults.

Also: the builder candidates now include the global -D builderSuffix so
a custom suffix cannot strand a marked bean in endless deferral; the
creation method prefers create, then of, then alphabetical order; the
naming strategy's name fallback only resolves marked beans (a bean
literally named <X>Builder no longer hides its own methods); option
resolution is shared via AnnotationSupport.systemOption.
…nned builder names

MapStruct never forwards foreign annotation processor options to SPI
environments, so the BuilderProcessor resolves
simplebuilder.usingMapStructIntegration itself (same -D over -A
precedence as every option) and publishes it together with the bean to
builder qualified names it plans to emit to the SPIs sharing the
classloader. MapStructIntegration holds that per-compilation state and
is reset on every processor init so stale entries cannot leak across
Gradle daemon compilations.

Both SPIs consult the registry first, giving exact answers for
SimpleBuilderFor targets and custom packageName layouts, and fall back
to the conventional name lookup for builders generated in earlier runs.
The package scan is dropped: the generator always emits the
conventional name, so the scan only served exotic hand-written
builders.
Gate the conventional-name lookup on @BuilderImplementation(forClass =
<bean>): the marker has CLASS retention, so it is readable on builders
generated in earlier compilations, while foreign types that merely
match the conventional name are never claimed. The naming strategy
drops its marker-free name fallback for the same reason — a builder is
attributed to this generator only through the registry or the marker
on the type itself. Builders emitted with
usingBuilderImplementationAnnotation=DISABLED keep working through the
registry in the run that produces them.
No extra detection at all: a type is attributed to this generator only
when BuilderProcessor published it for the current compilation, so
foreign builders and builders from earlier runs can never be claimed
or shadow other solutions. The naming strategy's bean resolution and
the provider's lookup both become pure registry reads; the now-unused
annotation-based detection helpers are removed.
The status reports where simple-builders' own builder generation
stands and the registry only lists builders BuilderProcessor plans to
emit — make both scopes visible in the name. Javadoc states the
lifecycle is transitioned by BuilderProcessor alone; SPI adapters only
observe, with spiInitialized merely aging out a stale FINISHED.
Review follow-up: the registry now carries PublishedBuilder descriptors
(ResolvedBuilder + configured setterSuffix) resolved by BuilderProcessor
at registration time. The provider resolves create/build by the
descriptor's named contract instead of scanning candidates; the naming
strategy gets bean and setterSuffix from the descriptor and stops
reading bean annotation mirrors — AnnotationSupport shrinks away, its
only remaining use being the deferral marker inlined in the provider.

SimpleBuildersSpiIntegration moves to the processor package with
package-private mutators (only BuilderProcessor transitions state),
Optional lookups keyed both directions, and the switch renamed to
integrationEnabled. Registered-but-unresolved types defer only while
the compilation is not FINISHED. Adds a unit test for the lifecycle,
registry and switch precedence.
- registerBuilder takes (beanType, builderType, setterSuffix) and builds
  the ResolvedBuilder internally under the standard generated-builder
  contract; BuilderProcessor reuses its computed builderTypeName and maps
  the bean via JavaLangMapper.mapToTypeName
- initCompilation parameter renamed to integrationEnabled
- New MapStructSpiProbeTest: a probe processor ahead of BuilderProcessor
  drives both SPIs inside javac — deferral for marked unregistered and
  template-annotated beans, null for foreign/ignored beans, BuilderInfo
  + same-instance cache hit once emitted, noType lookup, and naming
  classification of generated builder methods (SETTER vs OTHER)
New fourth state TARGETS_REGISTERED marks the end of BuilderProcessor's
first processing round. A registry miss before it defers the mapper for
any bean — this covers @SimpleBuilderFor targets, which carry no marker
of their own, with no annotation scanning. Afterwards only beans marked
for generation defer while their entry may still arrive in a later
round; foreign and opted-out beans resolve to null immediately. Probe
and lifecycle tests cover both windows.
Restrict TypeHierarchyErroneousException deferral to beans marked for
generation while INIT/PROCESSING holds; unmarked beans return null in
every state so foreign types are never claimed or delayed. At FINISHED a
marked bean without a registered builder reports a stderr warning (SPI
environments expose no Messager). Published-but-unemitted builder types
keep deferring until FINISHED.

Move the parameterless method scan into JavaLangAnalyser as
findMethodWithoutParameters and use its findAnnotation helpers for the
marker and template checks. Reword the integration-switch comment to not
resemble code and suppress java:S3516 on process(), which must always
return false to leave annotations unclaimed.

Restructure the SPI tests to the repo conventions: test sources as
private static helper methods, a createCompiler(Processor...) overload
in ProcessorTestUtils preserving invocation order, an
assertNoWarningContaining helper, and a scoped-out marked bean covering
the TARGETS_REGISTERED/FINISHED miss paths.
SPI adapters now only read the holder: staleness detection moves to
isCurrentCompilation(Elements) — javac hands all processors of one
compilation the same Elements instance, so a mismatch means the holder
still describes a previous run. This replaces spiInitialized().

- targetsRegistered() transitions unconditionally
- registerBuilder takes the ResolvedBuilder (real instance from
  resolveGeneratedBuilder, new API extracted in BuilderScopeResolver
  which now deduplicates the in-round contract construction)
- isMapstructGenerationEnabled(elementUtils, options) replaces
  isIntegrationDisabled; stale observers fall back to -D/-A
- isSimpleBuildersFinishedForIntegration() helper replaces state
  comparisons at deferral points
- Naming strategy defers via TypeHierarchyErroneousException when a
  published bean type is not emitted yet
- Bean field scanning moved into JavaLangAnalyser.findFields
  (non-static incl. inherited)
- compilationElements held in AtomicReference (java:S3077 — volatile on
  a non-thread-safe type is not enough)
- finished-state warning goes through System.Logger (java:S106) — SPI
  environments still expose no Messager
- null-guard annotation type element before meta-annotation lookup
  (javabugs:S2259)
A registry miss for a marked bean now defers via\nTypeHierarchyErroneousException throughout the non-final phase (the\nTARGETS_REGISTERED fallback is dropped — a bean may still be registered\nin a later round or with an element another processor emits); once\nFINISHED the miss is definitive and reported as a warning. The skipped\nprobe compile runs behind BuilderProcessor so FINISHED is observable and\nits warning exercised end-to-end.
TARGETS_REGISTERED behaved identically to PROCESSING in every check\nsince marked beans defer through all non-final states; the lifecycle\nsimplifies to INIT -> PROCESSING -> FINISHED. FINISHED stays: it is the\nonly point where 'never registered' is provable — later rounds still\nregister targets, and never-planned marked beans need warn+fallback\ninstead of a last-round error.
… registry is final

BuilderProcessor now generates in the first round carrying its
annotations and marks the registry final when that round ends, so the
SPI lookup can treat every miss before it as "maybe later": any bean
defers via TypeHierarchyErroneousException, which also covers
SimpleBuilderFor targets that carry no bean-side marker. The marker
check remains only to warn for marked beans missing once the registry
is final. Elements first appearing in later rounds get no builder;
documented in CONFIGURATION.
…r-claim contract

The extra 'generated' member duplicated what the SPI state already
knows: FINISHED means the generating round ran. process() also gains
javadoc explaining why it must always return false — claiming is
all-or-nothing over the wildcard supported set required for template
discovery.
…ilder, tidy tests

MapStruct forwards only declared options into SPI environments: the new
MapStructAdditionalSupportedOptionsProvider declares both option spellings
so -Asimplebuilder.usingMapStructIntegration reaches the SPIs without any
processor-published state. isMapstructGenerationEnabled now reads -D then
-A (prefixed, then bare) like CompilerArgumentsReader and needs no
compilation check.

PublishedBuilder flattens to bean/builder type plus creation and build
method names, so SPI adapters no longer touch ResolvedBuilder;
resolveGeneratedBuilder's only outside caller is the SPI publish in
BuilderProcessor and resolve() goes back to inlining it.

Test cleanups: default processor pair inside the compile helper, source
factories moved to the bottom of the probe test.
@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1790806270-mapstruct-builder-provider branch from 5d0aacf to 252d607 Compare October 5, 2026 06:50
…he finished check

The MapStruct SPIs resolve simplebuilder.usingMapStructIntegration from the
options map MapStruct hands them — through static CompilerArgumentsReader
overloads taking an explicit options map, sharing the -D > -A > bare
precedence with the processor. SimpleBuildersSpiIntegration is back to
pure registry plus lifecycle state.

isSimpleBuildersFinishedForIntegration(elements) now answers FINISHED only
for the compilation the given Elements belongs to, so no caller can
observe a previous run's final state.
builderFor, builderByName and isSimpleBuildersFinishedForIntegration now
take the caller's Elements and answer empty/not-finished for foreign
compilations — the staleness guard lives inside the API instead of at each
call site. isCurrentCompilation and state() are internal again
(package-private), the strategy's early compilation check drops because an
empty registry already yields default classification, and the finished
check's javadoc documents why the guard exists: the classloader outlives
the compilation, so a previous run's FINISHED plus registry must never be
trusted before this run's init.
- JavaLangAnalyser.findFields removed: Elements.getAllMembers already
  covers inherited fields; the naming strategy uses ElementFilter on it
  with a static-field filter directly
- BuilderProcessor: duplicate never-claim comment removed (method javadoc
  carries the contract)
- SimpleBuildersSpiIntegration: stale reference to package-private
  isCurrentCompilation in the class javadoc fixed
- docs/CONFIGURATION.md: usingMapStructIntegration section rewritten for
  the SPI-side option read via AdditionalSupportedOptionsProvider
- java:S1192: extract GENERATED_FACTORY_METHOD_NAME for the generated
  builder's factory method name, used by the generated-path
  ResolvedBuilder construction and the factory-name preference list
- javabugs:S2259: Objects.requireNonNull on isBuilderGenerationTarget's
  beanElement — findAnnotation's null tolerance marked the argument as
  possibly-null for the later getAnnotationMirrors() access
… sources

setterSuffix is prepended by the generators (withName), not appended —
the naming strategy built <field><suffix> so any configured suffix hid
every write accessor. Share the derivation via a context-free
generateBuilderMethodName(field, prefix) overload.

The expected direct setters also derived from bean fields, but builder
fields come from the bean's setter methods and the selected constructor's
parameters: a field-only bean emits no setter, and a setter-only
property would have been misclassified OTHER. Mirror
BuilderDefinitionCreator via Elements overloads of
findAllPossibleSettersOfClass (same ordering, first wins) and
findConstructorForBuilder in JavaLangAnalyser.

Probe test gains a setterSuffix='with' bean asserting withName stays the
single SETTER while every prefixed helper resolves to OTHER.
Every integration case now exercises the demanding order — MapStruct's
lookup reaches the SPI before the generating round ran, so the deferral
path is the baseline rather than a single dedicated case. The explicit
ordering test flips to cover the BuilderProcessor-first order, keeping
both invocation orders verified.
The example compile now exercises the demanding processor order in the
real Maven build too — MapStruct's SPI lookups defer until the builder
registry is published, matching the integration test's new default. The
committed PersonDtoMapperImpl still binds PersonDtoBuilder.create() and
is regenerated byte-identical.
The builder's own TrackedValue<X> fields are the property set, so the
expected direct setters are read off the builder element instead of
re-deriving them from the bean's setters and selected constructor.
Drops the bean resolution (and the published-but-unemitted deferral,
which the provider already covers) and reverts the now-unused Elements
overloads in JavaLangAnalyser.
The skipped compile now includes a bean marked only via a template
annotation (@SimpleMinimalBuilder), exercising the finished-state
meta-annotation marker check. A foreign class's own setter is probed
to stay SETTER, covering the unpublished-builder path of the naming
strategy.
@sonarqubecloud

Copy link
Copy Markdown

@AndreasIgel
AndreasIgel merged commit 48c1bb3 into java-helpers:main Oct 11, 2026
6 checks passed
devin-ai-integration Bot added a commit to igel-devin-ai/simple-builders-devin-fork that referenced this pull request Oct 11, 2026
…#343)

The merged java-helpers#312 files carry a shortened variant of the MIT text; the
new license check flagged them in CI. license:format rewrites them to
the canonical header.
AndreasIgel pushed a commit that referenced this pull request Oct 12, 2026
* build: add license-header check to the build (#343)

Adds com.mycila:license-maven-plugin (5.1.2) with a single check
execution, wired per module like PrimeFaces. Header text lives in
conf/header.txt; a COPYRIGHT_LINE header section accepts the existing
copyright-year variants (2025, 2025-2026, without "(c)") while new
files get the canonical line via license:format.

Includes cover src/main/java/** and src/test/java/** per module; the
generated example-builder tree and target/ are excluded. Missing or
corrupted headers (including two files carrying Apache 2.0 headers)
were normalized by license:format.

* build: reuse LICENSE.txt as license header source (#343)

LICENSE.txt is byte-identical to the header text, so point the plugin
at it and drop conf/header.txt. The header-section key is the literal
copyright line itself, keeping per-file year variants valid.

* build: normalize license headers on MapStruct SPI files (#343)

The merged #312 files carry a shortened variant of the MIT text; the
new license check flagged them in CI. license:format rewrites them to
the canonical header.

* build: add license-header check to performance-test module (#343)

The module is activated only by the performance-test profile and has no
committed Java sources; its DTOs are generated into src/main/java/.../
performance_test/library/** by generate_classes.py and builder output
lands in generated-performance-builder/. Both generated roots are
excluded so generated files are never flagged.

* ci: run license check on performance-test module in default pipeline (#343)

The module only enters the reactor under the performance-test profile,
which CI never enables. A dedicated license:check step with the profile
active covers it without building the module.

* build: use uniform 2025-2026 copyright range, drop redundant headerSection config (#343)

- normalize all source headers and LICENSE.txt to Copyright (c) 2025-2026
- remove headerSections from all pom.xmls: strict literal match against
  LICENSE.txt enforces the uniform range; the loose ensureMatch regex and
  2026 defaultValue would have let or written stale years
- delete accidentally added empty .mvn/maven.config

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Andreas Igel
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MapStruct integration: ship a BuilderProvider SPI so generated builders are auto-detected

2 participants