Skip to content

Capability-based builder contract for referenced builders #301

Description

@igel-devin-ai

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

  1. 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.

  2. No factory-method support. Generated builders expose a static create(), but the contract only recognizes constructors, and generated code always instantiates via new.

  3. All-or-nothing granularity. One missing member disables every helper, even those the builder could satisfy.

Sub-tasks

  1. #308 — Extend the minimal contract to require the empty constructor (breaking: fixes the latent compile break; builders lacking B() degrade to plain setters)
  2. #309 — Support static factory methods for referenced builder creation (create(), and create(T)/of(T) preferred over the copy ctor)
  3. #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)
  4. #313 — Visibility-aware contract checks (verify ctor(T)/B()/build()/factories are accessible from the generated builder's package, not just present)
  5. #317 — Find builders anchored inside the referenced type (MapStruct-style T.builder()) as a second lookup location next to the same-package candidate
  6. #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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions