Spring Modulith und Axon: Divide et impera
Spring Modulith und Axon: Divide et impera
Die Modularisierung von Anwendungen ist heutzutage in aller Munde. Liegt es nur daran, dass die Zahl der monolithischen Anwendungen – oft als Spaghetticode bezeichnet – stetig wächst, oder gibt es mittlerweile bessere Architekturansätze und Frameworks, um die Modularisierung zu fördern?
In der modernen Softwareentwicklung ist die Modularisierung von Anwendungen ein zentraler Aspekt, um deren Wartbarkeit, Erweiterbarkeit und Skalierbarkeit zu gewährleisten. Seit einigen Jahren dominiert dabei der Trend zu Microservices, die die Anwendungen in kleine, möglichst unabhängige Einheiten aufteilen. Diese Architektur verspricht eine größere Flexibilität und Unabhängigkeit der einzelnen Komponenten. Doch trotz der theoretischen Vorteile zeigt die Praxis oft ein anderes Bild: Viele Unternehmen stolpern über die Herausforderungen, die mit der Implementierung von Microservices einhergehen, und landen nicht selten bei dem, was als „verteilter Monolith“ bezeichnet wird. Aus diesem Grund wenden sich immer mehr Entwickler modularen Architekturansätzen zu, insbesondere den sogenannten Modulithen (Abkürzung für modularer Monolith).
Software nimmt einen immensen Stellenwert in der gesamtwirtschaftlichen Wertschöpfung ein. Technologischer Fortschritt einerseits, aber auch hoher Wettbewerbsdruck und hohe Geschäftsdynamik aufgrund wechselnder Marktbedingungen andererseits erhöhen den Druck auf die Softwareentwicklung. Gefragt ist hohe Qualität bei gleichzeitiger schneller Anpassbarkeit.
Modulare Architekturen haben hier eine Reihe von Pluspunkten. Zu den wesentlichen technischen Vorteilen zählen:
Bessere Wartbarkeit, höhere Wiederverwendbarkeit und Anpassbarkeit: Durch die Aufteilung der Software in Module mit klar definierten Verantwortlichkeiten wird der Code verständlicher und besser wartbar. Änderungen an einem Modul haben minimalen oder keinen Einfluss auf andere Module in Form von Seiteneffekten. Modulare Komponenten können leichter wiederverwendet oder angepasst werden.
Skalierbarkeit und technologische Flexibilität: Module können im Sinne einer effizienteren Ressourcennutzung unabhängig voneinander skaliert werden. Zudem lassen sich verschiedene Module mit Hilfe unterschiedlicher, passend für den jeweiligen Anwendungsfall ausgewählter Technologien entwickeln, was der Flexibilität zugutekommt.
Trennung von Geschäftslogik und Technologie: Die Trennung von fachlichem (Geschäftslogik) und technischem Code verbessert sowohl die Verständlichkeit und Wartbarkeit des Systems als auch die Robustheit und Testbarkeit.
Als Ausgangspunkt für die Modularisierung gilt häufig der Programmcode einer Anwendung. Hierzu können Java Packages verwendet werden. Ein üblicher Schnitt des Codes orientiert sich dabei an den Fachlichkeiten. Jedes Modul bietet ein API, das von anderen Modulen konsumiert und benutzt werden kann. Zugriffe auf Implementierungsdetails eines Moduls, zum Beispiel direkt auf die Persistenz eines anderen Moduls, sollten nicht stattfinden. So wird das Prinzip der Single Responsibility gefördert. Ein Modul bietet eine bestimmte Funktion an, die Implementierungsdetails obliegen aber dem Modul selbst. Auch die Verwendung unterschiedlicher Technologien in verschiedenen Modulen wird so ermöglicht. Jedes Modul kann für seinen Anwendungsfall optimiert, unabhängig erweitert oder restrukturiert werden. Dadurch, dass die Module ausschließlich über die definierten Schnittstellen kommunizieren, lässt sich eine losere Kopplung der Fachlichkeiten erreichen.
Betrachten wir eine Beispielanwendung, in der Benutzer Feed-Einträge veröffentlichen können. Die Anwendung bietet also Funktionalitäten rund um das Benutzerprofil, aber auch rund um die Feed-Einträge. Daher sollen diese in unterschiedlichen Packages gebündelt werden. Eine beispielhafte Package-Struktur ist in Listing 1 dargestellt.
Listing 1
de.dxfrontiers.example/
├─ feed/
│ ├─ persistence/
│ │ ├─ FeedEntryEntity.java
│ │ ├─ FeedEntryRepository.java
│ ├─ FeedService.java
│
├─ user/
│ ├─ persistence/
│ │ ├─ UserEntity.java
│ │ ├─ UserRepository.java
│ ├─ UserService.java
│
├─ messaging/
│ ├─ OutboundMessaging.java
│ ├─ message/
│ │ ├─ FeedEntryPublishedMessage.java
│
├─ FeedApplication.java
Jedes der beiden Packages user (Benutzer) und feed bietet dabei die Service-Klasse als API an, über das die Funktionalität des Moduls genutzt werden kann. Die Implementierungsdetails – genutzte Persistenztechnologie, Struktur der Persistenz, interne Implementierungen – sollten dabei nur innerhalb des Packages oder entsprechender Sub-Packages nutzbar sein. Lediglich das API kann von anderen Modulen genutzt werden.
Ein weiteres Package in Listing 1 ist messaging. Anders als die Packages für Benutzer und Feeds ist dieses nicht entlang einer Fachlichkeit geschnitten, sondern adressiert einen querschnittlichen Aspekt. Das ist dann sinnvoll, wenn etwa ein technisches Problem unabhängig von der Fachlichkeit übergreifend durch ein einheitliches API und eine einheitliche Implementierung gelöst werden kann. Das Messaging ist hierfür oft ein sehr gutes Beispiel, da die eigentliche Entscheidung, dass eine bestimmte Nachricht versandt werden soll, einfach aus dem fachlichen Code aufgerufen werden kann. Die technischen Details wie die Struktur der Nachricht, die zu verwendende Messaging-Technologie und Topics sollten jedoch vor dem fachlichen Code verborgen bleiben.
Listing 2 zeigt beispielhaft eine Methode aus der FeedService-Klasse, um einen neuen Feed-Eintrag (FeedEntry) zu veröffentlichen.
Listing 2
@Transactional
public void publishFeedEntry(
UUID userId,
String category,
String entry) {
UserEntity user = userRepository.findByUserId(userId);
UUID feedEntryId = UUID.randomUUID();
Instant publishedAt = Instant.now();
feedEntryRepository.save(
new FeedEntryEntity(
feedEntryId,
userId,
category,
entry,
publishedAt));
outboundMessaging.publish(
new FeedEntryPublishedMessage(
feedEntryId,
userId,
category,
entry,
publishedAt));
user.setLastUpdatedAt(publishedAt);
userRepository.save(user);
}
Der Code aus Listing 2 lädt dabei zunächst den Benutzer aus der Persistenz, um zu validieren, dass der übergebene Benutzer existiert. Anschließend wird ein neuer Feed-Eintrag angelegt und gespeichert. Dann wird über einen Message-Bus die Nachricht veröffentlicht, dass ein neuer FeedEntry angelegt wurde. Zuletzt wird der Zeitstempel der letzten Aktivität des Benutzers aktualisiert.
Der Code aus Listing 2 weist allerdings, obwohl er funktionsfähig ist, einige Probleme hinsichtlich der Modularisierung auf. Deren Ziel ist es, alle Implementierungsdetails in einem Modul zu verbergen und ein dediziertes API zur Verfügung zu stellen. Dieses API ist oftmals die Service-Klasse. Der FeedService im Listing verwendet jedoch nicht den UserService, um mit dem User-Modul zu interagieren, sondern greift direkt auf dessen Persistenz zu. Auch die Struktur der veröffentlichten Nachricht findet sich im eigentlich fachlich orientieren Code wieder und ist nicht im Messaging-Modul gekapselt.
Auch wenn diese Verstöße gegen die Modularisierung bei einer gründlichen Review auffallen, bedeutet es dennoch manuellen Aufwand, sie aufzufinden. Gerade in komplexerem Code können solche Probleme bei manueller Überprüfung untergehen. Ziel sollte es daher sein, die Modularisierung automatisiert sicherzustellen. Java als Sprache bietet mit der Visibility package-private zwar eine Möglichkeit an, Klassen nur innerhalb desselben Packages nutzbar zu machen, jedoch verhindert dieses Vorgehen auch jegliche Strukturierung in Form von Sub-Packages, was gerade bei größeren Modulen die Übersichtlichkeit innerhalb des Moduls behindert.
Diese Lücke schließt zum Beispiel ArchUnit [1], indem es die Struktur des Codes und Zugriffe auf bestimmte Klassen in speziellen Tests analysiert und gegen Regeln abgleicht. Spring Modulith [2] vereinfacht die Integration solcher Verifikationstests in Spring-Boot-Anwendungen. Gleichzeitig bringt Spring Modulith eine einfache Standardkonfiguration mit, die leicht zu verstehen ist und ohne zusätzlichen Konfigurationsaufwand für viele Module bereits eine sehr gute Strukturierung erlaubt. In der Standardkonfiguration sind Zugriffe auf andere Top-Level-Packages nur dann erlaubt, wenn der Zugriff auf eine Klasse auf oberster Ebene im anderen Modul stattfindet. In unserem Anwendungsfall kann zum Beispiel aus dem Package feed nur auf den UserService aus dem Package user zugegriffen werden, da dieser auf oberster Ebene in einem anderen Modul liegt. Zugriffe auf alle anderen Klassen im User-Modul werden von den Verifikationstests als Verstoß erkannt. Da der UserService als API zum User-Modul dienen soll, alle anderen Klassen jedoch Implementierungsdetails darstellen, die außerhalb des User-Moduls nicht bekannt sein sollen, erreichen wir unser Ziel.
Einen Verifikationstest zeigt der folgende Code, die FeedApplication-Klasse ist dabei die Klasse mit der main-Methode, und der Spring-Boot-typischen Annotation @SpringBootApplication.
@Test
public void isModularized() {
ApplicationModules.of(FeedApplication.class).verify();
}
Führt man diesen Test aus, erkennt er auch genau die im Text genannten Verstöße. Ein Ausschnitt aus der Konsolenausgabe des Tests ist in Listing 3 dargestellt.
Listing 3
Violations:
- Module 'feed' depends on non-exposed type UserRepository within module 'user'!
...
- Module 'feed' depends on non-exposed type UserEntity within module 'user'!
...
- Module 'feed' depends on non-exposed type FeedEntryPublishedMessage within module 'messaging'!
Aufgeführt werden jeweils die Zugriffe auf Klassen, die nicht aus dem API des anderen Packages stammen, insbesondere die Persistenzschicht des User- und die Nachrichtenklasse des Messaging-Moduls. Diese Fehler sind einfach zu korrigieren, indem das API der jeweiligen Module passende Methoden anbietet. Für das User-Modul wird eine Möglichkeit benötigt, die Existenz eines Users zu prüfen sowie die letzte Aktivität des Benutzers zu setzen. Das Messaging-Modul muss ein API anbieten, um Informationen über neue Feed-Einträge zu veröffentlichen, anstatt eines generischen API, um Nachrichten zu veröffentlichen. Wie genau die Nachricht hierfür aufgebaut sein muss, liegt dann in der Verantwortung des Messaging-Moduls.
Die dahingehend angepasste Implementierung des FeedService ist in Listing 4 zu sehen.
Listing 4
@Transactional
public void publishFeedEntry(
UUID userId,
String category,
String entry) {
if (!userService.isValidUser(userId))
throw new RuntimeException("invalid user");
UUID feedEntryId = UUID.randomUUID();
Instant publishedAt = Instant.now();
feedEntryRepository.save(
new FeedEntryEntity(
feedEntryId,
userId,
category,
entry,
publishedAt));
outboundMessaging.publishFeedEntryPublishedMessage(
feedEntryId,
userId,
category,
entry,
publishedAt);
userService.updateLastUserActivity(userId, publishedAt);
}
Die Implementierungsdetails der einzelnen Module sind jetzt zwar voreinander verborgen, doch sind die Module über Dependency Injection...