ArchUnit Tutorial: Test Java Architecture with JUnit 6

ArchUnit checks the structure of Java code, such as layers, package cycles, naming and Spring annotations, inside ordinary JUnit 6 tests. This tutorial sets up archunit-junit6 1.5.1, writes layered, onion and Spring Boot rules, reads real failure messages and freezes rules for legacy code.

Layered architecture diagram with controller, service and repository layers, green allowed arrows from RecipeController to RecipeService to RecipeRepository, and a red dashed arrow from ShoppingListController to ShoppingListRepository marked as a violation

ArchUnit is a Java library that checks the structure of our code, such as package dependencies, layers, naming and annotations, inside an ordinary JUnit test. ArchUnit reads the compiled class files, so it sees every field type, method parameter, method call and annotation in our classes. When a class breaks a rule, the test fails and lists each offending line.

We use ArchUnit to keep a codebase in the shape the team agreed on. Typical rules say that controllers never call repositories, that @Service classes stay in the service package, that packages have no cycles, and that nobody uses field injection.

The following example is an ArchUnit test for a Spring Boot recipe app. The rule forbids any class in a controller package from using a class in a repository package.

@AnalyzeClasses(packages = "com.howtodoinjava.recipes",
    importOptions = ImportOption.DoNotIncludeTests.class)
class RecipeArchitectureTest {

  @ArchTest
  static final ArchRule controllersDoNotUseRepositories = noClasses()
      .that().resideInAPackage("..controller..")
      .should().dependOnClassesThat().resideInAPackage("..repository..")
      .because("controllers must go through the service layer");
}

// RecipeController uses only RecipeService:      test passes
// RecipeController also uses RecipeRepository:   test fails, 3 violations listed

Notice that the rule reads almost like the sentence in our team’s coding guidelines, and it runs with Maven like any other JUnit test.

In the rest of this tutorial, we set up ArchUnit 1.5.1 with JUnit 6 and write rules for whole architectures and for a Spring Boot app. We also read real failure messages and freeze the rules for legacy code that cannot be fixed in one go.

1. What Is ArchUnit and What Does It Check?

Every team agrees on an architecture, and over time the code moves away from it. A new developer in a hurry injects a repository into a controller, and a few months later two packages depend on each other. Code review catches some of these changes, but reviewers look at the diff, not at the dependency graph of the whole project.

ArchUnit turns these agreements into tests. We write each rule in plain Java with a fluent API, and the rule runs on every build. A violation fails the build in the same way as a failing assertion, so we catch the wrong dependency in the pull request that adds it.

Most ArchUnit rules fall into one of these groups.

What we checkExample ruleArchUnit API
Dependencies between classesControllers do not use repositoriesnoClasses().that()…should().dependOnClassesThat()
LayersOnly the service layer may call the repository layerlayeredArchitecture()
Onion or hexagonal layoutThe domain never depends on adaptersonionArchitecture()
Package cyclesNo two feature packages depend on each otherslices().matching(…).should().beFreeOfCycles()
NamingClasses annotated with @RestController end with ControllerhaveSimpleNameEndingWith(…)
Annotations@Service classes reside in a service packageareAnnotatedWith(…) plus resideInAPackage(…)
General coding rulesNo System.out, no field injectionGeneralCodingRules constants

ArchUnit works on bytecode, not on source files, and it never starts the application. In our example project, the six rules of RecipeArchitectureTest run in about 1.3 seconds, including the class import.

Flow diagram of an ArchUnit test, from compiled class files through ClassFileImporter and JavaClasses to an ArchRule that either passes or throws an AssertionError
ArchUnit imports compiled classes once and evaluates each rule against them; a violation throws an AssertionError that fails the test.

2. Adding ArchUnit to a Maven Project With JUnit 6

ArchUnit ships one core artifact and one integration artifact per test framework. For JUnit 6, we add archunit-junit6. It brings the core library and the @AnalyzeClasses and @ArchTest annotations, plus a JUnit Platform test engine that runs the @ArchTest fields.

The following example is the project we use in this tutorial. It is a Spring Boot 4.1.1 recipe app on Java 25, tested with JUnit 6.1.3 and ArchUnit 1.5.1, the latest releases on Maven Central at the time of writing. The complete project is on GitHub.

<dependency>
  <groupId>com.tngtech.archunit</groupId>
  <artifactId>archunit-junit6</artifactId>
  <version>1.5.1</version>
  <scope>test</scope>
</dependency>

The JUnit Jupiter dependency comes from spring-boot-starter-test, as described in JUnit 6 Maven dependency. Spring Boot 4.1.1 manages JUnit 6.0.3, so we set the junit-jupiter.version property to 6.1.3 to get the latest JUnit release.

The right artifact depends on the test framework of the project.

Test frameworkArtifactNotes
JUnit 6com.tngtech.archunit:archunit-junit6Added in ArchUnit 1.5.0
JUnit 5com.tngtech.archunit:archunit-junit5Also runs on the JUnit 6 Platform in ArchUnit 1.5.1
JUnit 4com.tngtech.archunit:archunit-junit4Uses @RunWith(ArchUnitRunner.class)
Any other frameworkcom.tngtech.archunit:archunitNo annotations; we call rule.check(classes) ourselves

For Gradle, the same dependency is testImplementation ‘com.tngtech.archunit:archunit-junit6:1.5.1’.

The example project keeps clean code and broken code side by side. The recipes and mealplanner packages pass all rules, whereas the legacy package breaks them on purpose so that we can see the failure messages.

src/main/java/com/howtodoinjava
  recipes/        controller, service, repository, model   (all rules pass)
  mealplanner/    domain/model, domain/service, application, adapter/rest, adapter/persistence
  legacy/         controller, service, repository, pricing, billing, report   (violations)
src/test/java/com/howtodoinjava/archtests
  RecipeArchitectureTest, OnionArchitectureTest, LegacyViolationsTest, FrozenRulesTest, ...
src/test/resources/archunit.properties
archunit_store/   frozen violations, committed with the code

3. Importing the Classes to Check

Before ArchUnit can check anything, it needs to know which classes belong to our app. Importing the whole classpath would also pull in the Spring and JDK classes, so we always narrow the import to our own packages.

3.1. @AnalyzeClasses and ImportOption

With the JUnit integration, we put @AnalyzeClasses on the test class and declare each rule as a static final ArchRule field annotated with @ArchTest. The ArchUnit engine imports the classes once per test class and evaluates every rule field against them. Each field shows up as its own test in the IDE and in the Surefire report, as in the RecipeArchitectureTest class from the intro.

Without ImportOption.DoNotIncludeTests, test classes such as mocks and test configurations are checked as well, and they often break production rules. For example, a test class in the controller package that uses a repository would fail the rule from the intro.

The annotation has a few more attributes that pick the classes to import.

AttributeWhat it importsExample
packagesThe named packages and their sub-packagespackages = “com.howtodoinjava.recipes”
packagesOfThe packages of the given classes, safe for refactoringpackagesOf = RecipeApplication.class
classesOnly the listed classesclasses = RecipeService.class
importOptionsFilters applied to every class file locationImportOption.DoNotIncludeJars.class
wholeClasspathEverything on the classpath, rarely usefulwholeClasspath = true

If we set none of packages, packagesOf or classes (or the rarely used locations), ArchUnit imports the package of the test class. ArchUnit also caches the imported classes, so several test classes with the same @AnalyzeClasses settings share one import.

3.2. ClassFileImporter in a Plain JUnit Test

The ClassFileImporter class is the API behind @AnalyzeClasses. We call it ourselves when we want a normal @Test method, for example to assert on the result, or when the test framework has no ArchUnit integration.

JavaClasses imported = new ClassFileImporter()
    .withImportOption(new ImportOption.DoNotIncludeTests())
    .importPackages("com.howtodoinjava.recipes");                  // 5 classes

JavaClass service = imported.get("com.howtodoinjava.recipes.service.RecipeService");

