Skip to main content

ArchUnit and Clean Architecture: the 4-Layer Diagram Is a Lie Until You Enforce It

The backend's ArchUnit gate was running zero executed tests, a total false green. How I found it, what I froze with FreezingArchRule (30+4 historical violations), and why a 4-layer diagram without automated enforcement is not an architecture.

What do you think about this article?

ArchUnit and Clean Architecture: the 4-Layer Diagram Is a Lie Until You Enforce It
Architettura Softwarefedericocalo.dev

At a Glance

The backend's ArchUnit gate was running zero executed tests, a total false green. How I found it, what I froze with FreezingArchRule (30+4 historical violations), and why a 4-layer diagram without automated enforcement is not an architecture.

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.

ArchUnit gate status before and after the fix, Spring Boot backend
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
Internal audit from 2026-07-07 on the Spring Boot 3.4 backend, 4-layer Clean Architecture. Gate code: 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 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.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects