All articles

// Knowledge.log — 技術記事

ArchUnit on Maven: the illegal dependency code review missed

A Maven ArchUnit rule catches a domain class depending on a persistence adapter that slipped through code review, using Java 25 and JUnit 6, no Spring.

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 equivalent layeredArchitecture()) running under mvn test.
  • The failure report: mvn -B test exits with code 1, Tests run: 2, Failures: 2, and the AssertionError lists all three violations — constructor, field, method call — each one pointing from academy.devdojo.shop.domain.Order to academy.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:

ComponentVersion
JDKTemurin 25.0.4 (Java 25 LTS)
Maven3.9.12
ArchUnit1.5.0 (com.tngtech.archunit:archunit-junit6)
JUnit Platform (via ArchUnit)6.1.2
maven-compiler-plugin3.14.1, <release>25</release>
maven-surefire-plugin3.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:

  1. Origin — the class that shouldn't depend on anything: academy.devdojo.shop.domain.Order.
  2. Target — the forbidden class: academy.devdojo.shop.adapter.persistence.OrderStore.
  3. How — three bytecode facts, not source facts: a constructor parameter, a field, a method call. The first two show up as Order.java:0 because 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 OrderStore into Order and never references the type, javac drops the import when it generates the .class file. 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, an Object or Map field 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 an int, 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 breaks plus(). 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.

javatesting

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles