ArchUnit: Architektur automatisiert durchsetzen

Foto des Autors
Till Meerkamp
Till Meerkamp

Architekturvorgaben werden in der Praxis nicht immer eingehalten. Zeitdruck, Kommunikationsprobleme oder unterschiedliche Kompetenzlevel im Team führen schnell zu Abweichungen. Dabei sind die Anforderungen eigentlich klar und sollen dauerhaft gelten.
Die Frage ist: Wie stellen wir sicher, dass unsere Architektur auch wirklich eingehalten wird? Manuell bei jedem Pull-Request prüfen? Darauf warten, dass jemand mit Architekturwissen Zeit hat?

Es geht besser: Architekturvorgaben als Regeln in Code formulieren und automatisiert im Buildprozess prüfen.

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:

  1. Transformation: Regelzieltyp angeben (classes()fields()methods(), …)
  2. Selektion (optional): Regelziele eingrenzen (.that().haveSimpleNameEndingWith("Dto"))
  3. Anforderung: Bedingung formulieren (.should().beRecords())
  4. Begründung (optional): Bei Fehler angezeigt (.because("records are cool"))
  5. 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 ViolationStore muss 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 public zu nicht-public Klassen 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.

Anmeldung zu unserem Contentletter

Das könnte Dich auch noch interessieren

Titelbild zum Wissensbeitrag "Multi-Tenancy in Spring-Boot-Projekten"

Multi-Tenancy in Spring-Boot-Projekten

In diesem Wissensbeitrag erläutern wir die grundlegenden Eigenschaften einer Multi-Tenancy-Architektur und demonstrieren anhand eines Spring Boot-Projekts eine beispielhafte Implementierung. Dabei ...
1. DDD Meetup Rhein/Main ein voller Erfolg

1. DDD Meetup Rhein/Main ein voller Erfolg

Am 16.05.2018 haben wir das erste DDD Meetup Rhein/Main in Frankfurt veranstaltet. Mit Marco Heimeshoff hatten wir den „DDD Care ...
Die Anatomie von Event Sourcing in Java

Die Anatomie von Event Sourcing in Java

In diesem zweiten Beitrag widme ich mich nun umgekehrt dem Event Sourcing, klammere dafür aber CQRS explizit aus. Das Ziel ...