Den Wert von Architekturregeln zur Sicherung der inneren Qualität von Systemen haben viele Entwicklungsteams verstanden und akzeptiert. Wie aber können Teams eine Vielzahl von Regeln verschiedener Granularität auch im stressigen Tagesgeschäft durchsetzen und erhalten?

Stellen Sie sich eine Applikation vor, die Kunden und Produkte verwaltet. Der Fachbereich gibt komplexe Produktregeln vor, die jeweils in einer dedizierten Regelklasse implementiert sind. Die Architektur sieht nun vor, dass innerhalb dieser Klassen nicht auf die Datenbank zugegriffen werden darf, um mehrfaches Laden derselben Daten zu vermeiden. Solche Regeln zählen zu den typischen Konventionen im Entwicklungsteam. In größeren Systemen gibt es oftmals ziemlich viele davon, und selbst erfahrene Entwickler haben selten alle dieser Regeln im Kopf – daher kommt es immer wieder (versehentlich) zu Verletzungen solcher Regeln. Zur Abhilfe schlagen wir vor, die guten Erfahrungen beim Testen funktionaler Anforderungen (sprich: automatisierte Tests) auf solche Architekturregeln anzuwenden.

Genau dieses Feature bietet ArchUnit: Es ermöglicht automatisierte Tests von Architekturregeln eines Softwareprojekts in etablierten Testframeworks wie JUnit.

Insbesondere kann das Entwicklungsteam im Rahmen der täglichen Arbeit die Architektur der Applikation absichern und gegebenenfalls verbessern. Das hilft sowohl bei Neuentwicklung, aber insbesondere auch bei Verbesserungs- oder Umbaumaßnahmen bestehender Systeme: Wann immer Sie eine Zielarchitektur definieren können (etwa die erlaubten Abhängigkeiten), können Sie ArchUnit gewinnbringend einsetzen.

Haben Sie in einem bestehenden System etwa mit vielen Verletzungen dieser Regeln zu kämpfen, können Sie mit ArchUnit die Verstöße gegen diese Regeln explizit aufzeigen. Bei Bedarf geben Sie Bereiche des Systems an, in denen Sie Abweichungen von den Regeln zulassen (“erlaubte Ausnahmen“). ArchUnit kann mit sehr einfachen und bewährten Mitteln in Form von Unit-Tests viel Transparenz in Ihre Architekturen bringen.

Im Folgenden lesen Sie, für welche typischen Anwendungsfälle Sie ArchUnit einsetzen können. Außerdem zeigen wir Möglichkeiten auf, wie Sie ArchUnit sehr einfach in Ihren täglichen Entwicklungsprozess integrieren können.

Installation und Integration

ArchUnit besitzt ein denkbar einfaches Setup: Die automatisierten Tests der Architekturregeln führen Sie einfach mit dem Unit-Test Framework ihrer Wahl aus – beispielsweise JUnit. Die Installation beschränkt sich auf das Einbinden einer kleinen zusätzlichen Library, die Sie über Maven Central beziehen können. Anschließend können Sie in kompakter Form Architekturregeln als Unit-Tests beschreiben und ausführen. Im Folgenden zeigen wir Ihnen dazu noch eine Reihe von Beispielen.

Der Charme dieses Vorgehens: Analog zu “normalen“ Unit-Tests kann das Entwicklungsteam die Architekturregeln während der Entwicklung in der IDE auswerten. Genauso einfach kann Ihr Build- oder CI-Server eine Suite von Regeln auswerten. Details zur Installation und Nutzung mit Gradle und Maven finden Sie auf der Webseite der ArchUnit Library.

Einfach und erweiterbar

ArchUnit macht „einfache Dinge einfach und komplizierte Dinge möglich”. In anderen Worten, typische Architekturregeln können Sie sehr einfach und kompakt beschreiben. Da ArchUnit auf der anderen Seite aber die volle Mächtigkeit von Java zur Entwicklung von Regeln anbietet, können Sie bei Bedarf eigene Regelarten entwickeln, und damit den Leistungsumfang von ArchUnit erweitern.

ArchUnit ist übrigens Open-Source und wird auf Github entwickelt.

Einfache Abhängigkeiten prüfen

Gehen wir von dem typischen Fall aus, dass unser System aus Schichten mit gerichteten Abhängigkeiten besteht (siehe Abbildung 1): Controller dürfen Services aufrufen, aber DAOs möchten wir das verbieten.

Abbildung 1: DAOs sollen keine Services aufrufen
Abbildung 1: DAOs sollen keine Services aufrufen

Einen ArchUnit-Test für diesen Fall könnten Sie beispielsweise wie in Listing 1 formulieren:

