The PR got approved. It compiled, the business tests passed, nobody flagged anything in review. And yet the Order class — supposed to be a plain domain object — picked up a field of type OrderStore, the class that persists the order. Domain depending on adapter, the exact opposite of what every layering diagram in the team's wiki claims. That diagram lives on a page nobody has opened since onboarding. javac has no opinion on the matter, and the reviewer, scanning the diff, saw a constructor taking a dependency and moved on. That's what constructors do.
This post turns that architecture rule — "domain must not depend on adapter" — into a test Maven actually runs, one that fails the build with the source class, the target class, and the exact bytecode fact that broke the rule. No Spring Boot, no container, just Java 25, Maven, and ArchUnit.
What you'll verify
- A
noClasses().dependOnClassesThat()rule (and an equivalentlayeredArchitecture()) running undermvn test. - The failure report:
mvn -B testexits with code 1,Tests run: 2, Failures: 2, and theAssertionErrorlists all three violations — constructor, field, method call — each one pointing fromacademy.devdojo.shop.domain.Ordertoacademy.devdojo.shop.adapter.persistence.OrderStore. - The fix — pulling the adapter type out of the domain — and the rerun with
Tests run: 2, Failures: 0. - What this rule doesn't catch, and where it stops making sense.
Prerequisites and versions
Set verified on this host on 2026-09-21, no RC, milestone, or snapshot involved:
| Component | Version |
|---|---|
| JDK | Temurin 25.0.4 (Java 25 LTS) |
| Maven | 3.9.12 |
| ArchUnit | 1.5.0 (com.tngtech.archunit:archunit-junit6) |
| JUnit Platform (via ArchUnit) | 6.1.2 |
| maven-compiler-plugin | 3.14.1, <release>25</release> |
| maven-surefire-plugin | 3.5.6 |
One thing worth stating plainly, because it trips up anyone who only checks the project homepage: ArchUnit's own news widget still lists v1.4.2. Maven Central and the GitHub tag say otherwise — v1.5.0, shipped with JDK 25 support and the archunit-junit6 artifact. It's the kind of stale page nobody rushes to fix, because "everyone already knows" — until the next PR copies the wrong version straight from it.
archunit-junit6 is the convenience artifact: API, test engine, and a shared cache of imported classes across rules, following the same split Jupiter uses for JUnit 5 vs 6. It pulls in junit-platform-engine 6.1.2 transitively — no need to add junit-jupiter or a JUnit BOM just to use @AnalyzeClasses/@ArchTest. The older path, archunit-junit5 on top of JUnit 5.14.x, is still documented in the official User Guide and still shows up in projects that migrated less recently; it's mentioned here only as migration context, not as the example — the guide itself treats archunit-junit6 as the default install.
The minimal project
Three folders, two packages, one test dependency.
src/main/java/academy/devdojo/shop/domain/Order.java
src/main/java/academy/devdojo/shop/adapter/persistence/OrderStore.java
src/test/java/academy/devdojo/shop/ArchitectureTest.java
pom.xml with a single test dependency:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>academy.devdojo</groupId>
<artifactId>archunit-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<name>archunit-demo</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
<archunit.version>1.5.0</archunit.version>
</properties>
<dependencies>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit6</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
</project>
No junit-bom, no standalone junit-jupiter. Surefire picks up the JUnitPlatformProvider on its own, straight from the ArchUnit dependency.
The rule: domain must not depend on adapter
package academy.devdojo.shop;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "academy.devdojo.shop", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureTest {
@ArchTest
static final ArchRule domain_must_not_depend_on_adapter =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..adapter..");
@ArchTest
static final ArchRule adapter_must_not_be_accessed_from_domain =
layeredArchitecture().consideringAllDependencies()
.layer("Domain").definedBy("academy.devdojo.shop.domain..")
.layer("Adapter").definedBy("academy.devdojo.shop.adapter..")
.whereLayer("Adapter").mayNotBeAccessedByAnyLayer();
}
Two shapes for the same idea. The first is a point rule: nothing in ..domain.. may depend on anything in ..adapter... The second is layeredArchitecture(), meant for when you have several layers and want to declare the whole map at once — as of 1.5.0 it requires .consideringAllDependencies() before .layer(...).
Notice the verb: dependOnClassesThat, not accessClassesThat. accessClassesThat only sees method calls and field access; it misses a constructor parameter stashed in a field that's never read, or a type used only as a method parameter that's never invoked inside the analyzed class. dependOnClassesThat covers fields, constructor parameters, method parameters, return types, and inheritance — that's the wider net you want in a layering rule.
@AnalyzeClasses scans the package in bytecode (not source), and ImportOption.DoNotIncludeTests.class keeps the test class itself out of the analysis — otherwise it would generate noise, since it typically depends on half the project.
The illegal dependency
package academy.devdojo.shop.domain;
import academy.devdojo.shop.adapter.persistence.OrderStore;
public final class Order {
private final OrderStore store;
public Order(OrderStore store) {
this.store = store;
}
public void persist(String orderId) {
store.save(orderId);
}
}
package academy.devdojo.shop.adapter.persistence;
public final class OrderStore {
public void save(String orderId) {
// no-op: bytecode-only demo, no JDBC/Spring
}
}
Nothing here screams "this is bad." Order compiles, persist works, the unit test that just calls persist("123") passes without complaint. This is exactly the kind of change that survives a ten-minute code review: the reviewer sees a constructor taking one more dependency and keeps reading, because that's what constructors do.
Running mvn -B test:
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.830 s <<< FAILURE! -- in academy.devdojo.shop.ArchitectureTest
academy.devdojo.shop.ArchitectureTest.domain_must_not_depend_on_adapter -- Time elapsed: 0.760 s <<< FAILURE!
java.lang.AssertionError:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..' should depend on classes that reside in a package '..adapter..'' was violated (3 times):
Constructor <academy.devdojo.shop.domain.Order.<init>(academy.devdojo.shop.adapter.persistence.OrderStore)> has parameter of type <academy.devdojo.shop.adapter.persistence.OrderStore> in (Order.java:0)
Field <academy.devdojo.shop.domain.Order.store> has type <academy.devdojo.shop.adapter.persistence.OrderStore> in (Order.java:0)
Method <academy.devdojo.shop.domain.Order.persist(java.lang.String)> calls method <academy.devdojo.shop.adapter.persistence.OrderStore.save(java.lang.String)> in (Order.java:14)
The second rule, adapter_must_not_be_accessed_from_domain, fails with the same trio of facts, just wrapped in layer vocabulary. The build exits with status 1, Maven marks BUILD FAILURE. Two rules, one problem, reported twice — great if you want either test alone to block the merge, a bit redundant if the goal is a report you can skim in five seconds. Pick one for daily use; keeping both here is just to show they say the same thing in different words.
Reading the report
The AssertionError has a fixed shape, and it's worth memorizing the three questions it answers:
- Origin — the class that shouldn't depend on anything:
academy.devdojo.shop.domain.Order. - Target — the forbidden class:
academy.devdojo.shop.adapter.persistence.OrderStore. - How — three bytecode facts, not source facts: a constructor parameter, a field, a method call. The first two show up as
Order.java:0because fields and constructor parameters don't carry a line number in the debug table; the method call gets the real line,Order.java:14.
It's not "something's wrong somewhere in the domain." It's origin, target, and the exact coupling point — enough to open the right file without needing git blame to figure out who introduced what.
The fix
Order goes back to being what it should have been: a value object with an id, with no idea OrderStore exists.
package academy.devdojo.shop.domain;
public final class Order {
private final String id;
public Order(String id) {
this.id = id;
}
public String id() {
return id;
}
}
package academy.devdojo.shop.adapter.persistence;
import academy.devdojo.shop.domain.Order;
public final class OrderStore {
public void save(Order order) {
// no-op: bytecode-only demo, no JDBC/Spring
}
}
OrderStore now depends on Order — and the rule allows that: adapter depending on domain is fine, the other direction is what's banned. It's the direction of the arrow that matters, not the existence of a relationship.
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.795 s -- in academy.devdojo.shop.ArchitectureTest
mvn -B test exits with code 0, BUILD SUCCESS. If an SLF4J warning shows up saying it couldn't find a binding (the NOP logger), ignore it — that's noise from an ArchUnit transitive dependency, not a test failure. Mistaking that warning for a real problem is a fast way to burn ten minutes chasing a bug that doesn't exist.
What this rule doesn't catch
ArchUnit imports bytecode and checks structure — who depends on whom, who calls what, who extends what. It never runs the production method. Worth listing where that boundary actually hurts:
- Unused imports. If someone imports
OrderStoreintoOrderand never references the type,javacdrops the import when it generates the.classfile. No type in the bytecode, no violation — even if the import "looks" illegal in a visual diff review. - Wiring via reflection or bean name.
Class.forName, string-based injection, anObjectorMapfield a framework fills at runtime with an adapter instance — none of that leaves a type edge in the bytecode ArchUnit can import. A typed field, like in the example above, is visible; a reference that only exists at runtime isn't. - Behavioral bugs. An architecture rule has no opinion on whether
Order.plus()adds wrong, overflows anint, or rounds the wrong way. That's a logic failure, not a dependency failure — and it's exactly the kind of case property-based tests cover, as in the jqwik post about a broken sum (English version: jqwik: property-based testing beyond fixed examples), where the generated inputs find the value that breaksplus(). Two different failure modes: an architecture violation with class names versus a falsified property with a shrunk sample. ArchUnit doesn't replace that kind of test, it covers a different axis.
Fixing it and watching for it in production
For every violation, the fix is the same move: invert or remove the dependency until only the adapter knows about the domain, never the reverse. What changes is how early you catch it, before it turns into an incident.
Observability here doesn't need a new metric or a Micrometer counter — the build pipeline already does the job. Run mvn test (or mvn verify) in CI on every PR; the Surefire report for ArchitectureTest will hand you the source class, the target class, and the bytecode point the moment someone reintroduces the coupling. Name the test class with a Test suffix — that's the default pattern Surefire includes; a name like ArchitectureRules simply runs zero tests, and a silent report is easy to mistake for success.
Recommendation
noClasses().dependOnClassesThat() (or layeredArchitecture(), if the layers are already well defined) is worth adopting as soon as a project has more than one package with a dependency rule that today only lives in a document or in the head of whoever drew the architecture. The cost is one test dependency and a short class; the payoff is a BUILD FAILURE naming the offending classes instead of a late discovery in production or in an architecture meeting three months down the line.
Hold off if the module is small enough to fit entirely in a reviewer's head, or if the layers are still reshaping themselves week to week — at that stage the rule turns into constant test maintenance rather than real protection. And don't treat it as a substitute for behavioral tests: ArchUnit guarantees the arrow points the right way; if whatever sits at the tip of that arrow computes something wrong, that's a different test with a different tool.