Oftmals startet ein Softwareprojekt mit einer durchdachten Architektur und eindrucksvollen Architekturdiagrammen, um die Zusammensetzung und das Zusammenspiel der verschiedenen Komponenten des Systems zu veranschaulichen. Anfangs funktioniert alles reibungslos, doch mit zunehmender Größe des Projekts, steigender Komplexität der Anwendungsfälle und dem Ein- und Ausstieg von Entwicklern beginnen die Schwierigkeiten. Dieses Szenario dürfte vielen vertraut sein, die bereits in Softwareprojekten gearbeitet haben.

Plötzlich werden neue Funktionalitäten einfach hinzugefügt, ohne dass eine klare Struktur oder ein gemeinsames Verständnis der Architektur gewahrt bleibt. Die Architektur verliert an Flexibilität und Qualität.

Um solche Probleme zu vermeiden, ist der Einsatz von ArchUnit eine effektive Lösung. ArchUnit ist eine Testbibliothek für javabasierte Anwendungen, die es ermöglicht, die Codestruktur zu analysieren, Abhängigkeiten zwischen Komponenten zu überprüfen und sicherzustellen, dass die definierten Architekturrichtlinien eingehalten werden.

Angesichts dieser Herausforderungen und der Bedeutung einer effektiven Architektur haben wir uns dazu entschlossen, ArchUnit in einem unserer Projekt einzuführen und in Kombination den Fokus auf agile Architektur weiter zu stärken.

AAMA Spaghetti Code

Hintergrund und Arbeitsweise

In unserem Projekt legen wir bereits einen hohen Stellenwert auf agile Arbeitsweisen hinsichtlich der Architektur. Wir führen regelmäßige Architekturtermine durch, bei denen alle Entwickler proaktiv Themen vorbereiten und präsentieren können. Dieser Termin wird vom Entwicklerteam getrieben und verwaltet, um sicherzustellen, dass alle relevanten Aspekte der Architektur diskutiert und berücksichtigt werden. Durch diese regelmäßigen Treffen fördern wir eine offene Kommunikation und den Wissensaustausch über die Architektur des Projekts.

Des Weiteren arbeiten wir mit der Hexagonal Architektur und dem Domain-Driven-Design (DDD) . Die Hexagonal Architektur, auch bekannt als ‚Ports and Adapter Architecture‘ oder ‚Onion Architecture‘, legt den Fokus auf die klare Trennung der inneren Geschäftslogik von externen Schnittstellen und Infrastrukturdetails. Das Domain-Driven Design ermöglicht die Modellierung der Kernlogik eines Systems basierend auf Domänenkonzepten. Domänenkonzepte repräsentieren die verschiedenen Aspekte und Elemente einer bestimmten Fachdomäne, wie beispielsweise Produkte oder Bestellungen  in einem E-Commerce-System.

Unsere Architektur ist dabei modular aufgebaut, sodass eine fachlich zusammengehörige Einheit (DDD: ‚Bounded Context‘), wie z.B. ein Bestellungkontext oder ein Produktkontext, jeweils in eine eigene hexagonale Architektur eingebettet ist.

1
2
3
AAMA Hexagonale Architektur
1

Domänenlogik nach DDD

2

primäre Adapter  (‚Inbound‘)

3

sekundäre Adapter (‚Outbound‘)

Hexagonale Architektur

Im Verlauf des Projekts haben sich an einigen Stellen komplexe Strukturen entwickelt, die auf verschiedene Gründe zurückzuführen sind. Einerseits spielte mangelndes Wissen oder fehlende Erfahrung eine Rolle, da bestimmte architektonische Prinzipien nicht vollständig verstanden oder angewendet wurden. Andererseits wurde im Laufe der Zeit erkannt, dass einige Prinzipien in ihrer ursprünglichen Form nicht optimal auf die Anforderungen des Projekts zugeschnitten waren. Diese Prinzipien wurden im Verlauf des Projekts entwickelt und angepasst, um den Anforderungen hinsichtlich Größe und Komplexität des Projekts besser gerecht zu werden

Vorgehen

ArchUnit bietet einen umfangreiche API, die verschiedene Möglichkeiten bietet, um Architekturrichtlinien zu validieren. Dabei reicht das Spektrum von einfacher Regelsyntax zur prägnanten Spezifizierung von Architekturregeln bis hin zu komplexeren vordefinierten Regeln.

1
2
3
Architektur API
1
@ArchTest
static final LayeredArchitecture example = 
  layeredArchitecture()
    .consideringAllDependencies()
    .layer("Controller")
      .definedBy("..controller..")
    .layer("Service")
      .definedBy("..service..")
    .layer("Repository")
      .definedBy("..repository..")
    .whereLayer("Controller")
      .mayNotBeAccessedByAnyLayer()
    .whereLayer("Service")
      .mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Repository")
      .mayOnlyBeAccessedByLayers("Service");
2
@ArchTest
static final ArchRule exampleRule = 
    classes()
        .that()
        .resideInAPackage("..service..")
        .should()
        .onlyBeAccessed()
        .byAnyPackage("..controller..");
3
JavaClasses classes = 
     new ClassFileImporter()
        .importPackages("de.example.archunit");
classes
    .forEach(
       javaClass -> println(javaClass.getDirectDependenciesFromSelf())
    );

ArchUnit-API

Zu Beginn haben wir uns dafür entschieden, definierte PlantUML-Dateien einzusetzen, um die Architekturregeln automatisch zu validieren und den initialen Aufwand so gering wie möglich zu halten. Dieser Ansatz erwies sich als äußerst effizient und bot zudem den Vorteil einer visuellen Komponente, die das Verständnis und die Kommunikation innerhalb des Teams erleichterte.

Im weiteren Verlauf wurden die PlantUML-Diagramme regelmäßig überarbeitet, um sie den Diskussionen und Erkenntnissen aus den Architekturterminen anzupassen. Dies geschah insbesondere dann, wenn neue Kontexte oder Anforderungen auftraten, die eine Aktualisierung der Architektur erforderten. Durch diese iterative Anpassung konnten wir sicherstellen, dass die Diagramme stets den aktuellen Zustand und die gewünschten Strukturen unseres Projekts widerspiegeln.

Praktische Umsetzung und Integration

