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 check | Example rule | ArchUnit API |
|---|---|---|
| Dependencies between classes | Controllers do not use repositories | noClasses().that()…should().dependOnClassesThat() |
| Layers | Only the service layer may call the repository layer | layeredArchitecture() |
| Onion or hexagonal layout | The domain never depends on adapters | onionArchitecture() |
| Package cycles | No two feature packages depend on each other | slices().matching(…).should().beFreeOfCycles() |
| Naming | Classes annotated with @RestController end with Controller | haveSimpleNameEndingWith(…) |
| Annotations | @Service classes reside in a service package | areAnnotatedWith(…) plus resideInAPackage(…) |
| General coding rules | No System.out, no field injection | GeneralCodingRules 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.

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 framework | Artifact | Notes |
|---|---|---|
| JUnit 6 | com.tngtech.archunit:archunit-junit6 | Added in ArchUnit 1.5.0 |
| JUnit 5 | com.tngtech.archunit:archunit-junit5 | Also runs on the JUnit 6 Platform in ArchUnit 1.5.1 |
| JUnit 4 | com.tngtech.archunit:archunit-junit4 | Uses @RunWith(ArchUnitRunner.class) |
| Any other framework | com.tngtech.archunit:archunit | No 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.
| Attribute | What it imports | Example |
|---|---|---|
| packages | The named packages and their sub-packages | packages = “com.howtodoinjava.recipes” |
| packagesOf | The packages of the given classes, safe for refactoring | packagesOf = RecipeApplication.class |
| classes | Only the listed classes | classes = RecipeService.class |
| importOptions | Filters applied to every class file location | ImportOption.DoNotIncludeJars.class |
| wholeClasspath | Everything on the classpath, rarely useful | wholeClasspath = 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.
| Part | Method | Matches 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.

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.

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.
| Constant | Fails when a class … |
|---|---|
| NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS | Uses System.out, System.err, java.lang.IO (JDK 25) or printStackTrace() |
| NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS | Throws Throwable, Exception or RuntimeException |
| NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING | Uses java.util.logging |
| NO_CLASSES_SHOULD_USE_JODATIME | Uses Joda-Time instead of java.time |
| NO_CLASSES_SHOULD_USE_FIELD_INJECTION | Has a field annotated with @Autowired, @Value, @Inject or @Resource |
| OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USED | Uses java.util.Date, java.util.Calendar, java.sql.Date, java.sql.Time or java.sql.Timestamp |
| DEPRECATED_API_SHOULD_NOT_BE_USED | Uses a class or member annotated with @Deprecated |
| ASSERTIONS_SHOULD_HAVE_DETAIL_MESSAGE | Creates 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.

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
- ArchUnit User Guide
- ArchUnit Getting Started
- ArchUnit on GitHub
- ArchUnit Javadoc
- archunit-junit6 on Maven Central
- JUnit User Guide
- Spring Modulith: Verifying Application Module Structure
Happy Learning !!