Beliebte Suchanfragen
|
//

ArchUnit in der Praxis: Architektur sauber halten und optimieren

20.9.2024 | 17 Minuten Lesezeit

Wer kennt es nicht: Ein neues Projekt beginnt oder der alte Code soll endlich aufgeräumt werden. Ein großes Meeting mit allen Entwicklern und Entwicklerinnen wird einberufen: „Diesmal machen wir es sauber, korrekt und strukturiert!“ Architecture-Decision-Records (ADRs) werden erstellt, um die beschlossenen Regeln festzuhalten. Endlich herrscht eine heile Welt mit einer sauberen Paketstruktur, gut benannten Klassen, keinen zyklischen Abhängigkeiten und einem klaren Dependency-Flow.

Doch oft hält diese heile Welt nicht länger an, als man "Architecture-Decision-Records" aussprechen kann. Zunächst schleichen sich kleine Fehler ein, die schnell behoben werden. Aber meistens bleibt es nicht dabei, und die Fehler häufen sich und werden gravierender. Die ADRs geraten bald in Vergessenheit und das Team akzeptiert die Situation. Schließlich stehen die nächsten Deadlines an, und es gibt noch Bugs zu fixen.

Nach ein paar Monaten kommt dann der Impuls, wieder mehr Struktur in den Code zu bringen. Ein weiteres Meeting wird einberufen, in dem besprochen wird, was umgesetzt werden soll, und ein neues ADR wird erstellt. Oh, da gab es ja schon eins – aber das neue ist sicher besser.

Um diesen Kreislauf zu durchbrechen, braucht es einen Mechanismus, der kontinuierlich die getroffenen Entscheidungen überprüft und dem Entwicklerteam mögliche Fehler aufzeigt. Hier kommen Architekturtests ins Spiel.

Was sind Architekturtests?

Architekturtests sind eine spezielle Art von Softwaretests. Statt Funktionen oder Abläufe in der Businesslogik zu prüfen, konzentrieren sich diese Tests auf die Struktur der Software selbst. Dabei werden zuvor festgelegte Architekturregeln überprüft, um dem Entwicklerteam Feedback zu geben, wo im Code diese verletzt werden und Anpassungen nötig sind.

Diese Tests stellen sicher, dass die gewählten Architekturentscheidungen die Anforderungen an das System erfüllen. Architekturtests werden häufig mithilfe von automatisierten Tools und Frameworks durchgeführt, die speziell für die Überprüfung der Softwarearchitektur entwickelt wurden.

Einige bekannte Tools und Frameworks in diesem Bereich sind SonarQube, Structure101, JDepend und ArchUnit. Obwohl diese Tools nicht alle direkt miteinander vergleichbar sind, dienen sie alle dem Zweck, aufzuzeigen, wie eure Architektur aussieht und wo möglicherweise Verbesserungsbedarf besteht.

ArchUnit - Der goldene Apfel unter den Birnen

Warum ist ArchUnit so besonders, dass es einen ganzen Artikel wert ist? Werfen wir einen Blick auf den Alltag vieler Entwickler und Entwicklerinnen.

Nehmen wir an, die Regeln für die Architekturtests sind geschrieben und implementiert. Ein Entwickler schnappt sich ein Ticket, öffnet seine IDE und startet mit der Entwicklung. Ein neuer Branch wird erstellt, ein paar Klassen hier, ein paar Abhängigkeiten da. Alles läuft wie am Schnürchen. Das Feature funktioniert, die Unit- sowie die Integrationstests sind geschrieben und es ist natürlich Freitagmittag. Also noch kurz einen PR erstellt, und dann kann es auch bald schon ins Wochenende gehen.

Doch leider läuft nun zum ersten Mal die Pipeline durch, in der viele Frameworks wie SonarQube die Architektur testen. Und natürlich ist die Pipeline rot. Also, zurück zur IDE, die Klassen umbenennen, in die passenden Pakete schieben und ein paar unpassende Abhängigkeiten lösen. Die IDE hilft viel, aber einiges muss doch von Hand gemacht werden.

Aber nun passt alles: Also wieder pushen und schauen, was die Pipeline sagt. Mist, schon wieder rot. Nur noch eine Kleinigkeit, aber eigentlich habe ich jetzt schon keine Lust mehr. Und wenn ich den Architekten am Montag im Daily treffe, frage ich ihn erstmal, was das soll. So viel Zeitverschwendung, nur damit der Port auch wirklich „Port“ genannt wird.

Das Resultat: Der Entwickler hat keine Lust auf diese Architekturentscheidungen und hegt einen Groll gegen den Architekten. Der Architekt hat keine Lust, jedes Mal kämpfen zu müssen, damit die sinnvollen Architekturentscheidungen eingehalten werden. Am Ende leiden Zeit und Moral an etwas, das nötig und wichtig ist.

Hier kommt ArchUnit ins Spiel. Mit diesem Tool rücken wir eine Ebene näher ans Entwicklerteam heran. Die Tests können direkt in der IDE gestartet werden. Noch besser: Es muss weder ein neues Tool in die Infrastruktur integriert noch eine neue Sprache erlernt werden. ArchUnit-Tests basieren auf JUnit (4 oder 5), derselben Sprache wie die anderen Tests. Die Bibliothek wird einfach in die pom.xml oder build.gradle.kts eingefügt, und die Tests können geschrieben werden.

Die Tests lassen sich dann auch direkt während der Entwicklung ausführen. Am Ende ist es ein Tool, das sich so nahtlos in den bereits vorhandenen und bekannten Tech-Stack einfügt, dass man oft nach kurzer Zeit nicht mehr wahrnimmt, dass es sich um ein extra Tool handelt.

Ja, es basiert auf Java. Dabei ist es jedoch egal, ob ihr Kotlin, plain Java oder eine andere Sprache verwendet, solange der kompilierte Code am Ende Java-Klassen sind.

Am besten funktioniert das Ganze mit dem Test-Framework JUnit. Es können jedoch auch andere Frameworks eingesetzt werden, wobei die ArchUnit-Regeln dann etwas aufwändiger selbst geschrieben werden müssen.

ArchUnit in Action

Genug Prosa, wie sieht denn nun die Implementierung aus? Unser Projekt haben wir mit Kotlin und Gradle (Kotlin DSL) umgesetzt, dabei kam auch JUnit 5 zum Einsatz. Zusätzlich haben wir Spring verwendet, was allerdings nur einen sekundären Einfluss auf unser Thema hatte.

Um ArchUnit zu nutzen, müsst ihr die Dependency zunächst in eure build.gradle.kts einbinden:

1dependencies {
2    testImplementation ("com.tngtech.archunit:archunit-junit5:1.2.1")
3}

Nachdem die Dependency eingebunden ist, könnt ihr beginnen, ArchUnit-Tests zu schreiben. In einem typischen Setup verwendet ihr JUnit 5 könnten der ArchUnit Testcode wie folgt aussehen:

1[1] @AnalyzeClasses(
2    packagesOf = [Application::class],
3    packages = ["de.codecentric.example"],
4    importOptions = [DoNotIncludeTests::class]
5)
6[2] class ArchitectureTest {
7
8    [3] @ArchTest
9    val `services should reside in service package`: [4] ArchRule = classes()
10        .that().haveSimpleNameEndingWith("Service")
11        .should().resideInAPackage("..service..")
12}

Schauen wir uns die einzelnen Bestandteile des Codes genauer an:

  1. @AnalyzeClasses: Diese Annotation definiert, welche Klassen und Pakete von ArchUnit analysiert werden sollen. In diesem Fall wird die gesamte Anwendung (angegeben durch Application::class) sowie das Paket de.codecentric.example analysiert. Zudem wird durch die Option DoNotIncludeTests::class festgelegt, dass Testklassen von der Analyse ausgeschlossen werden.
  2. class ArchitectureTest: Hier wird die Testklasse definiert, die die ArchUnit-Tests enthält. Diese Klasse ist wie jede andere JUnit-Testklasse aufgebaut und dient als Container für die Architekturregeln, die überprüft werden sollen.
  3. @ArchTest: Diese Annotation kennzeichnet die ArchUnit-Tests innerhalb der Testklasse. In diesem Fall wird eine Regel definiert, die überprüft, ob alle Klassen, deren Name mit "Service" endet, im servicePackage liegen.
  4. ArchRule: Die Regel selbst wird durch classes() erstellt, was eine Sammlung von Klassentypen darstellt. Dann wird spezifiziert, dass diese Klassen mit "Service" enden sollen (that().haveSimpleNameEndingWith("Service")). Schließlich definiert die Methode should().resideInAPackage("..service.."), dass diese Klassen im servicePackage liegen müssen.

Der Aufbau der Tests in ArchUnit folgt immer einem ähnlichen Muster: Ihr beginnt mit classes() oder methods() als Ausgangspunkt, je nachdem, ob ihr Klassen oder Methoden prüfen möchtet. Anschließend könnt ihr auf verschiedene Aspekte wie Klassennamen, Paketstrukturen, Annotationen und weitere Kriterien prüfen, um sicherzustellen, dass die Architekturregeln eingehalten werden.

Da sich der Aufbau von Standardtests immer nach diesem Schema richtet, werden wir uns hier nicht weiter auf die Implementierung des Standardfalls konzentrieren. Für eine detaillierte Beschreibung und weitere Beispiele empfehlen wir, die offizielle Dokumentation von ArchUnit anzuschauen.

Stattdessen beschreiben wir im folgenden Abschnitt einige besondere Fälle, die in der Praxis auftreten können, und wie man diese mit ArchUnit handhaben kann.

Besondere Fälle und Herausforderungen

Nested Classes

Besonders in Kotlin kommen Nested Classes häufig vor, die dann an die Top-Level-Klasse angehängt werden. So zum Beispiel bei Companion-Objects. Das sieht dann beispielsweise so aus:

1TopLevelClass$Companion

Das führt dazu, dass solche Klassen bei einem Namenscheck nicht mehr korrekt erkannt werden:

1Class <TopLevelClass$Companion> does not have simple name ending with 'Class' in (TopLevelClass.kt:0)

Um dieses Problem zu vermeiden, prüfen wir nur die Top-Level-Klassen mit der .areTopLevelClasses()-Methode.

1@ArchTest
2val `check mapper`: ArchRule = classes()
3    .that().resideInAPackage("..mapper..")
4    .and().areTopLevelClasses()
5    .should().haveSimpleNameEndingWith("Mapper")

Klassen und Funktionen in einer Datei

Angenommen ich habe eine Datenklasse namens FooEntity. In dieser Datei habe ich neben der Datenklasse auch eine Funktion zum Beispiel eine Extension-Function.

1@Entity(name = "foo")
2data class FooEntity(
3    @Id
4    var id: UUID,
5    var name: String,
6)
7
8fun Foo.toBar(): String {
9    return "Bar is better"
10}

Testet man dies mit folgendem Code erhält man eine Fehlermeldung:

1@ArchTest
2val `check entities`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Entity")
4    .or().resideInAPackage("..entity..")
5    .or().areAnnotatedWith(Entity::class.java)
6    .and().areTopLevelClasses()
7    .should().haveSimpleNameEndingWith("Entity")
8    .andShould().resideInAPackage("..entity..")
9    .andShould().beAnnotatedWith(Entity::class.java)
1Class <...FooEntityKt> does not have simple name ending with 'Entity' in (FooEntity.kt:0)

Das liegt daran, dass sowohl eine FooEntity.class als auch eine FooEntityKt.class erzeugt wird.

Es gibt verschiedene Lösungen dafür. Zum einen kann man die Extension-Function in ein Companion Object packen oder man erstellt eine separate Datei für die Extension-Function:

1- entities
2  |- FooEntity
3  |- BarEntity
4  |- ExtensionFunctions
5  |- ...

Aber auch hier wird derselbe Fehler auftreten, da die Klasse FooEntityKt.class weiterhin erzeugt und im entities-Package platziert wird. Sie müsste also weiter extrahiert werden, was jedoch nicht mehr übersichtlich ist. Doch genau das wollen wir mit ArchUnit ja erreichen: bessere Lesbarkeit des Codes, mehr Einheitlichkeit und somit ein besseres Verständnis.

Die Lösung ist recht einfach, erfordert jedoch einige Überlegungen zur Vorgehensweise:

Im Fall von Entitäten könnt ihr den Test einfach in zwei Tests aufteilen:

1@ArchTest
2val `check entities by name`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Entity")
4    .and().areTopLevelClasses()
5    .should().resideInAPackage("..entity..")
6    .andShould().beAnnotatedWith(Entity::class.java)
7
8@ArchTest
9val `check entities by annotation`: ArchRule = classes()
10    .that().areAnnotatedWith(Entity::class.java)
11    .and().areTopLevelClasses()
12    .should().resideInAPackage("..entity..")
13    .andShould().haveSimpleNameEndingWith("Entity")

Jetzt wird die zusätzliche Klasse, die durch die Extension-Funktion erzeugt wird, nicht mehr als Fehler erkannt, da sie zwar weiterhin im Package liegt, aber weder mit Entity endet noch die Annotation @Entity besitzt.

Der „Nachteil“ hiervon ist, dass ihr nun eine Klasse im Package haben könnt, die FooEntity heißt, aber nicht mit @Entity annotiert ist und dennoch als korrekt angesehen wird.

Der Vorteil ist, dass ihr nun die Möglichkeit habt, innerhalb dieses Packages andere Hilfsklassen zu haben, die nur für die Entitäten da sind. Es befreit euch also ein wenig von der strengen Kopplung. Hier muss man also abwägen, welchen Kompromiss man eingehen will.

Aber was, wenn wir Klassen haben, die keine Annotation besitzen, wie es bei Ports der Fall ist?

1@ArchTest
2val `check ports`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Port")
4    .or().resideInAPackage(portsPackage)
5    .and().areTopLevelClasses()
6    .should().haveSimpleNameEndingWith("Port")
7    .andShould().resideInAPackage(portsPackage)
8    .andShould().beInterfaces()

Hier muss man sich überlegen, ob es erlaubt sein soll, dass im Ports-Package Klassen liegen, die keine Ports sind. Wenn man das vermeiden möchte, muss der Test verkleinert werden:

1@ArchTest
2val `check ports by name`: ArchRule = classes()
3    .that().haveSimpleNameEndingWith("Port")
4    .and().areTopLevelClasses()
5    .should().resideInAPackage(portsPackage)
6    .andShould().beInterfaces()

Liegt in dem Package nun aber ein Port, der FooPortFlasch heißt, wird das nicht als Fehler angesehen.

So muss man für jeden Fall separat entscheiden, ob es Sinn ergibt, das ganze Package sauber zu halten und nur deren Hauptklassen darin zuzulassen oder ob daneben noch weitere Klassen erlaubt sind. Im Falle der Ports haben wir uns dafür entschieden, dass wir hier strikt nur Ports zulassen. Bei den Entitäten haben wir es geöffnet, weil wir mit der Annotation ein weiteres Mittel haben, um mehr Eindeutigkeit zu erzielen.

Umgang mit leeren Prüffällen

Es kann Situationen geben, in denen eine Regel definiert werden soll, die im Moment noch nicht angewendet werden kann. Ein Beispiel hierfür wäre die Festlegung auf eine bestimmte Namenskonvention und Paketstruktur von Scheduler-Klassen.

Wenn jedoch noch keine Scheduler-Klasse im Code existiert, wirft ArchUnit eine Fehlermeldung:

...failed to check any classes. This means either that no classes have been passed to the rule at all, or that no classes passed to the rule matched the that() clause. To allow rules being evaluated without checking any classes you can either use ArchRule.allowEmptyShould(true) on a single rule or set the configuration property archRule.failOnEmptyShould = false to change the behavior globally.

Um diesen Fehler zu vermeiden, gibt es verschiedene Ansätze. Eine Möglichkeit wäre, den entsprechenden Test zu löschen oder auszukommentieren – was jedoch nicht optimal ist.

ArchUnit bietet stattdessen verschiedene Optionen an, die auch in der Fehlermeldung erwähnt werden.

Eine Möglichkeit besteht darin explizit anzugeben, dass kein Fehler geworfen werden soll, falls keine Klasse gefunden wird:

1ArchRule.allowEmptyShould(true)

Alternativ kann diese Einstellung auch global für alle Tests vorgenommen werden. Dazu erstellt man unter src/test/resources die Datei archunit.properties und fügt die folgende Zeile hinzu:

1archRule.failOnEmptyShould=false

Diese Vorgehensweise sollte jedoch mit Bedacht gewählt werden. Die Regel ist nicht ohne Grund standardmäßig aktiviert.

Angenommen, es existiert eine Klasse, die gegen eine Regel verstößt und im Package services liegt. Wenn dieses Package in service (ohne „s“) umbenannt wird, ohne die ArchUnit-Tests anzupassen, wird der ArchUnit-Test erfolgreich durchlaufen. Grund dafür ist, dass im Package services keine Klassen mehr gefunden werden, die gegen die Regel verstoßen. Der Test bleibt grün, weil schlichtweg keine Klassen mehr geprüft werden.

Refactoring: Die kleine Aufgabe, die es in sich hat

In einem neu gestarteten Projekt auf der "grünen Wiese" mag es einfach erscheinen, direkt alle ArchUnit-Regeln anzuwenden. Solche Projekte wird man in der Praxis jedoch nur selten vorfinden. Viele Projekte sind bereits weit fortgeschritten, möglicherweise schon in Produktion. Solche Projekte auf einen Schlag an alle neuen Regeln anzupassen, bedeutet unter Umständen einen enormen zeitlichen Aufwand – Zeit, die in den seltensten Fällen auf einmal zur Verfügung steht. Was wir brauchen, ist eine Möglichkeit, die Regeln zu definieren und sie dann schrittweise umzusetzen. Genau dafür bietet ArchUnit die Freeze-Funktion an.

Freeze

Der Freeze ist eine äußerst nützliche Funktion und möglicherweise die wichtigste, wenn es darum geht, ArchUnit in bestehende Projekte zu integrieren. Nachdem alle Tests geschrieben wurden, kann man die Tests, die man erst später fixen möchte, mit einem Freeze umschließen:

1@ArchTest
2val `check ports by name`: ArchRule = 
3freeze(
4    classes()
5        .that().haveSimpleNameEndingWith("Port")
6        .and().areTopLevelClasses()
7        .should().resideInAPackage(portsPackage)
8        .andShould().beInterfaces()
9)

Beim nächsten Durchlauf der Tests werden alle Regelverletzungen, die innerhalb dieses Tests auftreten, gespeichert und für zukünftige Tests ignoriert. Doch diese „Du kommst aus dem Gefängnis frei“ Karte erhält man nur einmal. Wenn in Zukunft eine Änderung am Code vorgenommen wird, die den Fehler berührt, wird ArchUnit den Fehler nicht mehr ignorieren. Dann muss dieser eine spezifische Fehler behoben werden – aber nur an dieser Stelle und nicht im gesamten Repository. Somit hilft ArchUnit dabei in kleinen iterativen Schritten diese Fehler zu beheben.

Store

Die Regelverstöße werden in einem Violation Store gespeichert. Damit dieser angelegt wird, müssen zunächst die archunit.properties entsprechend angepasst werden:

# store location
freeze.store.default.path=src/test/resources/frozen

# allow creation of a new violation store (default: false)
freeze.store.default.allowStoreCreation=true

Speicherung der Regeln

Jede Regel, die mit einem Freeze versehen ist, wird in der Datei stored.rules gespeichert, die im Ordner test/resources liegt und ihr wird eine eindeutigen ID (UUID) zugewiesen. Beispiel für stored.rules:

1#
2#Fri Jul 26 08:06:22 CEST 2024
3classes\ that\ have\ simple\ name\ ending\ with\ 'Port'\ or\ reside\ in\ a\ package\ 'de.codecentric.domain.port..'\ and\ are\ top\ level\ classes\ should\ have\ simple\ name\ ending\ with\ 'Port'\ and\ should\ reside\ in\ a\ package\ 'de.codecentric.domain.port..'\ and\ should\ be\ interfaces=ce02ea80-2508-4c9b-a4e6-7772b06bee83

Speicherung der Regelverletzungen

Für jede Regel wird ein Store mit den Regelverstößen angelegt. Diese Dateien erhalten als Namen eine UUID, die der jeweiligen Regel in stored.rules zugeordnet ist.

Beispiel für eine gespeicherte Regelverletzung:

1Class <de.codecentric.domain.port.outbound.TestPortClient> does not have simple name ending with 'Port' in (TestPortClient.kt:0)

Auf diese Weise kannst du ArchUnit auch in bestehenden Projekten einsetzen und den Code Schritt für Schritt refactoren.

Ausflug in die Hexagonale Architektur

Wenn man im Internet nach Möglichkeiten sucht, ArchUnit für komplexere Architekturen einzusetzen, stößt man schnell auf Vorschläge, dies mit Architectures.layeredArchitecture umzusetzen. Damit lassen sich bereits viele Aspekte abbilden. Einerseits können die Layer definiert werden, die für die Checks relevant sind, indem man über .consideringOnlyDependenciesInLayers() die einzelnen Layer spezifiziert. Dabei gibt man an, wie der Layer heißen soll und in welchem Package er sich befindet.

1.layer("inbound")
2.definedBy("..adapter.inbound..")

ArchUnit untersucht dann ausschließlich die angegebenen Packages, weiß jedoch noch nicht, in welcher Beziehung diese zueinander stehen. Nur weil ein Layer als „domain“ bezeichnet wird, hat das für ArchUnit noch keine Bedeutung – diese Benennung dient lediglich der besseren Lesbarkeit des Codes. Um ArchUnit mitzuteilen, dass beispielsweise der Domain-Layer gemäß der Hexagonalen Architektur keine Abhängigkeiten zu anderen Layern haben soll, können diese Beziehungen explizit definiert werden. Dies geschieht, indem man an die zuletzt definierten Layer weitere Anweisungen anhängt:

1.whereLayer("domain")
2.mayNotAccessAnyLayer()
3
4.whereLayer("port")
5.mayOnlyAccessLayers("domain")

Dies könnte dann so aussehen:

1@ArchTest
2val `check hexagonal architecture`: ArchRule = layeredArchitecture()
3        // define Layer
4        .consideringOnlyDependenciesInLayers()
5        .layer("inbound")
6        .definedBy("..adapter.inbound..")
7        .optionalLayer("usecase")
8        .definedBy("..application.usecase..")
9        ...
10        
11        // define Access
12        .whereLayer("inbound")
13        .mayOnlyAccessLayers("usecase", "domain")
14        .whereLayer("usecase")
15        .mayOnlyAccessLayers("domain")
16        ...
17
18@ArchTest
19val `inbound adapters are not dependent from each other`: ArchRule =
20        slices()
21            .matching("..adapter.inbound.(*)..")
22            .should()
23            .notDependOnEachOther()
24            .allowEmptyShould(true)
25}
26
27@ArchTest
28val `outbound adapter are not dependent from each other`: ArchRule =
29        slices()
30            .matching("..adapter.outbound.(*)..")
31            .should()
32            .notDependOnEachOther()
33            .allowEmptyShould(true)

Aber es geht noch einfacher! Tatsächlich bietet ArchUnit neben Architectures.layeredArchitecture auch die onionArchitecture an, die sich ebenfalls hervorragend für die Hexagonale Architektur nutzen lässt.

Diese bietet bereits vordefinierte Funktionen zur Definition der grundlegenden Bausteine der Hexagonalen Architektur. Um eine ähnliche Funktionalität wie im vorherigen Code zu erreichen, sind nur wenige Zeilen notwendig:

1@ArchTest
2val `hexagonal architecture is respected`: ArchRule = onionArchitecture()
3    [1].domainModels(domainModelPackage)
4    [2].domainServices(domainServicePackage, portsPackage, useCasePackage)
5    [3].applicationServices(applicationPackage)
6    [4].adapter("adapter", adapterPackage)
7    [5].withOptionalLayers(true)

In diesem ArchUnit-Test überprüfen wir, ob die hexagonale Architektur eingehalten wird, indem wir die Methode onionArchitecture() verwenden, die dabei hilft, architektonische Grenzen basierend auf dem Hexagonalen Architektur-Pattern durchzusetzen.

  1. domainModels(domainModelPackage): Hier werden die Domänenmodelle angegeben, die in der Regel Teil der Kerndomänenlogik sind und oft in einem domainPackage liegen, wenn man nach Domain-Driven Design (DDD) arbeitet.
  2. domainServices(domainServicePackage, portsPackage, useCasePackage): Dies umfasst die Domänenservices, die die Geschäftslogik der Anwendung bearbeiten. Diese sind ebenfalls Teil der Kerndomäne und befinden sich üblicherweise im domainPackage, zusammen mit den Domänenmodellen. Hierin sind ebenfalls die Ports und Use Cases enthalten.
  3. applicationServices(applicationPackage): Diese Services existieren außerhalb der Kerndomäne und sind aber keine Adapter. Sie können Konfigurationen, Utilities oder ähnliches beinhalten, die nicht direkt mit der Domänenschicht interagieren.
  4. adapter("adapter", adapterPackage): Dies definiert die Adapter, die die äußere Schicht des Hexagons bilden. Adapter können alles sein, was mit der Außenwelt interagiert, wie Controller oder Datenbankanbindungen.
  5. withOptionalLayers(true): Diese Option erlaubt es, dass zusätzliche Layer (Packages) neben den Kernschichten existieren dürfen. Das könnten Helper-Packages oder andere Komponenten sein, die nicht strikt in die Domänen- oder Adapterschichten passen.

Die stillen Stärken von ArchUnit

Neben den offensichtlichen Funktionen bietet ArchUnit eine Reihe von Vorteilen, die die Entwicklung unterstützen, aber nicht in der Dokumentation erwähnt werden und auch nicht direkt in den Testergebnissen auffallen. Dennoch gehören diese zu den wichtigsten Eigenschaften von ArchUnit.

Ein großer Vorteil ist, dass ihr euch intensiv mit der Architektur eures Projekts auseinandersetzen müsst. Ihr werdet als Team darüber nachdenken, wie Klassen benannt werden, welche Paketstruktur sinnvoll ist und wie das Ganze optimal auf eure Lösung zugeschnitten werden kann. Diese Überlegungen sollten idealerweise in einem ADR dokumentiert werden.

Die Zeit, die in diese Überlegungen investiert wird, zahlt sich später aus. ArchUnit stellt sicher, dass diese Entscheidungen eingehalten werden, ohne dass ihr ständig darauf achten müsst.

Ein weiterer Vorteil von ArchUnit ist, dass es euch ermöglicht, bekannte Architekturkonzepte wie Layer-Architektur oder Onion-Architektur direkt zu testen. Ihr müsst nicht sofort alle Details dieser Konzepte verstehen, sondern könnt euer Wissen Schritt für Schritt aufbauen. Anfangs zeigt ArchUnit möglicherweise Fehler an, etwa wenn Klassen in „falschen“ Packages liegen, die ihr noch nicht ganz nachvollziehen könnt. Mit der Zeit und etwas Recherche wird jedoch klar, warum diese Fehler auftreten. So können auch weniger erfahrene Entwickler von Anfang an eine saubere Architektur umsetzen.

It's a trap

Wie bei jedem Werkzeug in der Softwareentwicklung solltet ihr euch auch bei ArchUnit nicht blind darauf verlassen. Es kann zu False Negatives kommen oder noch schlimmer zu False Positives, die nicht sofort ins Auge fallen. Wie schon weiter oben beschrieben könnte das umbenennen von packages dazu führen, dass ArchUnit keine Klassen mehr in den Packages findet und somit auch keine Fehler.

Zudem ist es immer eine Frage, was als „richtig“ definiert wird. ArchUnit prüft zwar, ob in einer hexagonalen Architektur die Domänenschicht nicht auf äußere Schichten zugreift, aber ob eure Paketstruktur nach Technologie oder Fachlichkeit organisiert ist, bleibt eine andere Frage. Eine technisch korrekte Struktur kann architektonisch dennoch nicht optimal sein.

Ihr müsst euch daher immer bewusst sein, dass ihr die Eigentümer eures Codes seid und nicht das Werkzeug. „Ein Schraubenschlüssel hilft euch, eine Sechskantschraube (Hexagon) anzuziehen oder zu lösen. Wie fest ihr anzieht, bestimmt ihr. Dreht ihr zu wenig, wird es nicht fest, dreht ihr zu fest, reißt die Schraube.“

Fazit

ArchUnit ist ein mächtiges Werkzeug, das euch hilft, die Architektur eurer Software zu sichern und sauber zu halten. Es ermöglicht eine nahtlose Integration in den bestehenden Tech-Stack und sorgt dafür, dass Architekturentscheidungen frühzeitig überprüft werden können. Dadurch werden Fehler direkt in der Entwicklungsphase sichtbar und müssen nicht erst durch aufwändige Pipeline-Checks entdeckt werden.

Ein großer Pluspunkt ist die Flexibilität von ArchUnit. Es lässt sich sowohl in Projekten auf der „grünen Wiese“ als auch in bestehenden Projekten leicht einführen. Dank der Freeze-Funktion kann man schrittweise und iterativ bestehende Verstöße beheben, ohne den Entwicklungsprozess zu blockieren. Darüber hinaus fördert ArchUnit die Teamkultur, indem es Entwickler dazu anregt, über die Architektur nachzudenken und die getroffenen Entscheidungen durchzusetzen.

Zusätzlich bietet es die Möglichkeit, komplexe Architekturkonzepte wie die Hexagonale Architektur oder Onion-Architektur direkt zu testen und in den Code einzubetten. Dadurch wird der Übergang von Theorie zu Praxis erleichtert, und auch weniger erfahrene Entwickler können von Anfang an saubere und nachhaltige Architekturen umsetzen.

ArchUnit ist mehr als nur ein Test-Framework. Es wird zum Werkzeug, das hilft, die langfristige Wartbarkeit und Qualität des Codes sicherzustellen. Mit Bedacht eingesetzt, ist es ein starker Verbündeter, der euch dabei unterstützt, euren Code nicht nur technisch korrekt, sondern auch architektonisch sauber zu halten.

|

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.