Bei der Implementierung von ArchUnit haben wir von der Integration mit JUnit 5 profitiert, das bereits in unserem Projekt im Einsatz ist. Im ersten Schritt haben wir uns auf drei zentrale Aspekte konzentriert:

  • Abhängigkeiten zwischen den Kontexten
  • Struktureller Aufbau innerhalb der Kontexte
  • Kommunikation zwischen den Kontexten

      Abhängigkeiten zwischen den Kontexten

      @AnalyzeClasses(packages = BASE_PACKAGE)
      public class InterContextRulesTest {
      
          @ArchTest
          public void testContextDependencies(final JavaClasses importedClasses) {
              final var contextDependencyDiagram = getClass().getResource("/archunit/puml/context-dependencies-component.puml");
      
              assert contextDependencyDiagram != null;
      
              ArchRule testContextDependencies = classes()
                  .should(adhereToPlantUmlDiagram(contextDependencyDiagram, consideringOnlyDependenciesInDiagram()))
                  .as(CONTEXT_DEPENDENCIES.getDescription());
      
              testContextDependencies.check(importedClasses);
          }
      }
      

      ArchUnit erwies sich als äußerst wertvolles Werkzeug zur Aufdeckung der Kontextabhängigkeiten in unserem Projekt. Es ermöglichte uns, bisher unzureichend dokumentierte oder veraltete Abhängigkeiten aufzudecken. Besonders hilfreich war die Entdeckung von zyklischen Abhängigkeiten, die zuvor unbemerkt geblieben waren. Zyklische Abhängigkeiten sind problematisch, da sie zu einer erhöhten Kopplung zwischen den Modulen führen und die Wartbarkeit und Erweiterbarkeit des Systems erschweren können.

      Ein konkretes Szenario während der Analyse ergab, dass es sinnvoller war, fachspezifische Logik aus der Zahlungsabwicklung in den Kontext des Vertragsmanagements zu integrieren. Dieser Schritt wurde unternommen, um die ursprüngliche zyklische Abhängigkeit zu lösen.

      Struktureller Aufbau innerhalb der Kontexte

      Hierbei ging es darum, sicherzustellen, dass die Ports-und-Adapter-Architektur innerhalb jedes Kontextes konsistent und gemäß den definierten Regeln umgesetzt wurde.

      Im Rahmen unserer Analyse haben wir festgestellt, dass in einigen Fällen Verstöße gegen die interne Struktur der Kontexte vorlagen. Ein konkretes Beispiel dafür war die direkte Implementierung von Adaptern in den Domänenservices. Anstatt über die definierten Ports und Schnittstellen zu kommunizieren, griffen die Domänenservices direkt auf konkrete Adapterklassen zu. Diese Verstöße führten zu einer unerwünschten Kopplung zwischen den Schichten und einer Verletzung der Ports-und-Adapter-Architektur.

      @AnalyzeClasses(packages = VERTRAG)
      public class IntraContextRulesTest {
      
          @ArchTest
          ArchRule testVertragDependencies = adhereToHexagonalArchitecturePumlDiagram(VERTRAG);
      
          {other contexts ....}
      
          ArchRule adhereToHexagonalArchitecturePumlDiagram(String boundedContext) {
      
              final var plantUmlDiagram = getClass().getResource("/archunit/puml/hexagonal-architecture-component.puml");
      
              assert plantUmlDiagram != null;
      
              return classes().that()
                  .resideInAPackage(boundedContext + "..")
                  .should(adhereToPlantUmlDiagram(plantUmlDiagram, consideringOnlyDependenciesInAnyPackage(boundedContext + ".."))
                      // configuration is used everywhere
                      .ignoreDependenciesWithTarget(resideInAPackage(CONFIGURATION.getSliceName()))
                      // exceptions are used everywhere
                      .ignoreDependenciesWithTarget(resideInAPackage(EXCEPTIONS.getSliceName()))
                      // domain objects are used everywhere
                      .ignoreDependenciesWithTarget(
                          resideInAPackage(CORE_DOMAIN.getSliceName())));
          }
      }
      

      Kommunikation zwischen den Kontexten

      @AnalyzeClasses(packages = BASE_PACKAGE)
      public class InterContextRulesTest {
      
          @ArchTest
          ArchRule testInterContextCommunication =
              slices()
                  .assignedFrom(BOUNDED_CONTEXTS)
                  .should(onlyCommunicateViaDomainEvents);
      
          private static ArchCondition onlyCommunicateViaDomainEventsOrOutboundInport = new ArchCondition<>("only communicate via Domain Events or Inbound/Outbound") {
              @Override
              public void check(Slice slice, ConditionEvents events) {
                  List sliceDependencies = slice.getDependenciesToSelf().stream()
                      .filter(d -> {
                          // Ignore Dependency to other Packages
                          if (d.getOriginClass().isAssignableTo(resideInAnyPackage(OTHER_PACKAGES.stream().map(p -> p + "..").toArray(String[]::new)))) {
                              return false;
                          } else {
                              // Only allow Dependencies from Domain Event Structure
                              return !(d.getTargetClass().isAssignableTo(resideInAPackage(CORE_DOMAIN_EVENT.getSliceName())) &&
                                  d.getOriginClass().isAssignableTo(resideInAPackage(INBOUND_EVENT.getSliceName()))
                                  // Only allow Dependencies from Outbound to Inport Structure
                                  || d.getTargetClass().isAssignableTo(resideInAPackage(CORE_INPORT.getSliceName())) &&
                                  d.getOriginClass().isAssignableTo(resideInAPackage(OUTBOUND.getSliceName())));
                          }
                      }).collect(toList());
      
                  sliceDependencies.forEach(sliceDependency ->
                      events.add(SimpleConditionEvent.violated(slice, String.format(sliceDependency.getDescription()))));
              }
          };
      }
      

      Darüber hinaus haben wir verschiedene Regeln definiert, wie die Kontexte miteinander kommunizieren sollen, um Interaktionen und Abhängigkeiten zwischen den Kontexten aufzudecken. Dadurch konnten wir unerwünschte Kommunikationswege und unnötige Kopplungen erkennen.

      Ein konkretes Beispiel dafür war, dass die Domänenobjekte der Lieferantenverwaltung direkt auf die Domänenobjekte der Kundenverwaltung zugegriffen haben. Statt die Kommunikation über klar definierte Schnittstellen und Ports zu ermöglichen, wurde die direkte Interaktion zwischen den beiden Kontexten hergestellt. Diese Verstöße führten zu einer unerwünschten Abhängigkeit und einer Verletzung unserer Architekturprinzipien, die eine strikte Trennung der Domänenkontexte vorsehen.

      Integration

      Um die ArchUnit-Tests nahtlos in den Entwicklungsprozess zu integrieren, sind sie in die automatisierte Build- und Testpipeline eingebunden. Auf diese Weise wird die Architekturüberprüfung bei jedem Build-Vorgang automatisch durchgeführt.

      Ein wichtiger Schritt bei der Integration besteht darin, den ArchUnit Violation-Store einzurichten, der dazu dient, Verstöße gegen die Architekturregeln zu erfassen und zu verwalten. Bei Einführung der Regeln stellten wir fest, dass es eine beträchtliche Anzahl von Verstößen gab, die nicht alle sofort behoben werden konnten. Der einzige Weg, mit solch umfangreichen Verstößen umzugehen, besteht darin, einen iterativen Ansatz zu etablieren, der das weitere Verschlechtern des Codebestands verhindert. Der Store erlaubt es uns, initial alle Architekturverstöße die bereits bestehen zu erfassen. Die Einbindung des ArchUnit Stores bietet zudem den Vorteil, dass erfassten Verstöße gut mit Versionierungssystemen synchronisiert werden können.

      1
      2
      3
      ArchUnit Violation Store
      1

      Initialer Durchlauf

      2

      Beim ersten Durchlauf werden alle Verstöße gegen diese Regel als aktueller Zustand in normalen Text-Dateien gespeichert.

      3

      In den nachfolgenden Durchläufen führen neu aufgedeckte Verstöße zu einem fehlschlagendem Build und der Store mit den behobenen Verstößen wird entsprechend aktualisiert.

      ArchUnit Violation Store

      Method <de.example.archunit.dokumente.core.domain.DokumentService.find(DokumentMetadata)> calls method <de.example.archunit.zahlungsabwicklung.core.domain.KontoService.find(KundeId))> in (DokumentService.java:86)

      Ergebnisse

      Herausforderungen

      Trotz des Violation-Stores und der Möglichkeit, neue Verstöße zu vermeiden, traten während einer Releasephase Schwierigkeiten auf, die unseren Entwicklungsprozess beeinträchtigten. Die fest verankerten Strukturen erschwerten das Hinzufügen neuer Funktionen und das Beheben von Fehlern, da weitere Verstöße entstanden, die zeitaufwändig zu beheben gewesen wären. Als Folge davon wurden einige Regeln deaktiviert.

      Nichtsdestotrotz konnten wir trotz der genannten Herausforderungen mithilfe der im ArchUnit Store festgehaltenen Verstöße bereits erste Analysen durchführen und Maßnahmen zur Behebung bestimmter Verstöße definieren.

      Erkenntnisse

      • ArchUnit war auch dann hilfreich, wenn die Regeln nicht aktiviert waren. Die dokumentierten Verstöße bieten eine gute Grundlage zur Analyse.
      • Eine mögliche Empfehlung wäre, ArchUnit nicht von Anfang an scharfzuschalten, insbesondere während einer Release-Phase. Entwickler könnten sich durch zusätzliches Tooling möglicherweise überfordert fühlen.

      ArchUnit Best Practices

      • KISS (Keep It Simple, Stupid): Es ist ratsam, kleine Regeln zu definieren, anstatt komplexe Bedingungen und Prädikate zu verwenden. Eine klare und aufgeteilte Teststruktur ermöglicht eine bessere Fehlerverfolgung und -behebung.
      • Das Rad nicht neu erfinden: ArchUnit bietet eine umfangreiche Funktionalität, die genutzt werden sollte. Es ist nicht unbedingt erforderlich, eigene komplexe Lösungen zu entwickeln, wenn ArchUnit bereits entsprechende Funktionen bereitstellt.
      • Reflexion der Code-Struktur: Wenn vorgefertigte Regeln nicht ausreichend passen, um die Architekturprinzipien angemessen zu testen, könnte dies ein Hinweis auf eine schlechte Strukturierung des Codes auf Paketebene sein. Eine gut strukturierte Architektur erleichtert die Verwendung von ArchUnit.

      Bei der Verwendung von ArchUnit ist es wichtig, sich bewusst zu sein, dass es sich lediglich um ein Tool handelt. Es liefert wertvolle Hinweise auf potenzielle Verstöße gegen Architekturprinzipien, doch es ist nicht das alleinige Maß aller Dinge. Es ist essentiell, die Ergebnisse von ArchUnit nicht als absolut zu betrachten und nicht voreilig Schlüsse zu ziehen. Das Vorhandensein eines Verstoßes bedeutet nicht zwangsläufig, dass die Architektur an dieser Stelle fehlerhaft oder schlecht ist. Stattdessen sollte eine differenzierte Betrachtung und kontextbezogene Bewertung angestrebt werden, um die Gründe für den Verstoß zu verstehen und mögliche Lösungsansätze zu entwickeln.

      Darüber hinaus ist es wichtig zu betonen, dass Architektur ein kontinuierlicher Entwicklungsprozess ist. ArchUnit als Tool für die Architekturanalyse und -validierung entwickelt sich ebenfalls weiter, insbesondere im Kontext agiler Architekturpraktiken. Es ist ratsam, die Anwendung von ArchUnit in den agilen Entwicklungsprozess zu integrieren und regelmäßig zu überprüfen, ob die verwendeten Regeln und Konfigurationen noch den aktuellen Anforderungen und Zielen entsprechen.

      Next Steps

      • Weitere Analyse vorhandener Verstöße: Eine detaillierte Untersuchung der bestehenden Verstöße wird durchgeführt, um ihre Ursachen zu ergründen und gegebenenfalls geeignete priorisierte Maßnahmen zur Behebung einzuleiten.
      • Verfeinerung der Regeln und fortlaufende Verbesserung: Wir streben kontinuierliche Verbesserungen an, indem wir regelmäßig Feedback einholen, Erfahrungen austauschen und unsere Architektur weiterentwickeln.
      • Gemeinsames Verständnis und Mitgestaltung: Ein entscheidender Aspekt ist, dass alle Teammitglieder die definierten Regeln verstehen. Es sollte ein offener Austausch stattfinden, bei dem jeder die Möglichkeit hat, Anpassungen an den Regeln vorzuschlagen und diese gemeinsam zu diskutieren. Dies fördert ein kollektives Verantwortungsgefühl für die Architekturqualität.

      Nimm gerne Kontakt zu uns auf!

      Ich hoffe die Seite konnte Dir einen Einblick darüber geben, wie man ArchUnit in agilen Softwareprojekten nutzen kann.

      Du willst mehr darüber erfahren wie wir Architektur gestalten und leben ?  Informiere dich gerne auf unsere Homepage!

      Mattea Allgeier, Junior Consultant Architecture esentri AG

      Mattea Allgeier
      Junior Consultant Architecture