ArchRule rule = classes()
    .that().resideInAPackage("..model..")
    .should().beRecords();

EvaluationResult result = rule.evaluate(imported);                 // does not throw
boolean violated = result.hasViolation();                          // false

rule.check(imported);                                              // throws AssertionError on violations

The method evaluate() returns an EvaluationResult that we can inspect, whereas check() throws an AssertionError when the rule is violated. The imported JavaClass objects also answer questions about the code, such as service.getDirectDependenciesFromSelf(), which includes Recipe and RecipeRepository for RecipeService.

4. Writing Rules With the Fluent API

Every ArchUnit rule has the same shape. We start with classes() or noClasses() from ArchRuleDefinition, select the classes with that(), state the condition with should() and add the reason with because(). The reason ends up in the failure message, so the developer who breaks the rule learns why it exists.

The entry points are static methods, so each test class needs a few static imports.

  • ArchRuleDefinition.classes() and ArchRuleDefinition.noClasses() from com.tngtech.archunit.lang.syntax.
  • Architectures.layeredArchitecture() and Architectures.onionArchitecture() from com.tngtech.archunit.library.
  • SlicesRuleDefinition.slices() from com.tngtech.archunit.library.dependencies.
  • The GeneralCodingRules constants from com.tngtech.archunit.library.
ArchRule rule = noClasses()                                        // 1. which side: classes() or noClasses()
    .that().resideInAPackage("..controller..")                     // 2. select classes
    .should().dependOnClassesThat().resideInAPackage("..repository..")   // 3. condition
    .because("controllers must go through the service layer");     // 4. reason in the message

Package patterns use two dots as a wildcard for any number of packages. So “..controller..” matches com.howtodoinjava.recipes.controller and any sub-package of it. Parentheses in a pattern capture a package name, which we use for slices in section 7.

We combine predicates with and() and or(), and conditions with andShould() and orShould(). The methods we use most often fit in one table.

PartMethodMatches or requires
that()resideInAPackage(“..service..”)Classes in a package pattern
that()areAnnotatedWith(Service.class)Classes with an annotation
that()haveSimpleNameEndingWith(“Controller”)Classes by name
that()areAssignableTo(MealRepository.class)Subtypes of a type
should()resideInAPackage(…)The class is in the package
should()dependOnClassesThat()…Any dependency: field, parameter, call, annotation
should()accessClassesThat()…Only field accesses and method or constructor calls
should()haveSimpleNameEndingWith(…)Naming convention
should()beAnnotatedWith(…)Required annotation
should()onlyBeAccessed().byAnyPackage(…)Who may call the class

The difference between dependOnClassesThat() and accessClassesThat() matters. A controller with a repository field but no call to it violates dependOnClassesThat(), but not accessClassesThat().

5. Testing a Layered Architecture

A layered architecture splits the app into horizontal layers, and each layer may use only the layers below it. In a typical Spring Boot app, the controllers call the services and the services call the repositories. We can write this with several noClasses() rules, but layeredArchitecture() states all layers in one rule.

For example, a recipe app has RecipeController, RecipeService and RecipeRepository. If a controller reads from the repository, it skips the validation and the transactions in the service. In the diagram, the red arrow is the legacy ShoppingListController, which reads ShoppingListRepository without a service.

Layered architecture diagram with controller, service and repository layers, green allowed arrows from RecipeController to RecipeService to RecipeRepository, and a red dashed arrow from ShoppingListController to ShoppingListRepository marked as a violation
layeredArchitecture() allows the calls between neighbor layers and reports the controller that skips the service layer.

The rule names each layer with a package pattern and states which layers may use it.

@ArchTest
static final ArchRule layersAreRespected = layeredArchitecture()
    .consideringAllDependencies()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Repository").definedBy("..repository..")
    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");      // passes for recipes

