Unit Tests für deine Architektur? Einführung in ArchUnit

Julian | Jan 9, 2026 min read

Hallo zusammen, eigentlich dachte ich die ganze Zeit ich hätte hierzu schon einen Artikel geschrieben. Dann habe ich festgestellt, das ich ArchUnit zwar schon ein paar Mal erwähnt habe, aber dazu geschrieben habe ich nichts, außer in meinem Obsidian Vault. Das hole ich somit heute nach.

Wenn das Diagramm lügt

Wir alle kennen dieses Szenario: Zu Beginn eines Projekts erstellen wir wunderschöne Architektur-Diagramme. Wir definieren Schichten, Module und Abhängigkeiten. Alles sieht sauber aus – zumindest auf dem Whiteboard oder im Confluence-Wiki.

Aber dann kommt die Realität. Deadlines rücken näher, “Quick-Fixes” werden implementiert, und neue Teammitglieder, die den historischen Kontext nicht kennen, fügen Abhängigkeiten hinzu, die eigentlich verboten sein sollten. Sechs Monate später hat der Code kaum noch Ähnlichkeit mit dem ursprünglichen Plan.

Manuelle Code-Reviews helfen, aber sie sind fehleranfällig. Ein Senior-Entwickler übersieht vielleicht an einem Freitagabend, dass ein Service direkt auf ein DTO aus dem Web-Layer zugreift.

Die Lösung? Wir müssen unsere Architektur genau so behandeln wie unsere Geschäftslogik: Wir müssen sie automatisiert testen.

Was ist ArchUnit?

ArchUnit ist eine Java-Bibliothek, die es uns ermöglicht, Architektur-Regeln als ausführbare Tests zu kodieren. Man kann es sich vorstellen wie Unit-Tests, aber statt die Korrektheit einer Methode zu prüfen, prüfen wir die Struktur unserer Anwendung.

Das Geniale daran:

  • Keine separate Infrastruktur: ArchUnit läuft mit jedem normalen Java Unit-Testing-Framework (JUnit 4, JUnit 5, TestNG).
  • Bytecode-Analyse: Es importiert die kompilierten Java-Klassen und überprüft die Abhängigkeiten auf Bytecode-Ebene. Das ist schnell und zuverlässig.
  • Fluent API: Die Regeln sind fast wie englische Sätze lesbar (z.B. classes().should().onlyHaveDependentClassesThat()...).

Mit ArchUnit verschieben wir das Feedback zur Architektur von “irgendwann im Code-Review” zu “sofort beim Build”. Wenn jemand gegen die Regeln verstößt, schlägt der Build fehl. Punkt.

Setup & “Hello World”

Zuerst brauchen wir die Abhängigkeit. ArchUnit ist leichtgewichtig und lässt sich einfach über Maven oder Gradle einbinden.

Maven:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.4.0</version>
    <scope>test</scope>
</dependency>

Gradle:

testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.0'

Der erste Test

Anstatt einzelne Test-Methoden zu schreiben, nutzen wir oft die Annotation @AnalyzeClasses. Damit sagen wir ArchUnit, welches Paket (und dessen Unterpakete) gescannt werden soll.

Hier ist eine klassische Regel: Klassen im service-Paket sollten auch “Service” im Namen tragen.

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

@AnalyzeClasses(packages = "com.julianpaul.blog.example")
public class ArchitectureTest {

    @ArchTest
    static final ArchRule services_should_be_named_correctly = 
        classes()
            .that().resideInAPackage("..service..")
            .should().haveSimpleNameEndingWith("Service");
}

Pro-Tipp: Nutze statische Felder (static final), um Regeln zu definieren. ArchUnit erkennt diese automatisch durch die @ArchTest Annotation. Das macht den Testcode sehr sauber.

Die Klassiker: Regeln, die (fast) jedes Projekt “braucht”

Jetzt schauen wir uns drei Szenarien an, die fast jedem Entwickler schon Kopfschmerzen bereitet haben.

A. Layered Architecture prüfen

Das ist oft der Hauptgrund für den Einsatz von ArchUnit. Wir wollen sicherstellen, dass der Controller nicht direkt auf das Repository zugreift, sondern brav über den Service geht.

ArchUnit bietet hierfür eine sehr mächtige API, die fast wie eine Konfigurationsdatei aussieht:

import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@ArchTest
static final ArchRule layer_dependencies_are_respected = 
    layeredArchitecture()
        .consideringOnlyDependenciesInLayers()
        .layer("Controller").definedBy("..controller..")
        .layer("Service").definedBy("..service..")
        .layer("Persistence").definedBy("..repository..")

        .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");

B. Zirkuläre Abhängigkeiten vermeiden

Zyklen (Paket A braucht B, B braucht A) sind der Tod jeder Modularisierung. Sie machen Refactorings zur Hölle. ArchUnit kann diese “Slices” (Schnitte) automatisch erkennen und überwachen.

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@ArchTest
static final ArchRule no_cycles_in_structure = 
    slices().matching("com.julianpaul.blog.example.(*)..")
        .should().beFreeOfCycles();

Dieser Einzeiler stellt sicher, dass sich Module auf der obersten Ebene nicht gegenseitig referenzieren.

C. Saubere Schnittstellen

Ein Detail, das oft übersehen wird: Wir wollen nicht, dass Implementierungs-Details (wie JPA Entities) bis in den API-Layer (Controller) durchsickern. Controller sollten nur DTOs (Data Transfer Objects) zurückgeben.

@ArchTest
static final ArchRule controllers_should_not_return_entities = 
    classes().that().resideInAPackage("..controller..")
        .should().noMethod().should().haveRawReturnType(residesInAPackage("..entity.."))
        .because("wir wollen unsere interne Datenbank-Struktur nicht nach außen exponieren");

Hinweis: Die .because(...) Methode ist optional, aber extrem wertvoll. Sie erklärt dem Entwickler in der Fehlermeldung warum der Test fehlgeschlagen ist, anstatt nur dass er fehlgeschlagen ist.

Für Profis: ArchUnit in Legacy-Projekten (Freezing)

Das größte Hindernis bei der Einführung von Architektur-Tests in bestehenden Projekten ist die Menge an Altlasten. Wenn du die Regel “Keine Zyklen” in einem 5 Jahre alten Monolithen aktivierst, wirst du wahrscheinlich hunderte Fehler bekommen.

Die Reaktion des Teams? “Wir können das nicht alles fixen, lass uns ArchUnit wieder ausbauen.”

Hier kommt das Killer-Feature: FreezingArchRule.

Mit “Frozen Rules” können wir den Ist-Zustand akzeptieren, aber Verschlechterungen verhindern. ArchUnit merkt sich alle existierenden Verletzungen in einer Datei (dem Violation Store) und ignoriert diese bei zukünftigen Tests. Aber: Sobald jemand eine neue Verletzung einbaut, schlägt der Test fehl.

So funktioniert’s

Du wickelst deine Regel einfach in FreezingArchRule.freeze() ein:

import com.tngtech.archunit.library.freeze.FreezingArchRule;

@ArchTest
static final ArchRule no_cycles_frozen = 
    FreezingArchRule.freeze(
        slices().matching("com.julianpaul.blog.example.(*)..")
            .should().beFreeOfCycles()
    );

Beim ersten Ausführen passiert folgendes:

  1. ArchUnit findet alle Zyklen.
  2. Es legt eine Textdatei im Ordner archunit_store an, in der diese Fehler als UUIDs gespeichert sind.
  3. Der Test wird grün!

Wenn du nun einen der alten Zyklen reparierst, aktualisiert ArchUnit den Store automatisch. Der Fehler ist weg und darf nicht wiederkommen. Das ist der perfekte Weg, um technische Schulden Schritt für Schritt abzubauen, ohne den Build dauerhaft rot zu halten.

Fazit

ArchUnit schließt die Lücke zwischen dem Architektur-Diagramm an der Wand und dem tatsächlichen Code im Repository. Es verwandelt Architektur von einer “moralischen Verpflichtung” in eine harte Build-Metrik.

Mein Rat für den Start:

  1. Klein anfangen: Prüfe zuerst simple Dinge wie Paket-Zyklen oder Namenskonventionen.
  2. Nutze Freezing: Versuch nicht, das ganze Projekt an einem Tag aufzuräumen.
  3. Erkläre das “Warum”: Nutze .because(), damit deine Kollegen verstehen, warum der Build fehlgeschlagen ist.

Code-Qualität ist kein Zufall, sondern eine Entscheidung. Mit ArchUnit können wir sicherstellen, dass diese Entscheidung auch langfristig bestand hat.