@Test
public void services_should_only_be_accessed_by_controllers() {
    // Wir geben an, welche Klassen importiert werden sollen
    JavaClasses classes = new ClassFileImporter().importPackage("com.myapp");

    // Wir spezifizieren eine Regel
    ArchRule rule = classes().that().resideInAPackage("..service..")
        .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

    // Wir prüfen die Einhaltung dieser Regel
    rule.check(classes);
}
Listing 1: Einfache Regel mit ArchUnit

Wenn Sie diesen Test mit JUnit laufen lassen und er fehlschlägt, so erhalten Sie eine detaillierte Fehlermeldung (siehe Listing 2) inklusive Regeltext und genauer Zeilennummer der Verletzung:

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule "classes that reside in a package '..service..' should only be accessed by any package ['..controller..', '..service..']"
was violated:
Method <com.myapp.dao.EvilDao.callService()> calls method
       <com.myapp.service.SomeService.doSomething()> in (EvilDao.java:14)
Listing 2: ArchUnit meldet einen Regelverstoß

Die ArchUnit-API ist als Fluent-API definiert: Einerseits bleiben Regeln damit kompakt und sehr verständlich (also für Menschen geeignet), andererseits kann ihre IDE mittels Autocomplete Vorschläge machen, wie sich eine Regel bilden lässt.

Verbotene Abhängigkeiten prüfen

Lassen Sie uns weitere typische Regeln betrachten: Wir möchten eine bestimmte Art von Abhängigkeit ausdrücklich verbieten – beispielsweise wie in Abbildung 2 gezeigt: Klassen aus “source“ dürfen auf keinen Fall Abhängigkeiten zu Klassen in “foo“ haben.

Abbildung 2: Verbotene Abhängigkeit
Abbildung 2: Verbotene Abhängigkeit

Das müssen wir jetzt “negativ“ formulieren: Keine Klasse aus “source“ darf Abhängigkeiten zu “foo“ haben – die ArchUnit-Lösung liest sich genau wie der umgangssprachliche Satz (wobei wir uns auf die Netto-Regel beschränken, und Imports beziehungsweise den Aufruf der Check-Methode hier weglassen):

noClasses().that()
           .resideInAPackage("..source..")
           .should()
           .accessClassesThat()
           .resideInAPackage("..foo..")
Verbotene Abhängigkeit

Das ist einfach, oder? Da juckt es uns doch fast in den Fingern, sofort loszulegen und weitere Regeln zu beschreiben. Aber bevor Sie direkt in Ihre IDE wechseln, lassen Sie uns noch ein paar Beispiele zeigen.

So können wir erlaubte oder verbotene Abhängigkeiten auch für gewisse Namensschemata regeln, beispielsweise, wie in Abbildung 3 gezeigt, darf nur die Klasse GenericDispatcher Klassen mit Suffix “Dispatcher“ verwenden, andere Klassen dürfen das nicht.

Abbildung 3: Abhängigkeiten auf Klassenebene regeln
Abbildung 3: Abhängigkeiten auf Klassenebene regeln

Auch diese Regel können wir mit der ArchUnit API sehr einfach beschreiben:

classes().that()
         .haveNameMatching(".*Dispatcher")
         .should()
         .onlyBeAccessed()
         .byClassesThat()
         .haveSimpleName("GenericDispatcher")
Namensschema prüfen

Weitere Arten von Regeln

Dann gibt es natürlich auch das Problem, dass manche Klassen schlicht an der falschen Stelle (sprich: im falschen Package) liegen – auch für solche Fälle können wir eine einfache Regel aufstellen, einen sogenannten Containment-Check:

Abbildung 4: Containment-Check
Abbildung 4: Containment-Check
classes().that()
         .haveSimpleNameStartingWith("Foo")
         .should().resideInAPackage("com.foo")

In dieser Regel überprüfen wir, ob Klassen mit bestimmten Namen in bestimmten Packages liegen. Auf ähnliche Weise können Sie fast beliebige Namenskonventionen über ArchUnit Regeln prüfen. Betrachten Sie folgendes Beispiel:

classes().that()
         .implement(Connection.class)
         .should()
         .haveSimpleNameEndingWith("Connection")

Hier sichern wir eine Namenskonvention bei Vererbungen ab – alle Unterklassen von “Connection“ müssen ihrerseits einen Namen haben, der auf “Connection“ endet.

Zyklenfreiheit prüfen

Zyklische Abhängigkeiten zwischen Komponenten verursachen sowohl im Build als auch für Tests potentiell Probleme. Allerdings macht die Code-Completion moderner Entwicklungsumgebungen es fürchterlich leicht, zyklische Abhängigkeiten aus Versehen einzuführen. Aber immerhin können Sie mit Hilfe von ArchUnit Regeln definieren, die solche Zyklen erkennen. Dafür müssen wir allerdings begrifflich etwas ausholen, und den sogenannten Slice einführen:

Dieser bezeichnet einen (eher fachlichen) Schnitt des Codes auf Paket-Ebene. Zum Beispiel könnte eine größere Anwendung com.myapp aus zahlreichen Modulen innerhalb des Pakets com.myapp.modules konzipiert sein, wobei per Konvention das erste Unterpaket unter com.myapp.modules dann den jeweiligen (fachlich motivierten) Modulnamen repräsentiert. Etwa com.myapp.modules.user und com.myapp.modules.customer. Grundsätzlich handelt es sich bei einem Slice also um eine Menge von Klassen oder Packages, deren inhaltlicher Zusammenhalt durch eine Namenskonvention ausgedrückt wird. Klingt abstrakt – aber das folgende Beispiel (entnommen aus der ArchUnit API Dokumentation) hilft: Die Definition

slices.matching("some.pkg.(*)..")

gruppiert die Packages some.pkg.one.any und some.pkg.one.other in denselben Slice, das Package some.pkg.two.any in einen zweite Slice.

Mit diesem Begriff ausgestattet können wir das Verbot zyklischer Abhängigkeiten zwischen Slices (siehe etwa nachfolgende Abbildung) wiederum einfach als Regel formulieren:

Abbildung 5: Zyklenfreiheit prüfen
Abbildung 5: Zyklenfreiheit prüfen
slices().matching("com.myapp.modules.(*)..")
        .should()
        .beFreeOfCycles()

Erweiterbarkeit

Wie gesehen definieren Sie ArchUnit Regeln direkt in Java. Grundsätzlich funktioniert das, indem ArchUnit Java Bytecode importiert, also nur für Sprachen, die auf der JVM laufen. Das bietet den großen Vorteil der leichten Erweiterbarkeit der vordefinierten ArchUnit API:

Sie können direkt die Objekte benutzen, welche ArchUnit unter der Haube verwendet. Zum Beispiel importierte Objekte vom Typ JavaClass, die importierte Klassen mit ihren Feldern, Methoden, aber auch Zugriffen und Aufrufen darstellen:

JavaClasses classes =
    new ClassFileImporter().importPackage("com.myapp");

for (JavaClass javaClass : classes) {
    doSomethingWith(javaClass);
}

Sie können die oben beschriebene Regelsyntax um beliebige eigene, in Java implementierte, Predicates und Conditions erweitern, wodurch Sie die Möglichkeit haben fast beliebige individuelle Anwendungsfällen abdecken zu können:

DescribedPredicate<JavaClass>
   areSpecialForMyProject =  // Prädikat definieren

ArchCondition<JavaClass>
   doSpecificThings =  // Condition definieren

classes().that(areSpecialForMyProject)
         .should(doSpecificThings);

Näheres hierzu finden Sie im ArchUnit User-Guide.

Für welche Sprachen können wir ArchUnit einsetzen?

ArchUnit arbeitet aktuell exklusiv auf Bytecode. Daher können Sie zumindest für Java, Scala und Kotlin aussagekräftige Tests mit ArchUnit schreiben.

An dynamischen Sprachen wie Groovy beisst sich ArchUnit bislang leider die Zähne aus - weil zuviele konkrete Abhängigkeiten erst zur Laufzeit ermittelt werden. Eine (un-groovy) Abhilfe wäre, auf alle dynamischen def Statements zu verzichten, grundsätzlich explizite Typen zu verwenden (ja, wissen wir, die Groovy-Protagonisten schütteln sich entsetzt bei diesem Gedanken) und mit der Annotation @compileStatic zu arbeiten.

Fazit

ArchUnit kann bei allen Arten von (JVM-basierten) Systemen helfen, Abhängigkeiten besser zu strukturieren. Insbesondere bietet ArchUnit eine einfache Option, die systematische Verbesserung nach dem aim42-Ansatz zu unterstützen – allerdings beschränkt auf JVM-basierte Systeme.

Sie können mit ArchUnit ganz unterschiedliche Kategorien von Regeln formulieren, darunter einfache und komplizierte Arten von (erlaubten und verbotenen) Abhängigkeiten, Zyklenfreiheit, Namenskonventionen, Annotationen, Kapselungen und Verwendung bestimmter Exceptions. Sie können ArchUnit aber auch um eigene Regeln und Konzepte erweitern und beispielsweise in Ihren Java-7 oder Java-8 basierten Systemen schon Module à la Jigsaw nachbauen.

In diesem Sinne wünschen wir Ihnen viel Erfolg mit dem automatisierten Prüfen Ihrer Architektur – may the architectural force be with you.