Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Model weaving

A Goldberry model is plain Java. You write fields; the toolkit makes assignments to them observable.

You do not have to run the weaver. A plain jar binds a model reflectively and needs no build step at all; weaving is what a GraalVM native image is built from (ADR-0155). The short version is below; this page starts with what the weaver does, because that is the form everything else is described against.

@Model
public final class Settings {
    @Bind("app.gain")                          private int gain = 40;
    @Bind(value = "app.theme", restyle = true) private String theme = "dark";

    @Action("app.louder") private void louder() { gain++; }
    @Action("app.pick")   private void pick(String name) { theme = name; }
}
slider bind="app.gain" min=0 max=100 change="app.pick"
button "Louder" press="app.louder"
public final class Hello implements Application {

    private final Settings settings = new Settings();

    @Override public List<Object> models() { return List.of(settings); }

    @Override public Widget root() {
        return Widgets.inflater(icons, settings).inflate(document);
    }
}

gain++ moves the slider and asks for a frame. Changing theme restyles first. There is no Property, no set/get, no listener registration, and no repaint() anywhere in the application.

The five annotations

OnMeans
@Modela classit holds values: its @Bind fields are rewired
@Actionsa classit holds only methods, acting on somebody else’s values
@Bind("a.b")a fieldmarkup names this value; restyle = true means a rule depends on it
@Action("a.b")a methodmarkup names this handler; no argument, or one the toolkit can parse from a string
@Markup("button")a widget classthis is the node name a document writes for it

@Markup is the widget-author’s half: the build collects every annotated class in a module into a WidgetCatalog, declares it in the module descriptor, and Widgets.inflater(...) finds every catalog on the path. A module that ships widgets is found by an application that never names it (ADR-0131).

What the weaver actually does

It rewrites the compiled class, between compileJava and anything that reads its output, using the JDK 25 class-file API (JEP 484). For the class above, Settings.class comes out with:

  1. implements BoundModel, and a lazily created FieldListeners;
  2. a synthesised goldberry$set$gain(int) — compare, store, notify;
  3. every putfield gain rewritten into a call to it — in that class and in any other class in the same build that assigns to it, which is what lets an @Actions class beside the model change its values (ADR-0134);
  4. bindings() and actions(), built from the annotations, the second as one invokedynamic per action bootstrapped by LambdaMetafactory.

For a module with @Markup widgets it also writes a GoldberryCatalog, patches provides WidgetCatalog with … into module-info.class, and drops a META-INF/services entry — both, because a jar has to work on the module path and on the class path, and the module system ignores META-INF/services for a named module.

Step 3 is a one-for-one instruction swap: putfield pops objectref, value, and so does an instance call taking one argument.

Why it has to be a build step to see the write

A field write cannot be intercepted any other way. getfield and putfield are not virtual, so no subclass and no proxy can see one — the class that declares the field is the only place the write can be observed. Doing that to the compiled class in the build is the one option that needs no -javaagent, no opens, and nothing generated at runtime, which is also what lets the result go into a GraalVM native image (ADR-0127).

You probably do not need to run any of this

Model weaving is for a native image. An ordinary jar binds the same annotations at run time and needs no build step at all (ADR-0155):

WovenBound at run time
Whoa GraalVM native imageeverything else — gradle run, mvn exec:java, an IDE, java -jar
Build stepthe weaver, over the compiled classesnone
A change notifiesinside the assignment that made itat the next sweep
Needsnothingthe model’s package open to the toolkit, in a named module
Models.isWoventruefalse

Everything else is identical. The same Models.bindings, the same Models.actions, the same paths, the same values, the same refusals — and RuntimeAgreesWithWovenTest drives one model class both ways through the same actions to keep it that way.

The sweep, and the one line it sometimes costs

Reading is exact either way: an Observable over a woven field and one over a VarHandle both see the field itself. What the reflective form cannot do is see the write, so it compares each field against what it last held and notifies what moved. That sweep runs

  • after every action a document dispatches — across every model, because an @Actions record writes to the model beside it;
  • at the top of every frame, over the models Application.models() named;
  • wherever you call Models.refresh(model).

Which leaves one case: a field written from neither an action nor anything that leads to a frame.

job.onFinished(text -> {
    model.status = text;
    Models.refresh(model);   // a no-op, returning false, once this is woven
});

A model in a named module opens its package

The reflective form needs private access, and JPMS is what grants it:

opens com.example.app to io.github.digitalsmile.goldberry.core;

A classpath application needs nothing — the unnamed module is open. The refusal names the package and that exact line if you forget. The woven form needs neither, which is one more reason an image is the cheaper artifact.

Turning weaving on

./gradlew build -Pgoldberry.nativeImage=true    # the whole build, woven
./gradlew :example:weaveModels                  # one module, to look at

