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-0023: Log through SLF4J, bind nothing; and fold the example back in

  • Status: Accepted (supersedes the build arrangement in ADR-0021)
  • Date: 2026-08-15
  • Relates to: docs/ARCHITECTURE.md §15, ADR-0007, ADR-0021

Context

Two things, related only in that they both concern what a consumer of Goldberry sees.

The example was invisible to Gradle. ADR-0021 made example/ a separate build so it would consume Goldberry through published coordinates. The reasoning holds and the cost was higher than estimated: ./gradlew projects did not list it, IDEs did not import it, and ./gradlew run failed with “task ‘run’ not found”. The workaround — a root task shelling out to the example’s own wrapper — worked and did not fix the underlying problem, which is that a directory in the repository was not part of the project as far as any tool was concerned. That was reported twice.

Nothing logged. A UI toolkit that fails to find a display, loads the wrong native library, or is handed a surface format it cannot blit into should be able to say so. Until now the only diagnostic was an exception message, and only for the failures fatal enough to throw.

The constraint on logging is specific: an application that configures no logging must see nothing at all. SLF4J 2 does not cooperate by default. With no provider on the path it prints

SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation

to stderr on first use — noise on the console of every application that made a deliberate choice, and noise that reads like a complaint from Goldberry rather than from a library beneath it.

Decision

Goldberry logs through SLF4J and binds no implementation. slf4j-api is an api dependency of every module; no provider is a dependency of any of them. Choosing one is the application’s call, and a library that ships a backend takes that choice away — and starts a fight with whatever the application already uses.

The no-provider warning is silenced at source. Logs sets slf4j.internal.verbosity=ERROR in a static initializer, and every logger in the toolkit comes from Logs.of(...) — which is what guarantees the ordering, since that initializer runs before SLF4J’s Reporter reads the property. It is set only if the application has not, so -Dslf4j.internal.verbosity=INFO still shows everything SLF4J has to say.

Levels are conventional and worth stating so they stay that way: info for things a user would want in a bug report (which library loaded from where, which SDL, a window opening or closing, a scale change), debug for the toolkit’s own lifecycle (runtime start, buffer allocation, window creation flags), trace for per-frame detail, error only where something was swallowed.

example is a subproject again. include ':example' in settings.gradle, implementation project(':core'), no second wrapper, no dependency substitution. Gradle lists it, IDEs import it, ./gradlew run finds its run task the way ./gradlew test finds every module’s tests.

What ADR-0021 wanted is kept where it can be kept without a separate build: the example is still a module, still runs on the module path, and still declares --enable-native-access=io.github.digitalsmile.goldberry.natives. Those are what caught real problems — an unexported package and a wrong module name both fail here and nowhere else.

Alternatives considered

Ship slf4j-nop as a runtime dependency. It removes the warning without touching a system property, and it is what several libraries do. It also is a provider, so an application adding Logback gets SLF4J’s multiple-bindings warning instead — trading a warning nobody asked for against a worse one that appears only for users who did the right thing.

java.util.logging, or System.Logger. In the JDK, no dependency, and System.Logger is the modern answer for exactly this. Rejected because every Java application that logs at all already has an SLF4J bridge configured, and System.Logger output lands in whatever java.util.logging is doing by default — which is usually not where the application’s other logs go.

Leave the warning. It is two lines, once, and it is arguably SLF4J’s business rather than Goldberry’s. It also appears on the console of every application that deliberately configured nothing, and the user’s requirement here was explicit.

Keep the example as a separate build and document the friction. The coordinate check it provided is real and is now lost: nothing verifies that the published artifact is called goldberry-core rather than core until somebody publishes. That is a genuine regression, recorded below, and the price of a project layout that tools understand.

Consequences

An application sees exactly the logging it asked for and nothing else. One that adds Logback gets Goldberry’s diagnostics for free; one that adds nothing gets silence, including from SLF4J itself.

Goldberry sets a system property it does not own. It is scoped to SLF4J’s internal reporting, applied only when unset, and named in one place with a test asserting the string — but it is a global side effect of loading a class, and that is worth knowing about.

Logs is exported unqualified from :natives. A qualified exports ... to io.github.digitalsmile.goldberry.core would say what is meant and does not compile: :core is not on :natives’ compile module path (it depends the other way), so javac warns the target module is unknown and -Werror makes it fatal. The docstring carries the intent the module system cannot.

The published-coordinates check is gone. implementation project(':core') does not care what the artifact is called, so a mismatch between §15’s goldberry-core and the project name core will surface at publishing time rather than now. Publishing is not configured yet; when it is, it should assert the coordinates.

The example’s tests and the toolkit’s now run in one command, and a broken showcase breaks ./gradlew build. That is the intended coupling — a showcase that does not compile is a broken API — and it does mean the example can no longer be left temporarily broken while the toolkit moves.