Architektur vs. Realität
Architekturvorgaben werden in der Praxis nicht immer eingehalten. Ob durch Zeitdruck, Kommunikationsprobleme oder unterschiedliche Kompetenzlevel im Team – am Ende ist es menschliches Versagen. Dabei sind die Anforderungen eigentlich klar und sollen dauerhaft gelten.
Die Frage ist: Wie stellen wir sicher, dass unsere Architektur auch in der Realität eingehalten wird? Manuell bei jedem Pull-Request prüfen? Darauf warten, dass jemand mit Architekturwissen Zeit hat?
Nein – das geht besser. Der Ansatz: Architekturvorgaben als Regeln in Code formulieren und diese Regeln automatisiert im Buildprozess prüfen.
Was ist ArchUnit?
ArchUnit ist eine Open-Source-Bibliothek (Apache License 2.0), entwickelt von der TNG Technology Consulting GmbH. Sie ermöglicht:
- Statische Bytecode-Analyse: ArchUnit untersucht
.class-Dateien und ist damit nicht auf Java beschränkt, sondern auf alles anwendbar, was zu JVM-Bytecode kompiliert wird (Kotlin, Scala, Groovy, …) - Fluent API: Der produzierte Code ist sehr gut lesbar und kompakt
- Erweiterbarkeit: Wo die Fluent API nicht reicht, lässt sich ArchUnit einfach erweitern
Ein erster Eindruck – so liest sich eine ArchUnit-Regel:
classes()
.that().haveSimpleNameEndingWith("Dto")
.should().beRecords()
.because("records are cool")
.check(importedClasses);
Code-Sprache: Java (java)Fast wie ein englischer Satz. Und genau so formuliert ArchUnit auch seine Fehlermeldungen – die Regel wird in natürlicher Sprache wiedergegeben, inklusive der Begründung:
Architecture Violation [Priority: MEDIUM] -
Rule 'classes that have simple name ending with 'Dto' should be records,
because records are cool' was violated (1 times):
Class <...SomeDto> is not a record in (SomeDto.java:0)
Code-Sprache: Java (java)Grundkonzepte
ArchUnit arbeitet auf Klassensätzen (JavaClasses) und bietet zwei Herangehensweisen an Architekturtests:
- Lang API: Regeln ausgehend von Sprachelementen (Klassen, Felder, Methoden, …)
- Library API: Vorgefertigte Regeln und Metriken für gängige Architekturmuster
Das Domain-Modell von ArchUnit bildet Java-Sprachelemente und ihre Beziehungen ab – von JavaClass über Members (Felder, Methoden, Konstruktoren) bis hin zu Zugriffen zwischen Code-Einheiten. Ein Auszug:
Klassen laden
Der Einstiegspunkt ist der ClassFileImporter. Klassen können aus dem Classpath (Packages, einzelne Klassen), aus JARs oder aus Verzeichnissen geladen werden:
static JavaClasses importedClasses = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("de.conciso");
Code-Sprache: Java (java)Tipp zur Performanz: Klassensätze sollten wiederverwendet werden, da der Ladevorgang die teuerste Operation ist. Die Größe des Klassensatzes sollte angemessen bleiben (z.B. Projektklassen ohne Bibliotheken).
Regeln aufstellen mit der Lang API
Eine Architekturregel besteht aus fünf Schritten:
- Transformation: Regelzieltyp angeben (
classes(),fields(),methods(), …) - Selektion (optional): Regelziele eingrenzen (
.that().haveSimpleNameEndingWith("Dto")) - Anforderung: Bedingung formulieren (
.should().beRecords()) - Begründung (optional): Bei Fehler angezeigt (
.because("records are cool")) - Anwenden: Auf Klassensatz prüfen (
.check(importedClasses))
Praxisbeispiel: Eigene Erweiterungen
Bei einer komponentenbasierten Architektur sind Pakete in api (Schnittstellen, DTOs) und internal (Implementierung) aufgeteilt. Die zentrale Regel: Kein Code von außerhalb darf in ein internal-Paket hineingreifen. Zur Veranschaulichung:
@Test
void noDirectDependenciesShouldTraverseIntoAnInternalPackage() {
no(directDependencies())
.should(traverseIntoAnInternalPackage())
.because("of the component based architecture")
.check(importedClasses);
}
Code-Sprache: Java (java)Der ClassesTransformer extrahiert alle Abhängigkeiten aus dem Klassensatz:
AbstractClassesTransformer<Dependency> directDependencies() {
return new AbstractClassesTransformer<>("direct dependencies") {
@Override
public Iterable<Dependency> doTransform(JavaClasses c) {
return c.stream()
.flatMap(cls -> cls.getDirectDependenciesToSelf().stream())
.collect(Collectors.toUnmodifiableSet());
}
};
}
Code-Sprache: Java (java)Die ArchCondition segmentiert die Paketpfade von Ursprung und Ziel, findet den ersten abweichenden Segmentindex und prüft, ob ab dort ein internal-Segment auftaucht:
ArchCondition<Dependency> traverseIntoAnInternalPackage() {
return new ArchCondition<>("traverse into an internal package") {
@Override
public void check(Dependency dep, ConditionEvents events) {
// Ursprungs- und Zielpaketpfade segmentieren
String[] originSgmnts = dep.getOriginClass()
.getPackageName().split("\\.");
String[] targetSgmnts = dep.getTargetClass()
.getPackageName().split("\\.");
// Ersten abweichenden Segmentindex finden
int i = 0;
while (i < targetSgmnts.length && i < originSgmnts.length
&& Objects.equals(targetSgmnts[i], originSgmnts[i])) {
i++;
}
// Ab dem Unterschied: enthält der Zielpfad "internal"?
boolean accessingInternal = false;
while (i < targetSgmnts.length && !accessingInternal) {
accessingInternal = "internal".equals(targetSgmnts[i++]);
}
// Ergebnis eintragen
if (accessingInternal) {
events.add(SimpleConditionEvent.satisfied(dep,
"%s, which is an intrusion into %s".formatted(
dep.getDescription(),
String.join(".", Arrays.copyOf(targetSgmnts, i)))));
} else {
events.add(SimpleConditionEvent.violated(dep,
"%s, which is no intrusion".formatted(
dep.getDescription())));
}
}
};
}
Code-Sprache: Java (java)Die Fehlermeldung beschreibt jeden verbotenen Zugriff mit Ursprung, Ziel und Art der Verletzung:
Rule 'no direct dependencies should traverse into an internal package,
because of the component based architecture' was violated (3 times):
Constructor <b.internal.ServiceOfB(b.internal.c.internal.ServiceCImpl)>
has parameter of type <b.internal.c.internal.ServiceCImpl>,
which is an intrusion into b.internal.c.internal
Field <b.internal.ServiceOfB.serviceAImpl> has type <a.internal.ServiceAImpl>,
which is an intrusion into a.internal
Method <a.internal.ServiceOfA.aMethod()> references class object <b.internal.c.api.COutputDto>,
which is an intrusion into b.internal
Code-Sprache: Java (java)Praxisbeispiel: Library API
Die Library API bietet vorgefertigte Regeln für gängige Architekturmuster. Im Gegensatz zur Lang API arbeitet sie auf einer höheren Abstraktionsebene.
Onion-Architecture
Bei einer Onion-/Hexagonal-Architektur dürfen Abhängigkeiten nicht nach außen zeigen:
@Test
void projectShouldHaveOnionArchitecture() {
onionArchitecture()
.domainModels(BASE + ".core.domain.model..")
.domainServices(BASE + ".core.domain.service..")
.applicationServices(BASE + ".core.application..")
.adapter("configuration", BASE + ".infrastructure.config..")
.adapter("web", BASE + ".infrastructure.web..")
.adapter("persistence", BASE + ".infrastructure.persistence..")
.withOptionalLayers(true)
.check(importedClasses);
}
Code-Sprache: Java (java)Wird die Regel verletzt – etwa weil ein Application Service auf eine Infrastructure-Implementierung zugreift oder ein Domain-Service von einem Application Service abhängt – liefert ArchUnit eine präzise Meldung:
Rule 'Onion architecture consisting of
(optional) domain models ('..core.domain.model..'),
domain services ('..core.domain.service..'),
application services ('..core.application..'),
adapter 'configuration' ('..infrastructure.config..'),
adapter 'web' ('..infrastructure.web..'),
adapter 'persistence' ('..infrastructure.persistence..')'
was violated (3 times):
Constructor <...IllegalApplicationService(MessageRepository)>
checks instanceof <...infrastructure.persistence.internal.InMemoryMessageRepository>
Constructor <...IllegalDomainService(MessageMapper)>
has parameter of type <...core.application.service.message.MessageMapper>
Field <...IllegalDomainService.messageMapper>
has type <...core.application.service.message.MessageMapper>
Code-Sprache: Java (java)Darüber hinaus bietet die Library API Zyklenprüfungen mit Slices und die Möglichkeit, Architekturregeln direkt aus PlantUML-Diagrammen abzuleiten.
ArchUnit in bestehende Projekte einführen
Bei neuen oder kleinen Projekten (Greenfield) ist die Einführung von ArchUnit unkompliziert: Regeln werden von Anfang an definiert und greifen sofort, da es noch keine bestehenden Verstöße gibt.
Bei bestehenden oder Legacy-Projekten sieht es anders aus. Hier existieren häufig bereits zahlreiche Architekturverstöße, die nicht alle auf einen Schlag behoben werden können. Eine neue Regel würde sofort hunderte Fehler melden und den Build brechen – das ist weder praktikabel noch motivierend.
Die Lösung: FreezingArchRule. Damit lassen sich Architekturregeln einführen, ohne dass bestehende Verstöße den Build blockieren:
FreezingArchRule.freeze(
noClasses()
.should(haveADependencyTraversingIntoAnInternalPackage())
.because("of the component based architecture"))
.check(importedClasses);
Code-Sprache: Java (java)FreezingArchRule erstellt beim ersten Durchlauf einen ViolationStore (eine Textdatei), der alle zu diesem Zeitpunkt bestehenden Verstöße einfriert (auflistet). Diese Datei muss ins Repository eingecheckt werden.
Sobald der ViolationStore erstellt ist, gilt:
- Neue Verstöße werden erkannt und brechen den Build – die Architektur wird also nicht weiter verschlechtert.
- Behobene Verstöße werden automatisch aus dem Store entfernt – der Store schrumpft mit jeder Verbesserung. Diese Änderungen am
ViolationStoremuss natürlich mit gepusht werden.
So wird eine Regression verhindert, und das Team kann bestehende Verstöße schrittweise abbauen, ohne unter dem Druck zu stehen, alles sofort reparieren zu müssen.
Architekturmetriken
Gängige statische Analysetools wie PMD, SpotBugs oder Checkstyle messen auf Klassen- und Methodenebene: Cyclomatic Complexity, Lines of Code, Kohäsion einzelner Klassen (TCC), Fan-out. Sie beantworten Fragen wie „Ist diese Klasse zu komplex?“ oder „Hat diese Methode zu viele Pfade?“
ArchUnit ergänzt das um eine Ebene, die diese Tools nicht abdecken: Metriken auf Komponentenebene. Hier wird nicht eine einzelne Klasse bewertet, sondern das Zusammenspiel ganzer Pakete und Architekturbausteine:
- Visibility Metrics: Anteil
publiczu nicht-publicKlassen innerhalb von Komponenten – misst die Kapselung - Lakos Metrics: Wie viele Komponenten hängen (transitiv) voneinander ab? Kennzahlen wie CCD, ACD, RACD und NCCD
- Component Dependency Metrics: Efferente/afferente Kopplung (Ce/Ca), Instabilität, Abstraktionsgrad und Abstand von der idealen Hauptsequenz (Distance from Main Sequence)
Solche Architekturmetriken waren bisher spezialisierten (und teils eingestellten) Tools wie JDepend oder kommerziellen Lösungen wie Sonargraph vorbehalten. ArchUnit macht sie als Teil der normalen Testsuite zugänglich.
Fazit
ArchUnit schließt die Lücke zwischen Architekturvorgabe und Realität. Durch die Integration in den Buildprozess werden Architekturverstöße genauso früh erkannt wie fehlerhafte Unit-Tests. Die Fluent API macht Regeln lesbar, die Erweiterbarkeit ermöglicht auch projektspezifische Anforderungen, und mit der Library API sowie FreezingArchRule gelingt auch der Einstieg in bestehende Projekte. Da Regeln normaler Testcode sind, werden sie wie jeder andere Code gepflegt und versioniert – und bei größeren Umstrukturierungen kann FreezingArchRule erneut zum Einsatz kommen, um den Übergang schrittweise zu begleiten.
Dabei hat ArchUnit auch Grenzen. Als statisches Analysetool arbeitet es ausschließlich auf Bytecode: Laufzeitaspekte wie Spring-Proxies, dynamische Bean-Verdrahtung oder AOP-Verhalten bleiben unsichtbar. Auch die Fluent API hat ihre Tücken – sie erbt Eigenheiten der Reflection API, Negationen auf verschiedenen Ebenen (noClasses().should()... vs clases().should().not...) sind nicht immer intuitiv, und der Sprung von einzeiligen Standardregeln zu eigenen ClassesTransformer oder ArchCondition ist ein deutlicher Aufwandssprung. ArchUnit ersetzt kein Code Review, sondern entlastet es um die strukturellen Prüfungen, die sich automatisieren lassen.
Die vollständige Dokumentation findet sich im ArchUnit User Guide.
Food for your brain!
Du möchtest noch mehr Beiträge rund um IT, KI und digitale Transformation lesen?
Dann melde dich zu unserem Contentletter an. Er erscheint quartalsweise, bleibt angenehm kompakt und bringt dir die wichtigsten Impulse direkt ins Postfach, ganz ohne E-Mail-Flut.