The catalog half of the weaver is not optional and is not affected by any of this — see below.

What it never touches

Classes with neither marker are not rewritten — not even re-serialised, unless they assign to some model’s @Bind field. Reads are left alone: getfield is already the fastest thing that could happen.

And within a class it rewrites, it rebuilds only the methods it has to. A method is rebuilt if and only if it contains a write the weaver replaces; everything else is copied out of the original class file byte for byte, stack-map frames included. That is not tidiness. Rebuilding a method means regenerating its frames, and a frame where two of the author’s types meet — `Base x = b ? new A()
new B()` — is only computable by resolving both and asking what they have in common. A weaver that rebuilt every method would have to resolve every class the module was compiled against, to weave a method with nothing in it for the weaver.

A rewritten method keeps what javac wrote beside its code. Signature, both annotation attributes, MethodParameters and Exceptions survive — on the model and on any class that writes to one. This is what lets the reflective binder (ADR-0155) keep working on a class that happens to have been woven, and it is asserted against the woven bytes by MethodAttributesTest rather than against whatever is on the test classpath.

Weaving is idempotent in the sense that matters: a pass over a tree that changed nothing rewrites no model and writes no class file. It is not a no-op — the catalog half rewrites its GoldberryCatalog and its service entry every run, because that half is a generator rather than a rewriter and comparing its output to decide would cost more than writing it.

And it is safe on a half-recompiled tree, which is what an incremental build hands it. A woven class is still recognised as a model on a later pass; a write the weaver already turned into a setter call still counts as a write; and a model gains package-private setters when a writer outside its nest appears. Each of those three was a real defect: without the first, a recompiled sibling’s writes were left unrewritten and the build was green with dead bindings; without the second, recompiling only the model re-wove it with private setters its sibling could no longer reach, for an IllegalAccessError at the first click.

The two halves

The weaver does two unrelated jobs to the same tree, and a build asks for them separately:

FlagDoesNeeded by
--modelsrewires @Bind fields, writes the @Action call sitesa native image only
--catalogwrites the module’s WidgetCatalog from its @Markup widgets, patches provides into module-info.class, writes META-INF/servicesevery build

Neither flag means both, which is what every pre-ADR-0155 integration already wrote.

The catalog half has no runtime equivalent and never will: finding annotated classes while the program runs means scanning the path, which is the thing a provides exists to avoid (ADR-0131). So a module that ships widgets runs the weaver whatever it is building; a module that only keeps a model runs it only for an image.

Adding it to a project

The weaver is one jar with no dependencies beyond the JDK, and a main that takes directories of compiled classes. Every integration below is a way of calling that.

Gradle

Apply the plugin to any module that ships @Markup widgets, or that you want to be able to weave for an image:

plugins {
    id 'goldberry.weave'
}

It registers four JavaExecs — weaveCatalog and weaveModels, each for the main and test source sets. The catalog pair hangs off classes and testClasses so jar, run and every Test task reach through it; the model pair joins them only under -Pgoldberry.nativeImage=true.

In a build that consumes Goldberry from a repository rather than from this source tree, the equivalent is:

configurations { goldberryWeaver }
dependencies { goldberryWeaver "io.github.digitalsmile:goldberry-weaver:$goldberryVersion" }

def weave = tasks.register('weaveModels', JavaExec) {
    dependsOn tasks.compileJava
    def classes = tasks.compileJava.flatMap { it.destinationDirectory }
    // The weaver FIRST, then the classes it is weaving and everything they were
    // compiled against: regenerating a stack-map frame means resolving the
    // author's own types. The weaver's own jar alone is not enough.
    classpath = files(configurations.goldberryWeaver, classes, sourceSets.main.compileClasspath)
    mainClass = 'io.github.digitalsmile.goldberry.weaver.WeaverMain'
    argumentProviders.add({ [classes.get().asFile.absolutePath] } as CommandLineArgumentProvider)
    inputs.dir(classes)
    // A stamp, NOT `outputs.dir(classes)`. Declaring javac's own directory as
    // this task's output tells Gradle that two tasks write to one place, and
    // Gradle answers an overlapping output by throwing away the compiler's
    // incremental state -- every build then fully recompiles the module and
    // everything downstream of it (ADR-0398).
    outputs.file(layout.buildDirectory.file('tmp/weaveModels/stamp'))
    outputs.upToDateWhen { false }      // in place, and cheap on a settled tree
    doLast { layout.buildDirectory.file('tmp/weaveModels/stamp').get().asFile.text = 'woven' }
}
tasks.named('classes') { dependsOn weave }

Maven

