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

172. A package is a role, and the module is the fence

Date: 2026-08-23

Status

Accepted. Completes the package move begun for backend → render and layout → paint, and extends it across :core, :natives and :widgets. Relates to docs/ARCHITECTURE.md §2, §3.1 and §15.

Context

:widgets had already learned this lesson. ADR-0091 split it by group, and ADR-0065 gave each control a package of its own so that a slider-thumb is invisible outside …controls.slider — a boundary the compiler keeps rather than a convention a reviewer has to notice. Thirty-nine packages, none of them large.

:core and :natives had not. Four packages carried a third of the toolkit:

packagetypes
…goldberry.css23
…goldberry.backend21
…natives.yoga22
…natives.blend2d20

A package that size is not a boundary, it is a folder. CssTokenizer and ComputedStyle are at opposite ends of a pipeline and could see each other’s package-private members; BlendCompOp, which is a table of C constants, sat beside BlendContext, which owns a native handle. The names said “CSS” and “Blend2D” — the library the code came from — and said nothing about what any of it does.

The move backend → render had already started answering that, and had already found the cost: the first build of this branch was red, because BoxPainter.paintOne and FrameRing had been package-private and their callers had walked out of the package. That is the real question this ADR settles. Every split turns some package-private call into a compile error, and there are only two honest answers to one: the boundary is real and the member becomes public, or the boundary is imaginary and the split was wrong.

Decision

A package is named for the part its contents play, not for the library or the file they came from. The CSS engine is a compiler, so it is css.parse → css.select → css.cascade → css.value. Input is a dispatcher, so it is what arrives (input.event), the vocabulary an accelerator is written in (input.key), the snapshot it is routed against (input.hit) and the interfaces a widget implements to hear any of it (input.handler). A native library is split where the foreign memory stops: the wrappers that hold a handle stay beside the binding class they are the only callers of, and the enums and values, which touch no foreign memory at all, get packages of their own.

When a split makes a package-private call illegal, the member becomes public and says why. Every promotion in this change carries a doc comment naming this ADR. There are eleven of them across three modules, which is the number worth recording: a split that needed thirty would have been the wrong split.

Encapsulation that a package can no longer carry is carried by the module. docs/ARCHITECTURE.md §3.1 says a raw MemorySegment never leaves :natives, and until now that was mostly enforced by Blend2D being package-private. It is now enforced by the module descriptor and by a test that reads it (ExportedSurfaceTest), which is the arrangement ADR-0171 already reached for …form.parts: public in a package nothing can see.

Where the split would leak internals into the public API, there is no split. Two candidates were tried and reverted, and they are in “Alternatives” below, because a refactor that only records its successes is a refactor nobody can argue with.

The result:

modulepackages beforeafterlargest package
:core153510
:natives71512
:widgets383911

Alternatives considered

Split the root …goldberry package into an API half and a runtime half. This is the split the shape of the code suggests: Goldberry, Application and Host are what an application writes against, and Launcher and GoldberryRuntime are what runs it. It was rejected on a count. Launcher and GoldberryRuntime make 21 calls into Window’s package-private surface — handlePointerMoved, handleResize, handleCloseRequest, backendWindow, frameRing, and thirteen more — plus Popup.handleKey, Popup.dismissedByInput and Overlay.attached. Window and Popup are types an application holds, so every one of those would become public API: a toolkit whose Window offers the application a handlePointerMoved has published its own event loop by accident. Eleven promotions across the rest of this change bought four packages; these twenty-one would buy one, and cost the public surface. The ten types left in the root are one role — the running shell — and are documented as such.

Move WidgetRenderer and FrameTrace into widget.render. Attempted, and reverted the same hour. WidgetRenderer reads and writes Element’s style cache through cachedStyle, cacheStyle, stableStyle, isAnimating and animations, all package-private and all deliberately so — the cache protocol is ADR-0152’s and is not something a second implementation is meant to exist for. The renderer is not a neighbouring role; it is the element tree’s own paint pass. It stays beside Element.

Make the raw binding classes public in blend2d.ffm / yoga.ffm, so that every wrapper could move out. This would have allowed blend2d.font, blend2d.image and blend2d.path as separate packages. It was rejected because it inverts the boundary: Blend2D and Yoga expose about two hundred methods taking and returning MemorySegment, and making them public — even in an unexported package — moves the fence from “one class in one package” to “one line in module-info”. The unexported-package trick is the right tool for …form.parts, which is three small widgets; it is the wrong tool for the entire FFM surface. So MeasureCallback, MeasureProbe, SdlWindowHandle, SdlEventBuffer and SdlEventWatch were each moved out and then moved back the moment they turned out to traffic in MemorySegment. Their packages are smaller than they would have been, and the boundary is where §3.1 says it is.

Leave bind alone, because the weaver writes its package name as a string. ModelWeaver builds ten ClassDesc constants from one BIND_PACKAGE prefix and emits them into somebody else’s class file. Splitting bind meant the prefix became three, and nothing in the compiler would have caught getting that wrong — the weave would succeed and a woven native image would fail to start, much later, with a NoClassDefFoundError naming a package that no longer exists. This was not a reason to leave bind alone; it was a hole. It is now WrittenNamesTest, which reflects over the weaver’s own constants and resolves each one.

Consequences

A package name now tells you what its contents do. css.value holds the things a declaration resolves to; render.event holds the loop; input.hit holds the snapshot. The pipeline in §5 of ARCHITECTURE.md can be read off the package list, which was the point.

Eleven members are public that were not. They are: BoxPainter.paintOne, Frame.end, Frame.over (replacing a package-private constructor), FrameRing and its three recorder methods, Transform.parse, Transform.parseOrigin, Selector.PseudoClass.parse, BlendException’s constructor, SdlException’s constructor, and Edge.isPhysicalSide. Each says why in its own doc comment. Frame.end is the one worth watching: it used to be unreachable and is now merely wrong to call, so the frame enforces its own lifetime — ending twice is a no-op, painting afterwards throws, and FrameTest covers both.

Two new architecture tests, and they were needed. ExportedSurfaceTest reads :natives’ own descriptor and its own class files and fails if any reachable member mentions MemorySegment; it was checked against a deliberate break. WrittenNamesTest resolves every class name the weavers write as text. Both are written to discover their subject rather than list it, so a package added next month is checked next month.

An import diff of about nine hundred lines, and a merge conflict for anything in flight. Unavoidable, and the reason this was done in eight commits that each build and test green rather than one. The moves were made by tools/refactor/move_package.py, which is kept: it does the four edits a package move needs, and the fourth — the file left behind that used a type without an import because it shared a package with it — is the one nobody does by hand.

Two things this does not fix. The root …goldberry package still holds ten types, for the reason above; if Window’s event intake is ever separated from Window itself, the split becomes cheap and should be revisited. And blend2d.enums is named after a Java construct rather than a role, which is the one place this ADR does not follow its own rule — the honest description of its contents is “the enums, which map a C constant to a Java name and touch no foreign memory”, and no shorter name says that.