-
-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture Rules
What falco-archunit enforces, why each rule exists, and — as important — what it cannot see. The
module holds 39 ArchUnit rules over the compiled classes of every other
module. It has no main sources and is never published; how it fits into the build is in Build
Setup, what check runs per module in Testing and Javadoc.
The design document that derives the rules, with the evidence behind each one, lives in the
repository at
docs/superpowers/specs/2026-08-01-archunit-architecture-rules-design.md.
Falco claims three properties, and until this module nothing in the build checked any of them.
Three separately pullable artefacts. Installation promises "take one without the
other if that is all you need". No module declares a project dependency on another, so the promise
is kept purely by omission — one line in a build file plus one import turns three artefacts into
one, and neither the compiler nor a test says a word.
A compute core that knows no server. RegionFile is, by its own Javadoc, "a pure byte container.
It neither knows the NBT structure of a chunk nor the Minestom chunk model"; BlockLightSource
"separates the algorithm from the registries of a running server". Minestom is compileOnly
everywhere (see Dependency Management), so a wrong import compiles straight
through. This separation is also what makes the published numbers possible at all: Minestom's
AnvilLoader cannot be touched in a bare JMH fork because its static fields read the registry, while
Falco's RegionFile reads none — see Rationale: Chunk Loading.
An experimental but honest API. The build has no japicmp, no revapi and no Error Prone. Whatever
reaches repo.onelitefeather.dev cannot be taken back binary-compatibly, and the only carrier of the
@ApiStatus.Experimental promise is the annotation itself.
Two reasons, and the second is the stronger one.
Cross-module rules cannot be stated inside a module that only sees itself: "falco-anvil does not
know falco-light" has no home in either of them.
More importantly, falco-archunit pulls the other modules as test dependencies, so it sees only
their main classes — which puts it exactly where a consumer of the jar stands. That position is
what lets publicSignaturesStayReachable find a class of mistake no test in the modules
themselves can: a public method returning the package-private SectorAllocator compiles, passes
every test in falco-anvil because those tests share the package, and is unusable for a consumer,
who cannot name the type.
The same construction puts test sources out of reach by design. falco-light declares
testImplementation(project(":falco-anvil")) — a cross-module dependency the isolation rules would
otherwise flag, and one that is entirely legitimate, because the light engine is verified against
chunks the loader reads. Rules over test sources would need a second module or a second import path;
that is a decision deliberately not taken rather than an oversight.
39 @ArchTest fields in five classes, under
falco-archunit/src/test/java/net/onelitefeather/falco/architecture/.
| Class | Rules | What it pins |
|---|---|---|
ModuleBoundaryTest |
7 | The three published modules do not know each other; they use only the declared third-party libraries; no published module touches the Minestom implementation it replaces; the measuring path of the demo knows only the interface both sides share; SLF4J appears in no public signature |
PublicApiTest |
6 |
@ApiStatus.Experimental on every public type; @NotNullByDefault on the package; no unreachable types in public signatures; visible fields are immutable constants; public classes are final unless forced open; utility classes hide their constructor |
ForeignCouplingTest |
10 | The light core and the byte layer know no Minestom, no NBT and no file; the registry is queried only in the named adapters; Falco does not implement Minestom's Light; only ChunkLightService writes into it |
ErrorHandlingTest |
8 | Own exceptions are public, unchecked and carry a cause; no generic throwables; one ExceptionManager boundary per module; only the demo owns the console and System.exit; loggers are private static final and SLF4J only |
ConcurrencyTest |
8 | No mutable static field; virtual threads only, and bounded by a semaphore; no ThreadLocal; no publicly reachable monitor; shared state is safely published |
Each rule carries its reason in its own Javadoc, with the file and line the reason rests on.
./gradlew :falco-archunit:test # the rules alone
./gradlew check # everything, the rules included
archRule.failOnEmptyShould=true is pinned in archunit.properties, so a rule whose selection runs
empty fails rather than passing silently. That matters more than it sounds: several rules select a
handful of classes, and a refactoring that empties the selection would otherwise turn the rule green
by making it vacuous.
A rule that appears to cover more than it does is worse than no rule, so each one names its own limit in its Javadoc. The load-bearing ones:
-
synchronizedblocks. ArchUnit models bytecode members and accesses, notmonitorenter/monitorexit.noPublicMonitorcatches theACC_SYNCHRONIZEDflag on a method and is structurally blind tosynchronized (this)inside one.BiomePaletteResolvercontains exactly such a block; it is harmless there — a double-checked initialisation behind a volatile field — but the rule would never have found it. -
The seqlock protocol in
RegionFile. Whether the order read-version → read-data → read-version-again is respected is the actual correctness condition of the parallel read path (see Rationale: Concurrency). Accesses are visible to ArchUnit, their ordering is not. -
"No CPU-bound work happens while a lock is held." The claim the three-stage loader rests on.
Two rules secure it indirectly, by keeping NBT and the file system out of the layers that must
not have them, but
lock()andunlock()are ordinary method calls and the region between them is not a concept of the model. -
Raw types only. Generic arguments are invisible, so an SLF4J
Loggerinside aList<Logger>passes the rule that keeps SLF4J out of the public API. -
Anything that exists only in Javadoc. "every caller of this method is covered by a test", "the
array is not copied", and the truth — as opposed to the presence — of a
@Nullable.
Two properties are semantic and belong to tests rather than to any static tool:
supportsParallelLoading() == true implying loadChunk is thread-safe, and the invariants a subtype
of FalcoInstance or FalcoChunk has to keep.
RegionFile.retryWhileDenied sleeps up to 100 times for 1 ms each, on a Windows quirk when replacing
an external chunk file. noBlockingWait exempts that method by name rather than the class or the
module, so every further blocking wait still trips the rule. The 100 ms worst case is stated in the
Javadoc of FalcoAnvilLoader.saveChunk, because that method runs on the caller's thread.
Written sharp and without exemptions, three were red on the first run. All three were answered by changing the code, not the rule:
| Rule | What it found |
|---|---|
publicApiIsMarkedExperimental |
RegionFile.RawChunk, the one public type of thirty-one without the marker, reachable through readRaw
|
noPublicMonitor |
FalcoAnvilLoader.close() was public synchronized and therefore locked on an object the caller holds — a caller writing synchronized (loader) blocked the shutdown |
sharedStateIsSafelyPublished |
FalcoInstance.chunkSupplier and chunkLoader were written by public setters and read on the load path with no barrier, so a reader could observe a half-constructed ChunkLoader
|
The third had been found independently while modernising for Java 25, from the other direction — by reading the code rather than by the shape of the field. Two routes, one defect.
A fourth rule was red for a reason the design had missed, and there the rule was wrong rather
than the code. It covered net.minestom.server.registry as a whole, but that package holds the
dynamic registry, which needs a booted server, next to RegistryData, the static tables
block.registry() hands out. MinestomBlockLightSource only ever reaches the latter — which the
neighbouring rule grants it by name. Narrowed to the dynamic registry, both rules say what they meant.
Most of the measured tables are owned by Benchmarking and
Project Status; a page that carries one of its own says so where the table stands.
What the ± after a JMH mean covers is stated once, in Rationale: Measurement.
Wiki home · Repository · README and quick start · API documentation · Issues · Licence: AGPL-3.0
Start here
- Quick start (README)
- Installation
- Anvil Chunk Loader
- Light Engine
-
Rationale: Instances and Chunks — the third module,
falco-instance
The measured record
Why it is built this way
- Rationale — the index for the five rationale pages
- Research — the index for the five investigations
- Research: Fluent API — a proposal; describes no existing API
Working on the build