Skip to content

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

Open
igel-devin-ai wants to merge 25 commits into
java-helpers:mainfrom
igel-devin-ai:devin/1790806270-mapstruct-builder-provider
Open

igel-devin-ai wants to merge 25 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 — 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 ResolvedBuilder contract (StaticFactoryCall("create") + buildMethodName), and the resolved setterSuffix — in SimpleBuildersSpiIntegration (a static holder in the processor package; mutators are package-private so only BuilderProcessor writes state). The published ResolvedBuilder is the real one BuilderScopeResolver produces (via the new resolveGeneratedBuilder, which resolve() reuses) — 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!

* @param referencedType the type element being referenced
* @return the resolved builder, or empty if no generated builder is registered for the type
*/
public Optional<ResolvedBuilder> resolveGeneratedBuilder(TypeElement referencedType) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need a return type of resolvedBuilder should publishedbuiöder not hold more information? If not why not extending the publishedbuilder?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolvedBuilder is the resolver's own currency — resolve() and the rest of the resolution API produce and consume it, so the SPI descriptor embeds it rather than a parallel structure. PublishedBuilder already carries everything the contract doesn't know: the bean TypeName and the resolved setterSuffix — the latter only exists in the generation plan's per-element config, which is why the two compose exactly at registration. Extending PublishedBuilder further would duplicate fields already reachable through the contract.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is it used and please check, if the usage in mapstruct-integration-components could be replaced! In simpleBuilders components outside from mapstruct integration this should not be used!?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usages were exactly two: resolve() reusing it internally, and BuilderProcessor publishing it to the SPI. No mapstruct component calls it — but they did consume ResolvedBuilder through PublishedBuilder. Both are now addressed: resolve() inlines the lookup again so the method exists only for the SPI publish, and PublishedBuilder flattened to plain fields (beanType, builderType, creationMethodName, buildMethodName, setterSuffix) — mapstruct components no longer touch ResolvedBuilder or BuilderInstantiation at all; registerBuilder still takes the ResolvedBuilder the resolver produced and flattens it internally.

Comment on lines +118 to +126
/**
* Whether {@code observed} belongs to the compilation this holder currently describes — {@code
* false} while it still carries a previous run's leftovers. javac creates one {@link Elements}
* per compilation and shares it between all processors, so reference identity is a reliable
* staleness check that never requires the SPI adapters to write anything.
*/
public static boolean isCurrentCompilation(Elements observed) {
return observed != null && observed == compilationElements.get();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need that? Mapstruct could run on different compilation, this should not make any difference!?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still needed — it is exactly what makes 'MapStruct on a different compilation' a no-op. Without it the holder's stale leftovers are still visible: a registry entry from a previous compile on the same JVM (Gradle daemon, incremental builds) could claim a bean by FQN, and a stale non-final state would defer every bean into MapStruct's last-round error. The Elements identity check is the read-only way to tell 'this holder describes my compile'; SPIs never write anything. It now only guards the registry/state reads — the option check no longer uses it.

Comment on lines +170 to +175
if (isCurrentCompilation(observed) && state != State.INIT) {
Boolean published = integrationEnabled;
if (published != null) {
return published;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why needing this? Could that not be removed and just returned what is below this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed — and with it the whole published switch (initCompilation's Boolean and the Elements parameter are gone). You were right to push back on my earlier claim: I verified in MappingProcessor's bytecode that getOptions() on the SPI env only contains options declared via AdditionalSupportedOptionsProvider SPIs — so -A genuinely did NOT reach us before. The new MapStructAdditionalSupportedOptionsProvider declares both spellings, so -Asimplebuilder.usingMapStructIntegration now reaches the SPIs directly and the check is a pure current-compile read: -D, then -Asimplebuilder.*, then the bare -A option (mirroring CompilerArgumentsReader). The disabled-integration test proves the -A path end-to-end.

Comment on lines +51 to +52
mapStructCompiler(new BuilderProcessor(), new MappingProcessor())
.compile(personDto(), personDtoMapper());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the builderprocessor and mappingprocessor not assigned in compile method? We need this multiple times, right!?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — mapStructCompiler() with no args now supplies the usual (BuilderProcessor, MappingProcessor) pair; only the order-swap test passes processors explicitly.

*/
class MapStructSpiProbeTest {

private static JavaFileObject personDto() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move private static function to bottom of testclass

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — all five source factories moved to the bottom of the test class.

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.
javac initializes each processor lazily in its turn, so MapStruct's
SPIs may run before BuilderProcessor.init() of the same compilation —
and see leftover state of a previous compile in a reused JVM
(daemon/incremental builds). MapStructIntegration now carries
INIT/PROCESSING/FINISHED: the published switch and registry count only
once our processor initialized, a stale FINISHED observed at SPI init
is reset to INIT for the new compile, and marked beans defer while the
registry may still fill — a missing marked bean after FINISHED falls
back gracefully instead of erroring in the last round. A regression
test covers reversed processor ordering.
The bridge the SPI adapters share with the processor — compilation
lifecycle, published integration switch and the bean-to-builder
registry — is usable by future SPI integrations beyond MapStruct, so
it moves out of the mapstruct package next to the option machinery.
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
@sonarqubecloud

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

This branch has not been deployed

No deployments
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