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

ADR-0094: Name the overload, not the allocation

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/ARCHITECTURE.md §11, extends ADR-0093

Context

Two complaints about the same file, and they turned out to have different answers.

The showcase was one 770-line class doing four unrelated jobs: the application lifecycle, the view model, the widget tree, and the layout of three panes. Every screen was a private method on one state object, and the state object held the model.

And building a tree read badly — new Row(new Text(…), new Spacer()), nested four deep. The suggestion was Column.of(…) instead of new Column(…) throughout.

The second is worth taking apart, because the obvious fix is the wrong one.

Decision

new stays the way to build a widget. Static factories are added only where a constructor cannot carry the meaning — which was already this catalog’s rule (Progress.sweeping(), Badge.of(fallback, source), Scale.decibels()) and is now applied consistently.

Three arguments decided it:

A public record’s canonical constructor cannot be hidden. The JLS requires it to be at least as accessible as the record. So new Column(…) is public forever and of() could only ever be additive — two permanent public doors, with no way to steer anyone to one and no compiler help keeping them in step. Every other argument is downstream of that.

The noise is depth, not the keyword. Row.of(Text.of("a")) saves four characters against new Row(new Text("a")). What actually made the showcase hard to read was a 100-line build(), and decomposition fixed it — the file is 175 lines now and the deepest nesting in it is two.

Performance is not a reason either way, and was measured rather than assumed. 20 million allocations, best-of-15, on the JDK 25 toolchain:

new  45.23 ms   of  45.09 ms
new  45.44 ms   of  45.86 ms
new  45.09 ms   of  45.34 ms

Identical within noise, ~2.3 ns each. -XX:+PrintInlining says why: Box::of (10 bytes) inline (hot) — the factory is inlined and the machine code is the same. Memory is identical too: the same object, the same allocation, nothing extra. The only costs are startup-side — one extra bytecode frame before the JIT warms up, and ~45 more methods of metadata across the catalog — and both are negligible. A factory only changes memory behaviour if it caches, which would be a behaviour change and is unsafe for keyed widgets.

(The first attempt at that benchmark reported 87 ms against 46 ms and was entirely wrong: it had a String.equals branch inside the loop. Recorded because it is exactly how a microbenchmark lies, and because the wrong number pointed the “right” way.)

What does get named is the ambiguous overload. Slider had two five-argument constructors differing only in whether the fourth parameter is a double or an Observable; Knob and Toggle had the same shape. A reader cannot tell those apart at a call site and the compiler will pick one for a null. Those four are now Slider.of, Knob.of, Toggle.of and Progress.of — of because the catalog already used it for exactly this meaning, the bound variant.

The showcase is five classes and two documents. Showcase is the [Application] — lifecycle, stylesheets, registries, accelerators. ShowcaseModel is the view model: properties, the methods that change them, and the two registries a document resolves names against. ui.Screen is the layout, ui.Panes loads the documents, ui.Content is the one pane that must be Java.

titlebar.kdl and sidebar.kdl carry everything declarative, which is the first time §9’s markup path runs in a window with all three registries live: bind=, change=, press= and icon= all resolve against what ShowcaseModel and Showcase register, and all three registries are strict, so a typo fails at inflation with a line and column.

Alternatives considered

of() on every widget. The user’s original suggestion, and the honest reason to want it: it reads lighter. Rejected on the canonical-constructor constraint above — it cannot replace new, only join it.

of() on the containers only — Row, Column, Panel, the ones that actually nest. Half the churn for most of the visual gain, and it leaves the catalog inconsistent about which widgets have it, which is the worst of the three outcomes: a reader has to remember rather than know.

Keep the panes as private methods and just shorten them. The smallest change. Rejected because the four jobs in that file have four different lifetimes — the model outlives the screen, the screen outlives a pane, and the documents outlive the process — and a private method cannot express that.

Put the whole window in KDL. Tempting, and it fails on Content: its Undo and Reset buttons are disabled when the click count is zero, and §8’s markup has no expressions. A document that could evaluate clicks == 0 would be code in a data file with no stack trace when it went wrong (ADR-0062). The boundary is instructive and it is where the split was drawn.

Keep the model on the element. ADR-0052’s State is for what the UI remembers — a scroll offset, a caret — and it is right that those die with their widget. The click count and the gain are what the application is about; a second screen showing the same gain should read the model, not a copy.

Consequences

The showcase is a shape an application can copy. 175 lines of application, 209 of model, 188 of UI across three classes, 100 of markup. It was 770 lines in one file.

Markup has window coverage for the first time. sidebar.kdl builds every control the catalog ships, and ShowcaseDocumentsTest asserts the shape rather than trusting that a window opened — an empty document inflates to an empty column and paints a blank panel, and the three-frame headless run would pass. The test also asserts the bindings reach the model’s own properties, which a shape assertion misses entirely: a bind= resolving to nothing still renders a control, and the control renders perfectly and never moves.

Four constructors became factories, and the tests had to be repointed. Which is the argument for the change: the compiler could not tell those overloads apart either, and a regex that assumed new Toggle(label, x, y) meant the bound one mis-rewrote seven boolean call sites before the compiler caught it.

The catalog now has four ways to build a widget — constructor, markup, attribute chain, and named factory. That is one more than ADR-0093 left, and the ceiling: the rule is that a factory exists only where a constructor is ambiguous, and ChainingTest plus the parity tests hold the other three in step.

A second opens was needed, for …example.ui. JPMS works at package granularity, so an application that keeps documents beside more than one class opens more than one package. The improved error message from ADR-0093 named the missing line exactly, which is the first time that message earned its keep.

ShowcaseModel carries two Runnables — onChanged and onRestyle — which is a small observer wiring an application has to do by hand. A Property the launcher watched would remove it, and would also mean the toolkit deciding what counts as a restyle. Left as it is, and named here as the seam to revisit if a second application writes the same two lines.