The first call decides which dependencies the rule looks at.

  • The consideringAllDependencies() setting checks every dependency, even the one on java.lang.Object. It works well with rules on incoming dependencies, such as mayOnlyBeAccessedByLayers(…), and it also catches a path that goes through a class outside all layers.
  • The consideringOnlyDependenciesInLayers() setting ignores every dependency whose origin or target is outside the defined layers. Rules with mayOnlyAccessLayers(…) need this setting or the next one to avoid false violations on JDK classes, but it misses a path such as service to utils to controller.
  • The consideringOnlyDependenciesInAnyPackage(“com.howtodoinjava..”) setting checks only dependencies within our own packages, which is the middle ground between the two.

5.1. Reading an ArchUnit Failure Message

Let us break the rule on purpose. We add RecipeRepository as a second constructor parameter of RecipeController and call findByName() from the get() method. Running mvn test fails the build.

[ERROR] Failures:
[ERROR]   RecipeArchitectureTest.controllersDoNotUseRepositories Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..controller..' should depend on classes that reside in a package '..repository..', because controllers must go through the service layer' was violated (3 times):
Constructor <com.howtodoinjava.recipes.controller.RecipeController.<init>(com.howtodoinjava.recipes.service.RecipeService, com.howtodoinjava.recipes.repository.RecipeRepository)> has parameter of type <com.howtodoinjava.recipes.repository.RecipeRepository> in (RecipeController.java:0)
Field <com.howtodoinjava.recipes.controller.RecipeController.repository> has type <com.howtodoinjava.recipes.repository.RecipeRepository> in (RecipeController.java:0)
Method <com.howtodoinjava.recipes.controller.RecipeController.get(java.lang.String)> calls method <com.howtodoinjava.recipes.repository.RecipeRepository.findByName(java.lang.String)> in (RecipeController.java:28)
[ERROR]   RecipeArchitectureTest.layersAreRespected Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture considering all dependencies, consisting of
layer 'Controller' ('..controller..')
layer 'Service' ('..service..')
layer 'Repository' ('..repository..')
...
where layer 'Repository' may only be accessed by layers ['Service']' was violated (3 times):
...
[ERROR] Tests run: 6, Failures: 2, Errors: 0, Skipped: 0

The message has the same parts for every rule, so we learn to read it once.

  • The header shows the test field name, the priority, the full rule text with the because() reason, and the number of violations.
  • Each following line is one violation. It names the source member, the kind of dependency (has parameter of type, has type, calls method) and the target class.
  • The part in parentheses is the source file and line. Fields and constructor parameters show line 0, because the class file stores line numbers only for code inside methods.

One wrong dependency produced three violations, because ArchUnit reports each kind of dependency on its own line.

6. Testing an Onion Architecture

The onion architecture, a close relative of the hexagonal (ports and adapters) architecture, puts the domain in the center. Outer rings may depend on inner rings, never the other way around. Adapters such as REST endpoints and database code sit in the outer ring, and the domain does not know they exist.

Say a meal planner app keeps its calorie logic in a domain service and reads meals through a MealRepository interface. The in-memory and REST implementations live in adapters, so the domain stays testable without a database or a web server.

Onion architecture rings with domain model in the center, domain service, application and adapters around it, plus the five dependency rules that ArchUnit checks
In an onion architecture every dependency points inward, and onionArchitecture() also forbids adapters from using each other.

The onionArchitecture() rule takes a package pattern for each ring and a name plus pattern for each adapter.

@ArchTest
static final ArchRule onion = onionArchitecture()
    .domainModels("..domain.model..")                      // Meal
    .domainServices("..domain.service..")                  // CalorieCalculator, MealRepository
    .applicationServices("..application..")                // MealPlanService
    .adapter("rest", "..adapter.rest..")                   // MealPlanEndpoint
    .adapter("persistence", "..adapter.persistence..");    // InMemoryMealRepository; passes

The rule fails in cases such as these.

  • Meal imports a class from the application or an adapter package.
  • MealPlanService uses InMemoryMealRepository instead of the MealRepository interface.
  • The REST adapter calls the persistence adapter.

Notice that the repository interface lives in domain.service, so the persistence adapter depends on the domain, not the other way around.

