GEP-30
|
Abstract
java.beans is a desktop-era API: since JDK 9 it lives in the java.desktop module alongside
AWT and Swing, and platforms that are not desktops — Android, jlink-minimized server images,
GraalVM native-image closed worlds — either omit it entirely or pay for it disproportionately.
Groovy core today reaches for java.beans in its most load-bearing places: the default
metaclass implementation, the bytecode Verifier, and the record-type transform. In practice,
loading Groovy loads java.desktop and compiling a record requires java.desktop to be present.
The dependency is small and fully mapped: exactly fifteen java.beans types (verified at
the member level against the grooid jar during the GROOVY-12199 packaging work) plus two direct
desktop stragglers (java.awt.Image in the record transform’s generated BeanInfo companion,
and a Swing-based lexer debugging tool inside the core jar). Because the surface is this small,
the modern answer is not to shade a substitute implementation underneath it (the Android
approach since 2014, via relocated openbeans) but to retire the dependency from the core
spine: a minimal internal introspection abstraction, java.beans-backed where the platform has
it, reflection-only where it does not, and lazily-isolated leaves for the few features whose
public API is inherently beans-shaped.
The one-line pitch: Groovy core should run — and compile records — on any JVM subset that can
run java.base.
Motivation
Where Groovy stands today: the verified inventory
The complete java.beans/java.desktop surface of the core jar (Groovy 6 master, 2026-07-29),
grouped by how it is reached:
| Usage site | Types used | When it executes | Severity without java.desktop |
|---|---|---|---|
|
|
Runtime — metaclass initialization and JavaBeans event-support detection |
Spine. The default MOP cannot initialize |
|
|
Runtime — class-info cache eviction |
Spine. Loaded by essentially every Groovy program |
|
|
Compile time — every class compilation |
Spine. The compiler cannot load; on Android this has silently meant "no on-device
compilation" since 2020, because the relocated |
|
|
Class-file metadata only |
Benign — reflection silently drops unresolvable annotation types, but the marker is then invisible to introspectors |
|
|
Compile time — any record declaration |
Spine-adjacent. Compiling a record hard-requires java.desktop |
|
|
Compile time — only when |
Leaf — but the failure would be a raw |
|
|
Compile time when |
Leaf — note these live in core, not groovy-swing |
|
|
Runtime, only if instantiated |
Leaf with API exposure — signatures cannot change compatibly |
|
|
Only when explicitly launched |
Leaf — but it ships inside the core jar |
Everything else that touches java.beans (groovy-swing bindings, groovy-console, groovydoc
tooling) lives in modules whose whole purpose is desktop UI; they are out of scope and correct
as-is.
Two facts make this inventory actionable. First, it is complete and member-verified: during
the GROOVY-12199 shadow-migration review, every class and every method/field reference from
Groovy code into the beans namespace was resolved against openbeans 1.0.2 — fourteen of the
fifteen types resolve fully; only java.beans.Transient is missing. Second, it is small: the
spine portion is five files, and the beans API they actually consume (property/event
descriptors, decapitalize, cache flush, a marker annotation) is a fraction of java.beans.
Three environments pay for this today
Android. Android has never shipped java.beans (beyond a vestigial PropertyChange* subset).
The grooid jar therefore bundles openbeans relocated to groovyjarjaropenbeans. and rewrites
every java.beans reference — machinery that GROOVY-12199 just migrated from ant-jarjar to
shadow, where a string-relocation subtlety (Java9.JAVA8_PACKAGES()) already required a
targeted skipStringConstants carve-out. Shading *works, but its failure mode is silent: the
Transient gap sat unnoticed for six years because nothing verifies the relocated world is
closed. Retiring the spine dependency removes the need for the relocation (and openbeans) for
every app that does not itself use the beans-shaped leaves.
Minimized server runtimes. jlink-built images routinely omit java.desktop — it is one of
the heaviest platform modules, and headless services have no use for it. Groovy on such an image
fails at metaclass initialization. There is no workaround short of adding the module back;
Groovy is the reason a headless service ships AWT.
GraalVM native-image. Introspector-based introspection needs reflection metadata for every
introspected type, and reachability of java.beans pulls desktop classes into the closed-world
analysis. A reflection-only introspector on a documented internal seam is easier to supply
metadata for, easier to initialize at build time, and shrinks images. (This GEP does not promise
full native-image support — dynamic Groovy has larger obstacles there — but it removes one
structural one, and @CompileStatic-heavy applications benefit immediately.)
Why now
The java.beans usage predates the module system; when it was written, "the JDK" was one
indivisible thing. Post-JDK-9, java.desktop is severable and increasingly severed. Meanwhile
the historical reasons Groovy leaned on Introspector have weakened: the MOP’s own
property/method model (MetaClass, MetaProperty, CachedClass) has long been the primary
mechanism, with java.beans supplying supplementary event-set detection and descriptor
compatibility. The remaining coupling is habit plus a thin slice of genuine function — exactly
the shape an internal abstraction handles well.
Proposal
A three-tier classification
Every current usage falls into one of three tiers, with different obligations:
| Tier | Definition | Obligation |
|---|---|---|
Spine |
Code loaded by essentially every Groovy program or compilation: |
Must neither load nor link against |
Leaf |
Opt-in features whose implementation ( |
API unchanged. Classes remain lazily loaded; when used on a platform without beans support they
fail fast with a clear, documented error naming the feature and the missing module — never a
raw |
Desktop tooling |
|
Moved out of the core jar (to groovy-console or a dev-tools artifact), or failing that, loaded only reflectively |
The introspection seam
Introduce a minimal internal abstraction — working name
org.apache.groovy.beans.IntrospectionService (final naming during implementation; hanging it
off VMPlugin, which already abstracts platform variance, is the natural alternative) — with
exactly the operations the spine consumes:
-
property descriptors for a class (name, type, read/write methods), honouring the transient marker;
-
event-set detection (the
add*Listener/remove*Listenerpair recognitionMetaClassImplperforms today); -
decapitalizeand cache-flush hooks (InvokerHelper.flushFromCaches); -
transient-marker recognition (see below).
Two implementations:
-
java.beans-backed (default whenjava.desktopis present): delegates toIntrospectorand friends, preserving today’s behavior bit-for-bit, including interop with user-supplied customBeanInfoclasses — this is the compatibility anchor. -
Reflection-only (selected automatically when
java.beansis absent; forceable via a system property for testing): implements the same contract fromjava.lang.reflectalone. It does not honour customBeanInfoclasses — that is inherent, documented, and irrelevant on the platforms where it activates (they cannot express customBeanInfotoday either).
Selection is a one-time reflective probe at startup (Class.forName("java.beans.Introspector")
guarded, never a static link), cached, with the chosen implementation exposed for diagnostics.
Spine-specific fixes
-
Verifier: replace theClassHelper.make(Transient.class)class literal with a name-basedClassHelper.make("java.beans.Transient")(no class loading), or — preferable if review agrees — introducegroovy.transform.Transientas the marker Groovy itself recognises, withjava.beans.Transientstill emitted additionally when the platform has it. The choice affects only what annotation generatedgetMetaClass()methods carry; both variants remove the class-load-time link. Either way the emitted marker is preserved on beans-capable platforms because real consumers still honour it:Introspector/XMLEncoder(the original GROOVY-8284 driver) and Jackson’s getter-based property discovery (which respects@java.beans.Transientsince 2.6). Field-based serializers are unaffected throughout — the generatedmetaClassfield already carriesACC_TRANSIENT. -
RecordTypeASTTransformation: move theSimpleBeanInfo/BeanDescriptor/Imageclass literals out of static finals into the code path that actually generates theBeanInfocompanion, and generate that companion only when the introspection seam reports beans support (or when explicitly requested via@RecordOptions). Compiling a record then requires onlyjava.base; the companion remains generated by default on desktop JDKs. -
MetaClassImpl/ClosureMetaClass: route descriptor and event-set queries through the seam. This is the delicate migration — the existingjava.beanspath must remain the default implementation precisely so that behavior (including descriptor caching and customBeanInfohonouring) is unchanged where it matters. Golden tests comparing full property/event maps before and after, across a corpus of representative classes, gate this phase. -
GroovyObjectSupport: annotation usage may stay (annotations are metadata; they do not force loading), aligned with whichever marker decisionVerifierlands on. -
BuilderASTTransformation: replace compile-timeIntrospectoruse with the seam or with Groovy’s own AST-level property enumeration (which the other builder strategies already use).
Verification is part of the proposal
The GROOVY-12199 lesson is that this class of dependency regresses silently. Two standing guards:
-
a CI job that runs the core spine test subset on a
jlinkruntime built withoutjava.desktop— compiling classes and records, initializing metaclasses, exercising the MOP; -
an ArchUnit-style structural test over the core jar asserting that no class outside an allowlist (the leaf tier) references
java.beans/java.awt/javax.swingtypes in its constant pool — the same constant-pool analysis used to produce this GEP’s inventory, made permanent.
Design principles
-
Behavior-identical where java.desktop exists. The default path stays
java.beans-backed; desktop and server users on full JDKs see no observable change, including customBeanInfointerop. -
Isolate, don’t reimplement the world. The seam’s contract is the handful of operations Groovy actually uses — not a general beans replacement, not a public API. Internal (
@Internal,org.apache.groovy.*namespace), so it can evolve. -
Leaves keep their API; failures name the feature. No signature of
ObservableListor@Bindable-generated code changes. The compatibility cost of purging beans types from public APIs is out of all proportion to the benefit, and lazy class loading already gives the isolation — what is missing is only the diagnostic quality when the platform cannot support the feature. -
No new modules, no new artifacts, no packaging redesign. Android variant publishing, grooid retirement, and native-image metadata shipping are follow-on opportunities this GEP enables but does not undertake.
Groovy 7.0 deliverables
| Phase | What ships | Notes |
|---|---|---|
1 |
Spine link-severing: |
Small, independent, low-risk changes; the CI job locks them in. The |
2 |
The introspection seam: interface, |
The bulk of the work and review attention; behavior-identical default is the acceptance criterion |
3 |
Leaf hygiene: fail-fast diagnostics for |
Mechanical once tiers are agreed |
4 (stretch) |
Grooid packaging simplification: relocation and openbeans bundling become leaf-only (or drop entirely with leaves documented as unsupported on Android); GraalVM reachability-metadata for the reflection-only introspector |
Follow-on value; can trail into 7.x without blocking the GEP |
Excluded and deferred features
| Feature | Status | Rationale |
|---|---|---|
Removing |
Not planned |
Binary and source compatibility cost vastly exceeds benefit; lazy loading already isolates them |
A public bean-introspection API |
Not planned |
The seam is internal; a public API would freeze exactly the surface this GEP exists to keep flexible |
Full GraalVM native-image support |
Out of scope |
Dynamic Groovy has independent obstacles (runtime class generation, reflective dispatch); this GEP removes one structural blocker only |
Android variant publishing via Gradle Module Metadata (replacing the grooid classifier), indy-backend-on-ART work |
Deferred |
Real opportunities revealed by the same analysis, but packaging/backend concerns separate from dependency isolation |
Extending openbeans (e.g. adding |
Superseded |
Worth doing opportunistically for Groovy 5/6 grooid users, but this GEP removes the reason to need it |
|
Deferred |
A follow-up audit using the same constant-pool tooling may propose more; this GEP deliberately stays on the beans/desktop seam where the evidence is complete |
Compatibility and impact
Backwards compatibility
No public API signatures change. On a full JDK, the default implementation preserves current
behavior including custom BeanInfo interop; the golden-equivalence corpus is the gate. The
only intentionally observable changes are on platforms where the current behavior is failure:
clear diagnostics instead of NoClassDefFoundError, and working compilation where it was
impossible.
Binary compatibility
Spine classes keep their public members (per the project’s compatibility policy for
classgen/ast packages). The BeanInfo companion class for records remains generated by
default on desktop JDKs; a record compiled on a beans-less platform simply lacks the companion —
code reflectively looking it up must already handle its absence (pre-Groovy-4-record classes
never had one).
Tooling and documentation
joint-compilation stub generation, Groovydoc, and IDEs are unaffected (no syntax or AST-shape
changes). Documentation gains one page: which Groovy features require java.desktop, what the
failure looks like without it, and how the introspection fallback differs (custom BeanInfo
only). The Android/grooid README notes the phase-4 simplification when it lands.
Alternatives considered
-
Keep shading (status quo). Proven for Android, but it addresses only Android, adds a maintenance surface of its own (GROOVY-12199’s
skipStringConstantscarve-out; the silent six-yearTransientgap), and does nothing for jlink or native-image. Rejected as the primary mechanism; retained during transition and for leaves. -
Depend on openbeans everywhere. Inverts the problem: desktop users lose custom
BeanInfointerop and gain a third-party dependency in exchange for uniformity nobody asked for. Rejected. -
Ask the JDK/desugaring ecosystem to supply java.beans.
desugar_jdk_libsdeliberately excludes beans as non-Android API surface, and no JDK effort exists to split beans out ofjava.desktop(JEP 200 fixed the module graph in 2014). Waiting is not a plan. Rejected. -
Purge beans from public APIs too (maximal version). Cleanest end state, Groovy-8-sized compatibility break, and the leaves involved are low-traffic. The tiered design captures ~all of the value at ~none of the cost. Rejected for 7.0; re-evaluable later with usage data.
References
-
PR #2740 / GROOVY-12199 — jarjar→shadow migration whose review produced the member-level verified inventory this GEP is built on (15 types; openbeans 1.0.2 covers 14;
java.beans.Transientmissing) -
GROOVY-8284 — origin of the
java.beans.Transientclass literal inVerifier(the current on-device-compilation blocker on Android) -
JEP 200: The Modular JDK —
java.beansplaced injava.desktop -
java.desktopmodule summary — the module Groovy core currently cannot run without -
Android core library desugaring coverage —
java.beansabsent fromdesugar_jdk_libs, confirming no toolchain-side fix is coming -
openbeans — the Apache-Harmony-derived shim bundled into the grooid jar today (
me.champeau.openbeans:openbeans:1.0.2) -
GraalVM reachability metadata — the mechanism the reflection-only introspector simplifies
-
groovy.lang.MetaClassImpl,org.codehaus.groovy.classgen.Verifier,org.codehaus.groovy.transform.RecordTypeASTTransformation,groovy.beans.BindableASTTransformation,groovy.util.ObservableList— the inventory’s primary sites in-tree