Direct answer
A 4-layer diagram (domain, application, infrastructure, presentation) is not an architecture, it is an intention. It becomes an architecture only when an automated gate enforces it. In this portfolio's backend that gate ran for an unquantifiable amount of time importing zero classes, a total false green, before being discovered and replaced by an ArchUnit test that now freezes 30 historical presentation-infrastructure violations and 4 domain-purity ones, blocking any new regression.
Most Clean Architecture diagrams you see in talks and repository READMEs
are aspirational fiction. The arrows point toward the domain, the
layers are colored cleanly, and nobody has ever verified that the real
code respects those arrows. In this portfolio's Spring Boot backend
(4 layers, over 3,100 main Java files) I discovered that my own
enforcement gate, CleanArchitectureTest, had been running
for an indeterminate amount of time importing zero
classes. A green test that tested nothing. Here is what I learned
actually enforcing the architecture, not just drawing it.
Layering is a constraint, not an aesthetic
Clean Architecture's dependency rule is simple to state:
presentation → application → domain ← infrastructure. The
domain must know nothing about Spring, JPA, or HTTP. Controllers must
not import JPA repositories or infrastructure adapters directly, they
must go through an application use case. On paper this is obvious. In
practice, with over 3,100 classes and dozens of business verticals
(fiscal, ProjectHub, dataset marketplace, ecosystem portal, urbex)
added across different months by different agents, the rule breaks
silently dozens of times, because injecting a repository directly is
faster than going through an application port, and nothing stops it
unless something checks it on every build.
The false green: @ArchTest versus @Test
The first enforcement attempt used static-field @ArchTest
annotations, the "canonical" pattern documented by ArchUnit
itself. The problem: the archunit-junit5 discovery engine
(in the version in use) is not discovered by Surefire 3.5.3 against
JUnit Platform 1.11.4, managed transitively by Spring Boot 3.4 through
junit-jupiter 5.11.4. Result: Tests run: 0.
The test compiled, the module's build turned green, CI passed. No
error, no visible warning in normal output. An architectural safety
gate that protected absolutely nothing, for a period of time I cannot
precisely quantify because the symptom produces no distinctive signal.
The discovery only came from explicitly re-running the class and
reading the executed-test count in the output: not a red error, just
a zero where a number greater than zero was expected. The fix:
replace the @ArchTest fields with ordinary JUnit Jupiter
@Test methods, import the classes once in
@BeforeAll with ClassFileImporter, and call
rule.check(imported) inside every test. The same Jupiter
engine that already runs every other test in the module also runs
this one, deterministically.
Freezing the debt instead of pretending it does not exist
With the gate reactivated, the immediate question was: how many real
violations were introduced during the false-green period? The honest
answer was uncomfortable: about 30 presentation classes directly
importing infrastructure, and 4 domain classes depending on Spring or
JPA. Blocking everything immediately would have meant halting all
development for weeks on a huge, low-immediate-ROI refactor. The
choice was to use FreezingArchRule: explicitly freeze the
historical violations into a versioned store
(archunit-store), so the gate turns green again for the
existing debt, but every new violation added after
the freeze fails the build immediately.
| Gate | Before (false green) | After (frozen) |
|---|---|---|
| Classes imported by the test | 0 | 3,100+ |
| Presentation → infrastructure violations detected | 0 (unchecked) | 30 (frozen, no new ones) |
| Domain-purity violations detected | 0 (unchecked) | 4 (frozen, no new ones) |
| New violations allowed after the freeze | N/A | 0 |
CleanArchitectureTest.java.The trade-off is explicit and should be stated, not hidden: freezing the debt means accepting that 34 classes stay non-compliant indefinitely, until someone voluntarily decides to refactor them. The benefit is that the debt is now tracked, visible, quantified, instead of being invisible the way it was under the false green. Visible debt is debt someone can choose to pay down. Invisible debt is simply a deferred production incident.
Why narrow scope won before wide scope did
Before the global freezing gate, narrow-scope rules existed on single
classes or packages (FiscalAiController, the
euinvoice.presentation package). Those narrow rules had a
practical advantage: zero false positives on unrelated classes,
immediate and readable feedback during incremental development of a
specific feature. The downside: they covered only a tiny fraction of
the codebase, leaving out exactly the 30+4 violations the global gate
later found. The pattern that works in practice is not "narrow
scope OR wide scope", it is both in sequence: narrow rules during
active development of a feature, then a global frozen gate that closes
the loop without having to rewrite every narrow rule.
When enforcing architecture is not worth it
Honestly: if the team is a single person, if the code has an expected
lifespan of a few weeks (prototype, spike, proof of concept), or if
the project has fewer than 50 classes, ArchUnit and
FreezingArchRule setup cost outweighs the benefit. The
breaking point, in my direct experience on this backend, arrived
somewhere between 500 and 1,000 classes, when keeping track of which
layer depends on which by memory alone became impossible with
precision, and violations started being introduced by inattention
rather than deliberate choice.
The lesson I carry forward
A test that runs zero times is more dangerous than no test at all, because it gives false confidence. The concrete operational lesson I now apply to every new ArchUnit gate: after writing the test, run it explicitly once and read the actual number of tests executed in the output, not just the green/red color. Zero tests executed is a silent failure, not a success.
Frequently asked questions
Why can an ArchUnit test turn green without checking anything?
If the gate uses static @ArchTest fields and the archunit-junit5 discovery engine is not compatible with the Surefire/JUnit Platform version in use, the executed-test count stays zero with no visible error. The build turns green because technically no assertion fails, but no assertion is ever evaluated either.
How do you avoid the ArchUnit false-green problem?
By replacing @ArchTest fields with ordinary JUnit Jupiter @Test methods, importing the classes once in @BeforeAll with ClassFileImporter, and explicitly calling rule.check() inside every test method. This pattern uses the same Jupiter engine that already runs every other test in the module, so it is always actually executed.
What is FreezingArchRule and what is it for?
FreezingArchRule and what is it for?FreezingArchRule is an ArchUnit wrapper that saves a rule's existing violations into a versioned store, letting the build stay green for already-present historical debt while failing immediately if a new, not-yet-frozen violation is introduced. It makes technical debt trackable instead of forcing an all-at-once fix or total neglect.
Narrow-scope ArchUnit rules or a global gate: which should you choose?
It is not an either-or choice. Narrow-scope rules (on a specific class or package) are useful during active feature development because they give immediate feedback with no false positives. A global gate with FreezingArchRule covers the entire codebase and prevents new regressions everywhere, including areas not covered by the narrow rules.
At what codebase size does ArchUnit become worth introducing in a Spring Boot project?
There is no universal threshold, but in direct experience on this backend the breaking point arrived between 500 and 1,000 Java classes, when remembering which layer depends on which by memory alone became impossible and violations started being introduced by inattention more than by deliberate choice.
Commenti
Caricamento commenti...
Sign in to comment