What this is about
When a generated builder has a field whose type also has a builder, simple-builders can emit nested builder helpers (e.g. address(a -> a.city("X")) instead of address(new Address(...))). To do that, the processor must locate and use the referenced builder class for the field type.
Current state
BuilderScopeResolver resolves a candidate builder named <T><builderUsageSuffix> in the referenced type's package and checks a fixed, all-or-nothing contract:
- a constructor accepting the referenced type
T
- a parameterless
build() method returning T
If both exist, all nested-builder helpers are generated for that field. If either is missing, none are — the field silently falls back to a plain setter.
Problems with this
-
Latent compile break. The contract does not match what the generated code actually calls. The nested-builder consumer emits:
FooBuilder b = this.foo.isSet()
? new FooBuilder(this.foo.value()) // needs ctor(T)
: new FooBuilder(); // needs ctor() — NOT checked today!
A hand-written builder with FooBuilder(T) + build() but no no-arg constructor passes the contract and produces uncompilable generated code.
-
No factory-method support. Generated builders expose a static create(), but the contract only recognizes constructors, and generated code always instantiates via new.
-
All-or-nothing granularity. One missing member disables every helper, even those the builder could satisfy.
Sub-tasks
- #308 — Extend the minimal contract to require the empty constructor (breaking: fixes the latent compile break; builders lacking
B() degrade to plain setters)
- #309 — Support static factory methods for referenced builder creation (
create(), and create(T)/of(T) preferred over the copy ctor)
- #310 — Seed referenced builders via field functions when no copy function exists (empty-builder path + one setter call per readable property, emitted as a block lambda; only used when every property is covered — partial coverage still degrades to plain setters with a debug log; also makes FreeBuilder/AutoValue shapes resolve)
- #313 — Visibility-aware contract checks (verify
ctor(T)/B()/build()/factories are accessible from the generated builder's package, not just present)
- #317 — Find builders anchored inside the referenced type (MapStruct-style
T.builder()) as a second lookup location next to the same-package candidate
- #319 — Detect the build function by signature, not by name (any public non-static no-arg method returning
T; java.lang.Object methods excluded to avoid the StringBuilder/String false positive)
Reference: MapStruct's DefaultBuilderProvider
MapStruct detects builders structurally: a public static parameterless method on the target type (e.g. Person.builder()) returning a builder B, where B has a parameterless build method (named build, configurable via @Builder(buildMethod=...)). Instantiation always goes through the static factory, never new. Ambiguous factories produce a warning and no builder; java.*/javax.* are excluded; a BuilderProvider SPI allows custom detection.
Difference to keep in mind: we anchor on a naming convention (<T><suffix> in T's package), MapStruct anchors on a factory method on T itself.
Out of scope / open questions
- Whether to also accept a
builder()/create() marker method on the referenced type as a builder anchor (MapStruct-style).
builderUsagePackages semantics stay as they are.
(Spun off from the discussion on #296 — not part of the @SimpleBuilderFor feature.)
What this is about
When a generated builder has a field whose type also has a builder, simple-builders can emit nested builder helpers (e.g.
address(a -> a.city("X"))instead ofaddress(new Address(...))). To do that, the processor must locate and use the referenced builder class for the field type.Current state
BuilderScopeResolverresolves a candidate builder named<T><builderUsageSuffix>in the referenced type's package and checks a fixed, all-or-nothing contract:Tbuild()method returningTIf both exist, all nested-builder helpers are generated for that field. If either is missing, none are — the field silently falls back to a plain setter.
Problems with this
Latent compile break. The contract does not match what the generated code actually calls. The nested-builder consumer emits:
A hand-written builder with
FooBuilder(T)+build()but no no-arg constructor passes the contract and produces uncompilable generated code.No factory-method support. Generated builders expose a static
create(), but the contract only recognizes constructors, and generated code always instantiates vianew.All-or-nothing granularity. One missing member disables every helper, even those the builder could satisfy.
Sub-tasks
B()degrade to plain setters)create(), andcreate(T)/of(T)preferred over the copy ctor)ctor(T)/B()/build()/factories are accessible from the generated builder's package, not just present)T.builder()) as a second lookup location next to the same-package candidateT;java.lang.Objectmethods excluded to avoid theStringBuilder/Stringfalse positive)Reference: MapStruct's
DefaultBuilderProviderMapStruct detects builders structurally: a public static parameterless method on the target type (e.g.
Person.builder()) returning a builderB, whereBhas a parameterless build method (namedbuild, configurable via@Builder(buildMethod=...)). Instantiation always goes through the static factory, nevernew. Ambiguous factories produce a warning and no builder;java.*/javax.*are excluded; aBuilderProviderSPI allows custom detection.Difference to keep in mind: we anchor on a naming convention (
<T><suffix>inT's package), MapStruct anchors on a factory method onTitself.Out of scope / open questions
builder()/create()marker method on the referenced type as a builder anchor (MapStruct-style).builderUsagePackagessemantics stay as they are.(Spun off from the discussion on #296 — not part of the
@SimpleBuilderForfeature.)