7. Detecting Package Cycles With Slices

A package cycle means that package A uses package B and package B uses package A. Cycles make it impossible to move one package into its own module, and a change in either package can break the other. ArchUnit finds them with slices.

A slice is a group of classes selected by a capture group in a package pattern. The pattern “com.howtodoinjava.recipes.()..”* makes one slice for each direct sub-package of recipes, such as controller and service. The pattern *”..(**)”* captures nested package names as well.

@ArchTest
static final ArchRule noCycles = slices()
    .matching("com.howtodoinjava.recipes.(*)..")
    .should().beFreeOfCycles();                            // passes for recipes

In the legacy package, Invoice in billing calls PriceCalculator in pricing, and PriceCalculator.price() takes an Invoice parameter. The same rule on “com.howtodoinjava.legacy.()..”* reports the cycle and every dependency that forms it.

Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'com.howtodoinjava.legacy.(*)..' should be free of cycles' was violated (1 times):
Cycle detected: Slice billing ->
                Slice pricing ->
                Slice billing
  1. Dependencies of Slice billing
    - Method <com.howtodoinjava.legacy.billing.Invoice.total()> calls constructor <com.howtodoinjava.legacy.pricing.PriceCalculator.<init>()> in (Invoice.java:8)
    - Method <com.howtodoinjava.legacy.billing.Invoice.total()> calls method <com.howtodoinjava.legacy.pricing.PriceCalculator.price(com.howtodoinjava.legacy.billing.Invoice)> in (Invoice.java:8)
  2. Dependencies of Slice pricing
    - Method <com.howtodoinjava.legacy.pricing.PriceCalculator.price(com.howtodoinjava.legacy.billing.Invoice)> has parameter of type <com.howtodoinjava.legacy.billing.Invoice> in (PriceCalculator.java:0)
    - Method <com.howtodoinjava.legacy.pricing.PriceCalculator.price(com.howtodoinjava.legacy.billing.Invoice)> calls method <com.howtodoinjava.legacy.billing.Invoice.items()> in (PriceCalculator.java:8)

To fix the cycle, we break one direction, for example by passing the item count to price(int items) instead of the whole Invoice. If we want feature packages that do not use each other at all, we write slices().matching(…).should().notDependOnEachOther() instead.

8. ArchUnit Rules for a Spring Boot Application

Spring Boot apps have conventions that the compiler does not check. The stereotype annotations such as @Service and @Repository say what a class is, and the package says where it belongs. When the two disagree, the next developer looks for the class in the wrong place.

The first group of rules matches Spring annotations with packages and names. A class annotated with @Repository belongs in the repository package, and a @RestController ends with Controller.

@ArchTest
static final ArchRule servicesLiveInServicePackage = classes()
    .that().areAnnotatedWith(Service.class)
    .should().resideInAPackage("..service..");

@ArchTest
static final ArchRule repositoriesLiveInRepositoryPackage = classes()
    .that().areAnnotatedWith(Repository.class)
    .should().resideInAPackage("..repository..");

@ArchTest
static final ArchRule controllersAreNamedController = classes()
    .that().areAnnotatedWith(RestController.class)
    .should().haveSimpleNameEndingWith("Controller")
    .andShould().resideInAPackage("..controller..");

The legacy package has a DiscountHelper class annotated with @Service that sits in the controller package. The first rule reports it with a short message.

Architecture Violation [Priority: MEDIUM] - Rule 'classes that are annotated with @Service should reside in a package '..service..'' was violated (1 times):
Class <com.howtodoinjava.legacy.controller.DiscountHelper> does not reside in a package '..service..' in (DiscountHelper.java:0)

The second group covers dependency injection. Spring recommends constructor-based dependency injection for required dependencies, because the fields can be final and a unit test can pass mocks to the constructor. Field injection with @Autowired hides the dependencies, and ArchUnit has a ready rule for it.

@ArchTest
static final ArchRule noFieldInjection = GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;

The ShoppingListController in legacy has an @Autowired field of type ShoppingListRepository, so it breaks this rule and the controller rule from the intro. The field injection message names the field and the annotation.

Architecture Violation [Priority: MEDIUM] - Rule 'no classes should use field injection, because field injection is considered harmful; use constructor injection or setter injection instead; see https://stackoverflow.com/q/39890849 for detailed explanations' was violated (1 times):
Field <com.howtodoinjava.legacy.controller.ShoppingListController.repository> is annotated with @Autowired in (ShoppingListController.java:0)

Besides @Autowired, the rule detects fields annotated with @Value, @Resource and @Inject from Jakarta, javax or Guice. ArchUnit tests complement the tests for controller, service and DAO layers, because they check the structure, not the behavior, and they need no Spring context.

9. Predefined GeneralCodingRules

The GeneralCodingRules class holds ready-made rules for common bad practices. We assign each constant to an @ArchTest field, and the rule name and reason come with it.

@ArchTest
static final ArchRule noSystemOut = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;            // passes

@ArchTest
static final ArchRule noGenericExceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;   // passes

@ArchTest
static final ArchRule noJavaUtilLogging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;        // passes

@ArchTest
static final ArchRule noOldDateClasses = OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USED;    // passes

ArchUnit 1.5.1 has the following rule constants in GeneralCodingRules.

ConstantFails when a class …
NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMSUses System.out, System.err, java.lang.IO (JDK 25) or printStackTrace()
NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONSThrows Throwable, Exception or RuntimeException
NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGINGUses java.util.logging
NO_CLASSES_SHOULD_USE_JODATIMEUses Joda-Time instead of java.time
NO_CLASSES_SHOULD_USE_FIELD_INJECTIONHas a field annotated with @Autowired, @Value, @Inject or @Resource
OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USEDUses java.util.Date, java.util.Calendar, java.sql.Date, java.sql.Time or java.sql.Timestamp
DEPRECATED_API_SHOULD_NOT_BE_USEDUses a class or member annotated with @Deprecated
ASSERTIONS_SHOULD_HAVE_DETAIL_MESSAGECreates an AssertionError, for example with assert, without a detail message

In the legacy package, ReportPrinter writes to System.out and AuditPrinter writes to System.err, so the standard streams rule fails with one line per access.

Architecture Violation [Priority: MEDIUM] - Rule 'no classes should access standard streams' was violated (2 times):
Method <com.howtodoinjava.legacy.report.AuditPrinter.audit(java.lang.String)> gets field <java.lang.System.err> in (AuditPrinter.java:6)
Method <com.howtodoinjava.legacy.report.ReportPrinter.print(java.lang.String)> gets field <java.lang.System.out> in (ReportPrinter.java:6)

10. Freezing Rules in Legacy Code

Adding ArchUnit to a five-year-old project often produces hundreds of violations on the first run. Fixing them all before the rule goes live is rarely possible, and a rule that stays red gets disabled. The FreezingArchRule class solves this problem by recording the existing violations as known, so the rule fails only for new ones.

We wrap any rule in FreezingArchRule.freeze(…). On the first run, the rule writes all violations to a violation store. On later runs, it compares the current violations with the store and reports only those that are not in it.

@AnalyzeClasses(packages = "com.howtodoinjava.legacy")
class FrozenRulesTest {

  @ArchTest
  static final ArchRule noNewFieldInjection =
      FreezingArchRule.freeze(NO_CLASSES_SHOULD_USE_FIELD_INJECTION);   // passes, 1 known violation
}

By default, ArchUnit does not create a store, so the first run fails until we allow it. We configure the store in archunit.properties on the test classpath and commit the store folder with the code. A relative path resolves against the working directory, which is the project root when Maven runs the tests.

# Frozen rule violations are stored here and committed with the code
freeze.store.default.path=archunit_store
# Allow the first run to create the store (set to false on CI once the store exists)
freeze.store.default.allowStoreCreation=true

The store is plain text. The file stored.rules maps each rule text to a file name, and that file lists the known violations of the rule.

Field <com.howtodoinjava.legacy.controller.ShoppingListController.repository> is annotated with @Autowired in (ShoppingListController.java:0)

The behavior over several builds is easier to see on a timeline. In our test, ReportPrinter already prints to System.out when we freeze the standard streams rule, and a developer later adds AuditPrinter, which prints to System.err.

Three test runs of a frozen rule; run 1 stores ReportPrinter and passes, run 2 finds the new AuditPrinter and fails, run 3 finds no violations and removes ReportPrinter from the store
A frozen rule fails only for violations that are not in the store, and the store shrinks when old violations are fixed.

The second run fails with a message that names only the new class.

Architecture Violation [Priority: MEDIUM] - Rule 'no classes should access standard streams' was violated (1 times):
Method <com.howtodoinjava.legacy.report.AuditPrinter.audit(java.lang.String)> gets field <java.lang.System.err> in (AuditPrinter.java:6)

A few details of frozen rules matter in practice.

  • ArchUnit ignores line numbers when it compares violations, so adding lines above a known violation does not make it new.
  • When a known violation disappears, the next run removes it from the store, because freeze.store.default.allowStoreUpdate is true by default. We commit the smaller store with the fix.
  • The store key is the rule text. If we change the rule or its because() reason, ArchUnit sees an unknown rule and stores all its current violations again, as on the first run.
  • To accept all current violations again, for example after a large refactoring, we run once with freeze.refreeze=true.

11. ArchUnit FAQs

11.1. Does ArchUnit Work With JUnit 6?

Yes. Since version 1.5.0, ArchUnit has a separate archunit-junit6 artifact with a test engine for the JUnit 6 Platform. The package and annotation names are the same as in archunit-junit5, namely com.tngtech.archunit.junit.AnalyzeClasses and ArchTest, so a migration from JUnit 5 changes only the artifact ID. For the JUnit 6 basics, see our JUnit 6 tutorial.

11.2. How Do We Ignore Some Classes in ArchUnit?

We exclude classes at import time with an ImportOption. The predefined options ImportOption.DoNotIncludeTests, DoNotIncludeJars, DoNotIncludeArchives and DoNotIncludePackageInfos cover the common cases, and a custom option is one method that gets the class file Location.

public class ExcludeLegacyImportOption implements ImportOption {

  @Override
  public boolean includes(Location location) {
    return !location.contains("/legacy/");                 // skips com/howtodoinjava/legacy/**
  }
}

@AnalyzeClasses(packages = "com.howtodoinjava",
    importOptions = {ImportOption.DoNotIncludeTests.class, ExcludeLegacyImportOption.class})
class WholeAppArchitectureTest {

  @ArchTest
  static final ArchRule noSystemOut = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;   // passes, legacy is not imported
}

To skip classes for one rule only, we add a predicate to that(), for example .that().resideOutsideOfPackage(“..generated..”).

11.3. What Is the Difference Between ArchUnit and Spring Modulith?

ArchUnit is a general library, and we write every rule ourselves. Spring Modulith checks one fixed set of rules for application modules, such as no cycles between modules and no access to internal packages of another module. Spring Modulith can run jMolecules ArchUnit rules when they are on the classpath. For layer, naming and annotation rules like the ones in this tutorial, we use ArchUnit.

12. Conclusion

ArchUnit turns architecture decisions into JUnit tests that read the compiled classes. We add archunit-junit6 for JUnit 6, import our own packages with @AnalyzeClasses and ImportOption.DoNotIncludeTests, and declare each rule as an @ArchTest field.

The fluent API covers single dependencies with classes().that()…should(). For whole architectures, we use layeredArchitecture() or onionArchitecture(), and slices() finds package cycles. In a Spring Boot app, a handful of rules keep @Service and @Repository classes in their packages and stop controllers from calling repositories, and GeneralCodingRules forbids field injection.

Each failure message repeats the rule with its reason and lists every offending member with its line. For an existing codebase, FreezingArchRule records today’s violations, so the build fails only when someone adds a new one.

13. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.