Architektur ist kein Zufall — aber sie lässt sich schwer durchsetzen. Ein Entwickler fügt eine Abhängigkeit zwischen zwei Modulen hinzu, die eigentlich getrennt bleiben sollten. Ein anderer benennt eine Controller-Klasse ohne das Suffix Controller. Solche Verstöße fallen in Code-Reviews oft durch — aber nicht immer. ArchUnit macht Architekturregeln zu automatisch prüfbaren Unit-Tests. Was mit JUnit Assertions für die Code-Logik funktioniert, leistet ArchUnit für die Code-Struktur.
Grundlagen
ArchUnit besteht aus zwei Komponenten:
- archunit — die Kernbibliothek mit einer Fluent-API zum Definieren von Regeln
- archunit-junit5 — Integration mit JUnit 5 für komfortables Testen
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.2</version>
<scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)
ArchUnit analysiert den Java-Bytecode (keine Quelltextanalyse) und kann Regeln auf Package-, Klassen-, Methoden- und Feldebene definieren.
Paketabhängigkeiten überwachen
Die häufigste Architekturregel betrifft Schichten und Paketabhängigkeiten:
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.example.shop")
class SchichtenArchitekturTest {
@ArchTest
static final ArchRule schichtenRegel = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.layer("Domain").definedBy("..domain..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository");
}
Code-Sprache: CSS (css)
Dieser Test schlägt fehl, sobald ein Controller direkt auf ein Repository zugreift oder die Domänenschicht einen Service importiert — ein klassischer Fall von Architekturerosion.
Zyklenfreiheit erzwingen
Zirkuläre Abhängigkeiten zwischen Paketen sind ein Warnsignal für schlechte Modularisierung:
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@ArchTest
static final ArchRule keineZyklen = slices()
.matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
Code-Sprache: CSS (css)
Ein Zyklus zwischen com.example.shop.bestellung und com.example.shop.kunde führt sofort zu einem roten Test.
Namenskonventionen durchsetzen
Mit ArchUnit lassen sich auch Benennungsregeln zentral definieren:
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
@ArchTest
static final ArchRule controllerNamen = classes()
.that().resideInAPackage("..controller..")
.should().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule serviceImplementierungen = classes()
.that().resideInAPackage("..service..")
.should().haveSimpleNameEndingWith("Service")
.andShould().beAnnotatedWith(Service.class);
@ArchTest
static final ArchRule repositoriesAlsInterfaces = classes()
.that().resideInAPackage("..repository..")
.should().beInterfaces();
Code-Sprache: CSS (css)
Vertippt sich jemand und nennt einen Service KundeVerwaltung statt KundeVerwaltungService, signalisiert der ArchUnit-Test den Verstoß.
Geschützte Zugriffe auf externe Bibliotheken
Manche Klassen sollten bestimmte externe APIs nicht nutzen dürfen. ArchUnit kann das prüfen:
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@ArchTest
static final ArchRule serviceKeinSystemOut = noClasses()
.that().resideInAPackage("..service..")
.should().accessClassesThat()
.resideInAnyPackage("java.util.logging..", "java.sql..");
@ArchTest
static final ArchRule domainKeineFrameworkAbhängigkeit = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"com.fasterxml.."
);
Code-Sprache: CSS (css)
Die Domänenschicht bleibt so frei von technischen Abhängigkeiten — ein Kernprinzip von Clean Architecture und Domain-Driven Design.
Kapselung von Feldern
Auch innerhalb von Klassen lassen sich Regeln definieren:
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@ArchTest
static final ArchRule keinePublicFelder = fields()
.that().areDeclaredInClassesThat()
.resideInAPackage("..domain..")
.should().bePrivate();
@ArchTest
static final ArchRule keineJpaInDomäne = noClasses()
.that().resideInAPackage("..domain..")
.should().accessClassesThat()
.resideInAnyPackage("jakarta.persistence..");
Code-Sprache: CSS (css)
PlantUML-Integration: Diagramm als Regel
Eine Architektur kann auch als PlantUML-Komponentendiagramm modelliert und direkt als Regel eingebunden werden:
import com.tngtech.archunit.library.plantuml.PlantUmlArchCondition;
import static com.tngtech.archunit.library.plantuml.PlantUmlArchCondition.consideringOnlyDependenciesInAnyPackage;
@ArchTest
static final ArchRule plantUmlRegel = PlantUmlArchCondition
.adhereToPlantUmlDiagram(
MyArchitectureTest.class.getResource("/architektur.puml"),
consideringOnlyDependenciesInAnyPackage("com.example.shop.."));
Code-Sprache: CSS (css)
ArchUnit vergleicht dann die tatsächlichen Abhängigkeiten mit den im Diagramm erlaubten Verbindungen. So bleibt die Dokumentation im Code automatisch synchron mit der Realität.
Onion Architecture & Hexagonal Architecture
Neben der klassischen Schichtenarchitektur unterstützt ArchUnit auch Onion Architecture (Hexagonal Architecture / Ports & Adapters):
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
@ArchTest
static final ArchRule onionRegel = onionArchitecture()
.domainModels("..domain.model..")
.domainServices("..domain.service..")
.applicationServices("..application..")
.adapter("rest", "..adapter.rest..")
.adapter("persistence", "..adapter.persistence..");
Code-Sprache: CSS (css)
Die Domänenschicht ist der Kern und darf keine Abhängigkeiten nach außen haben. Adapter dürfen auf Domäne und Applikation zugreifen, aber nicht untereinander — ArchUnit prüft diese Regeln automatisch.
General Coding Rules
Für projektübergreifende Best Practices bietet ArchUnit vordefinierte GeneralCodingRules:
import static com.tngtech.archunit.library.GeneralCodingRules.*;
@ArchTest
static final ArchRule keinFieldInjection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
@ArchTest
static final ArchRule keinSystemOut = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
@ArchTest
static final ArchRule keineGenericExceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;
@ArchTest
static final ArchRule keineAltenDatumsklassen = OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USED;
Code-Sprache: CSS (css)
Diese Regeln sind sofort einsetzbar und decken häufige Fallstricke wie Field Injection, System.out-Logging oder die Nutzung von java.util.Date ab.
Frozen Rules
Hat man eine bestehende Codebasis mit vielen Architektur-Verstößen, ist es unrealistisch, alle auf einmal zu beheben. Frozen Rules frieren den aktuellen Stand ein und schlagen nur bei neuen Verstößen Alarm:
import com.tngtech.archunit.library.freeze.FreezingArchRule;
@ArchTest
static final ArchRule schichtenRegel = FreezingArchRule
.freeze(layeredArchitecture()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller"));
Code-Sprache: CSS (css)
Beim ersten Ausführen speichert ArchUnit alle aktuellen Verstöße in einem ViolationStore. Zukünftige Testläufe melden dann nur noch neue Verstöße — bestehende werden toleriert. Um den aktuellen Stand aller Verstöße bewusst neu einzufrieren, kann die Eigenschaft freeze.refreeze auf true gesetzt werden (z. B. via System-Property -Darchunit.freeze.refreeze=true). Der ViolationStore wird eingecheckt, sodass das gesamte Team dieselbe Baseline nutzt.
Fazit
ArchUnit verwandelt Architektur von einer subjektiven Diskussion in eine objektive, automatisierte Prüfung. Es ersetzt kein Architekturverständnis, macht aber Architekturregeln messbar und durchsetzbar. In CI/CD-Pipelines sichern diese Tests die strukturelle Integrität des Codes bei jedem Commit — und das mit einer API, die jedem JUnit-Entwickler sofort vertraut ist.