There is no first-class Maven plugin. exec-maven-plugin runs the weaver as it stands, bound to process-classes, which is the phase that exists for exactly this. Add <argument>--catalog</argument> (or --models) before the directory to run one half; with neither it runs both:

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <version>3.5.0</version>
  <executions>
    <execution>
      <id>weave-models</id>
      <phase>process-classes</phase>
      <goals><goal>java</goal></goals>
      <configuration>
        <mainClass>io.github.digitalsmile.goldberry.weaver.WeaverMain</mainClass>
        <arguments>
          <argument>${project.build.outputDirectory}</argument>
        </arguments>
        <classpathScope>compile</classpathScope>
      </configuration>
    </execution>
    <execution>
      <id>weave-test-models</id>
      <phase>process-test-classes</phase>
      <goals><goal>java</goal></goals>
      <configuration>
        <mainClass>io.github.digitalsmile.goldberry.weaver.WeaverMain</mainClass>
        <arguments>
          <argument>${project.build.testOutputDirectory}</argument>
        </arguments>
      </configuration>
    </execution>
  </executions>
  <dependencies>
    <dependency>
      <groupId>io.github.digitalsmile</groupId>
      <artifactId>goldberry-weaver</artifactId>
      <version>${goldberry.version}</version>
    </dependency>
  </dependencies>
</plugin>

A real Mojo would be nicer — one <plugin> block, incremental, no <mainClass> to get wrong — and is a small amount of work whose only awkward part is that this repository builds with Gradle and would have to write META-INF/maven/plugin.xml itself. It is not built, and this is honest about that rather than implying otherwise. Nothing above is a workaround for a missing feature: process-classes is where class post-processing belongs, and the weaver is a program that post-processes classes.

Any other build, or none

java -cp goldberry-weaver.jar:target/classes:<compile classpath> \
     io.github.digitalsmile.goldberry.weaver.WeaverMain target/classes

The classpath is not optional, which the shorter java -jar line this page used to show quietly implied it was. The weaver regenerates stack-map frames for the methods it rewrites, and a frame where two of your types meet at a control-flow join is only computable by loading both — so the weaver has to be able to see the classes it is weaving and everything they were compiled against. Without them, such a method fails with Could not resolve class, and there is nothing the author of that method can do about it.

It prints one line per class it wove, exits 0, and exits 1 with a message naming the member when it refuses a model.

If you forget

Nothing. A model that is annotated and not woven is bound at run time, which is the ordinary case — that is ADR-0155. All five annotations are RUNTIME-retained so that the reflective binder can read them.

The one thing you can forget is the opens line, in a named module, and the refusal quotes it back at you.

What it refuses, and why

Each of these is a failure naming the member — at build time when the weaver runs, and on the first Models call when it did not — because a binding that fails silently is a control that renders perfectly and never moves. Both forms refuse the same list, which is the point: a model that builds as an image builds as a jar.

RefusedBecause
static @Bind fieldA binding belongs to an instance; a static one is shared by every window in the process
final @Bind field (unless a Property)A value that cannot change is not something to subscribe to
an arrayOnly the assignment is observed, so values[0] = x would notify nobody. Hold a List and assign a new one
a path that is not a.b.cThe grammar Bindings enforces at runtime, checked first (ADR-0062)
two members claiming one nameTwo features quietly sharing one name presents as a value changing by itself
an @Action taking two argumentsA control reports either that something happened or what it should become, never both
an @Action parameter that is not String, double, int, boolean or a boxA valued action crosses as the string the document wrote down
a static @ActionAn action changes a model, and a static one has no model to change
an abstract or empty @ModelNothing to weave into, or nothing to publish
@Actions with a @Bind fieldA class that holds values is a @Model
@Actions with no @Action methodIt publishes nothing
both @Model and @Actions on one classA class holds values or it does not
a @Model extending a @ModelEach would get its own listener store and the subclass’s would shadow the superclass’s, so inherited fields would notify nobody
@Bind(restyle = true) on a PropertyNo writes to it are rewired, so there is nowhere to put the call
@Markup without public static Widget inflate(KdlNode, List<Widget>, Wiring)Nothing for the node name to build. Java cannot say this in an annotation, so the build says it
two classes claiming one @Markup nameA document writing it would get whichever the build saw last

Known limits

Notification is deferred when nothing wove the class. The sweep points above cover every path a document takes; a write outside all of them waits for Models.refresh. Woven, there is no deferral at all.

The order a registry lists its names in differs between the two forms. The weaver publishes in class-file order; reflection cannot recover that — getDeclaredFields and getDeclaredMethods promise no order — so the reflective form sorts by member name. It shows up only in the Bound: ... list a strict registry prints when it refuses a name.

Reading through a binding boxes a primitive. Models.observable(model, "app.gain").get() on an int field allocates, where the old Property<Integer> handed back a box it already held. Writes got faster and reads got slower; the numbers are in ADR-0125.