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).
Warum modulare Architekturen?
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.

LUST AUF NOCH MEHR ARCHITEKTUR & MICROSERVICES-TRENDS?
Entdecke Workshops vom 30. Juni - 2. Juli 2025
Modularisierung auf Programmcode-Ebene
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);
}
Herausforderungen in CRUD-basierten Anwendungen
Die Implementierungsdetails der einzelnen Module sind jetzt zwar voreinander verborgen, doch sind die Module über Dependency Injection sehr eng miteinander gekoppelt. Hierbei wird deutlich, dass die Modularisierung von auf CRUD (Create, Read, Update, Delete) basierenden Anwendungen eine besondere Herausforderung darstellt. Abbildung 1 verdeutlicht diese Verflechtung, die oftmals in Spaghetticode mündet, anhand des chronologischen Gesamtablaufs der Operation.

-
Ein REST-API-Controller nimmt Anfragen zur Nachrichtenerstellung entgegen. Diese sind einem registrierten Benutzer zugeordnet und werden an die Geschäftslogik des Feed-Moduls weitergeleitet.
-
Die für die Verarbeitung der Nachrichtenerstellung benötigten Daten werden aus der Datenbank ausgelesen und geprüft. Dadurch wird z. B. sichergestellt, dass der entsprechende Nachrichtenkanal bereits existiert.
-
Die Nachricht wird erstellt und in einem geeigneten Format an einen Nachrichtenbroker übergeben, um Umsystemen die Möglichkeit zu geben, darauf entsprechend zu reagieren.
-
Die neu erstellte Nachricht wird schließlich in der Datenbank gespeichert.
-
Das UserProfile-Modul wird informiert, dass vom entsprechenden Benutzer eine neue Nachricht erstellt wurde.
-
Die Geschäftslogik des UserProfile-Moduls lädt daraufhin die Benutzerdaten aus der Datenbank, um den Zeitpunkt der letzten Benutzeraktivität zu aktualisieren. Andernfalls würden Benutzer nach einer gewissen Zeit der Inaktivität automatisch aus dem System entfernt.
-
Es wird eine Ausgangsnachricht an den Nachrichtenbroker übergeben, um andere Systeme darüber zu informieren.
-
Anschließend wird der Zeitpunkt der letzten Benutzeraktivität in der Datenbank gespeichert.
-
Schließlich wird die erfolgreiche Ausführung der Operation über das REST API an den Aufrufer quittiert.
Das Anwendungsbeispiel zeigt verschiedene Arten der Kopplung auf, die der Modularisierung entgegenwirken. Dazu zählen im Wesentlichen:
-
Kopplung auf Ebene des Programmcodes und des Datenmodells: CRUD-Anwendungen haben oft stark verflochtene Codebasen, in denen Geschäftslogik über verschiedene Schichten und Module hinweg verteilt ist. Es fehlen klare Abgrenzungen der Verantwortlichkeiten oder schlichtweg geeignete Möglichkeiten der Erweiterbarkeit. So ist in unserem Beispiel die Mitteilung an das UserProfile-Modul über die Benutzeraktivität innerhalb des Feed-Moduls durch einen expliziten Aufruf – also eine direkte Kopplung bzw. Abhängigkeit zwischen den Modulen – umgesetzt. Das wird häufig noch durch die Verknüpfung der Datenmodelle in relationalen Datenbanken verschärft, und zwar dadurch, dass viele Module die gleichen Datenstrukturen nutzen. Änderungen an diesen Strukturen können sich somit schnell auf viele verschiedene Module auswirken.
-
Technologische und transaktionale Kopplung: Die Integration zentraler Ressourcen wie Datenbanken, Nachrichtenbroker oder Caches erfolgt in CRUD-Anwendungen oft direkt innerhalb der Geschäftslogik. Zwar wird versucht, diese Kopplung durch entsprechende Schichten technologisch zu abstrahieren, jedoch kann die Geschäftslogik dadurch nicht von der Verantwortung entbunden werden, zu wissen, welche Schichten und Umsysteme aufzurufen sind. Verschärft wird diese Kopplung zudem durch die Anforderungen an die schichten- und modulübergreifende Datenkonsistenz. Die dafür genutzten technischen Transaktionen sind durch entsprechende Frameworks zwar ebenfalls wegabstrahiert, die Aufrufreihenfolge obliegt jedoch nach wie vor der Geschäftslogik. So ist es in unserem Beispiel eben nicht egal, ob der Nachrichtenbroker vor oder nach der Speicherung einer Änderung in der Datenbank informiert wird. Je nach Reihenfolge ergeben sich z. B. komplett unterschiedliche Zustellgarantien für ausgehende Nachrichten. Diese Reihenfolgen korrekt einzuhalten, ist Aufgabe der Geschäftslogik und kann im Fall modulübergreifender Transaktionen – wie in unserem Beispiel der Nachrichtenversand aus unterschiedlichen Modulen – schnell zur Herausforderung werden.
Command Query Responsibility Segregation
Command Query Responsibility Segregation (CQRS) ist ein Architekturmuster, das die Trennung der Verantwortung für Schreib- und Leseoperationen in einer Anwendung propagiert. Die Grundidee ist, dass Operationen, die Daten ändern (Commands), von Operationen, die Daten lesen (Queries), getrennt werden.
CQRS-basierte Anwendungen
Als vorherrschende Motivation für den Einsatz CQRS-basierter Anwendungen wird in aller Regel zusätzlich noch die bessere Skalierbarkeit angeführt. Schreib- und Leseoperationen können unabhängig voneinander skaliert werden, was sich in vielen Anwendungen – unabhängig von der geringeren Kopplung – positiv auf die Performanz auswirkt, da die Lese- die Schreibzugriffe sehr oft dominieren.
Diese Trennung wird durch die Einführung verschiedener Modelle erreicht:
-
Schreibmodell (Command Model): enthält sämtliche für die Geschäftslogik relevanten Daten, um Änderungen am System vorzunehmen bzw. vorab zu prüfen, ob diese zulässig sind, z. B. für die Erstellung neuer Nachrichten in einem Feed.
-
Lesemodell (Query Model): enthält projizierte Daten, die den Ist-Zustand des Systems abbilden, z. B. die Liste aller erfolgreich erstellten Nachrichten in einem Feed. Diese Daten können speziell auf die Abfrage zugeschnitten und optimiert sein, da sie redundant zum Schreibmodell gepflegt werden. Das impliziert, dass es auch mehrere Lesemodelle für unterschiedliche Abfragen geben kann.
Auf unser Anwendungsbeispiel bezogen lassen sich sowohl die Nachrichtenerstellung im Feed-Modul als auch die Benutzeraktualisierung im UserProfile-Modul als Commands abbilden. Die Geschäftslogik innerhalb der jeweiligen Module entscheidet dann anhand des jeweiligen Schreibmodells, das etwa aus der Datenbank geladen werden kann, ob der mit dem Command implizierte Änderungswunsch durchgeführt werden soll oder nicht. Im Fall einer erfolgreichen Command-Verarbeitung muss die daraus resultierende Änderung im Command Model – für zukünftige Commands – gespeichert und anschließend in die Lesemodelle überführt werden.
Im gezeigten Beispiel lassen sich zwei unterschiedliche Lesemodelle identifizieren: die Datenhaltung für eine Abfrage zuvor erfasster Nachrichten und Feeds über das REST API sowie der Nachrichtenversand an den Nachrichtenbroker. Letzteres mag auf den ersten Blick seltsam anmuten, da man bei Lesemodellen eher an persistente Datenhaltung denkt. Letztlich kann aber jegliche Projektion vollzogener Änderungen im System als Lesemodell verstanden werden, also auch die auf ein oder mehrere Commands folgenden Ausgangsnachrichten.
Die durch CQRS erzwungene Trennung von Lese- und Schreibmodellen bedingt typischerweise einen gewissen Mehraufwand in der Umsetzung derartig aufgebauter Systeme, fördert aber deren Modularisierung. Betrachten wir die zuvor erwähnten negativen Kopplungen von CRUD-Anwendungen, so lassen sich daraus für CQRS-Architekturen die folgenden Vorteile ableiten:
-
Erstens gibt es durch die Trennung von ändernden und lesenden Operationen klarere Verantwortlichkeiten und Strukturen im Programmcode. Die Geschäftslogik eines Moduls fokussiert sich auf die Verarbeitung von Commands und die dafür notwendige Vorabprüfung der Geschäftsregeln unter Zuhilfenahme des jeweiligen Schreibmodells. Durch Verlagerung der Leseoperationen auf dedizierte Lesemodelle verhindert man zudem die unnötige Kopplung auf Datenstrukturebene. Schreibmodelle sind in gewisser Weise vollständig durch ihre Geschäftslogik gekapselt. Die Übergabe von Daten an Dritte – also auch an andere Module – geschieht ausschließlich durch Projektion entsprechender Lesemodelle.
-
Die Trennung in Command-Verarbeitung und Lesemodell-Projektion(en) fördert zweitens die Entkopplung von Technologien. Die Geschäftslogik, die die schreibenden Operationen verarbeitet, wird technologieagnostisch über Commands angesteuert, also z. B. simple Data Transfer Objects (DTO). Zur Prüfung der Geschäftsregeln wird lediglich ein Schreibmodell angebunden, das sämtliche Informationen enthält, die dafür benötigt werden. Die erfolgreiche Command-Verarbeitung spiegelt sich ebenfalls ausschließlich darin wider. Ob das Schreibmodell nun in einer Datenbankressource oder einem Event Store abgelegt ist, spielt dabei eine eher untergeordnete Rolle. Viel wichtiger ist, dass es sich um genau eine Ressource handelt und die transaktionale Kopplung an weitere Ressourcen vollständig vermieden wird.
Die oft mehrere Ressourcen überspannenden Transaktionen in CRUD-Anwendungen und die damit verbundenen Herausforderungen, was die Commit-Reihenfolge angeht, werden durch die Auslagerung auf dedizierte, in aller Regel asynchron arbeitende Projektoren für die jeweiligen Lesemodelle vermieden.
CQRS und Events
Obwohl CQRS die Modularisierung erheblich unterstützen kann, steht eine wesentliche Herausforderung im Mittelpunkt: die effektive Synchronisation zwischen den getrennten Lese- und Schreibmodellen. Angesichts der Tatsache, dass die Änderungen an einem Schreibmodell auf mehrere Lesemodelle projiziert werden müssen, stellt sich unmittelbar die Frage, ob diese Synchronisation direkt – also etwa im Programmcode oder über Datenbank-Trigger – oder nachgelagert – also asynchron – erfolgen sollte.
Die direkte Synchronisation, insbesondere im Programmcode, führt den Grundgedanken der Trennung von Lese- und Schreibmodellen schnell ad absurdum. Die Trennung der Verantwortlichkeiten wird dadurch gewissermaßen aufgehoben, da die Geschäftslogik (Schreibmodell) nun erneut in der Pflicht ist, sämtliche Lesemodelle zu kennen und diese instant im Rahmen der Command-Ausführung zu aktualisieren. Das sorgt neben der engen Kopplung – und somit schlechterer Modularisierung – für eine steigende Latenz bei den Schreiboperationen. Zudem lassen sich nachträglich nur schwer neue Lesemodelle etablieren, die die historischen Änderungen ebenfalls abbilden sollen.
Die nachgelagerte (asynchrone) Synchronisation der Lesemodelle zeichnet sich durch eine deutlich losere Kopplung aus und lässt sich bei steigender Anzahl der Lesemodelle besser und zielgerichteter skalieren. Die zwei wesentlichen Herausforderungen dieses Ansatzes sind die inhärente „Eventual Consistency“ – die in verteilten Systemen aber sowieso unvermeidbar ist –, und die Frage, wie sich die im Schreibmodell vollzogenen Änderungen nachträglich überhaupt erkennen lassen. Ist das Schreibmodell selbst nämlich so aufgebaut, dass es lediglich den aktuellen Stand, also z. B. das aktuelle Benutzerprofil, abbildet, lassen sich die Änderungen daran nachträglich nicht mehr nachvollziehen und folglich auch nicht in die Lesemodelle projizieren.
An dieser Stelle kommen Events ins Spiel, die die vollzogenen Änderungen am Schreibmodell beschreiben. Speichert man diese Events, z. B. in einem Event Store, kann man auf deren Basis zurückliegende Änderungen nachvollziehen und Lesemodelle aktualisieren. Die Projektion der Lesemodelle lässt sich folglich durch eine Art Abonnement auf den Event Store, der durch die Änderungen an Schreibmodellen gefüllt wird, umsetzen, um den Fortschritt bei der Projektion zu verwalten.
Zudem reduziert sich durch die Einführung der Events die Kopplung zwischen Lese- und Schreibmodellen, was der Modularisierung wiederum zugutekommt. So lassen sich dedizierte Projektoren beispielsweise in eigenständige Komponenten auslagern, solange sie Zugriff auf den Event Store und ihr Abonnement haben, und sogar individuell deployen, falls dies erforderlich sein sollte.
CQRS mit Axon
Das Axon Framework [3] unterstützt die Umsetzung CQRS-basierter Anwendungen auf der JVM und bringt gleichzeitig einen datenbankbasierten Event Store mit. Es kann besonders gut in Spring-Boot-basierten Anwendungen eingesetzt werden. Zentrale Framework-Komponenten werden dabei direkt als Beans im Spring-Kontext bereitgestellt, und Axon-Annotationen innerhalb anwendungsspezifischer Komponenten ermöglichen die Deklaration geeigneter Command und Event Handler. Die folgenden Elemente sind notwendig für die Umsetzung CQRS-basierter Anwendungslogik mit Axon:
-
Commands und Events: Commands repräsentieren Änderungswünsche, während Events vollzogene Änderungen widerspiegeln. Für beide Anwendungszwecke lassen sich bei Axon reguläre Java-Klassen oder Records verwenden. Es ist lediglich darauf zu achten, dass speziell die Events serialisierbar sein sollten, um sie z. B. als JSON im Event Store speichern zu können.
-
Command Gateway: Das vom Axon Framework bereitgestellte Command Gateway verantwortet die Übermittlung von Commands an die entsprechenden Command Handler. Diese werden anhand des Command-Typs ausgewählt und entsprechend aufgerufen. Listing 5 zeigt beispielhaft den Command-Aufruf aus einem Spring REST API Controller heraus.
Listing 5
private final CommandGateway commandGateway;
@PostMapping("/feed")
public void publishFeedEntry(@RequestBody PublishFeedRequest request) {
commandGateway.sendAndWait(
new PublishFeedEntryCommand(
request.userId(),
request.category(),
request.entry()
)
);
}
-
Command Handler: Mit @CommandHandler annotierte Methoden verantworten die Ausführung der entsprechenden Commands und müssen somit anhand des Command-Typs eindeutig identifizierbar sein. Ihnen obliegt es, anhand des Schreibmodells zu entscheiden, ob die gewünschte Command-Ausführung zulässig ist oder nicht. Im Fall einer erfolgreichen Ausführung steht in aller Regel die Aktualisierung des Schreibmodells sowie die Veröffentlichung ein oder mehrerer neuer Events, andernfalls wird eine entsprechende Exception geworfen. Listing 6 zeigt eine mögliche Command-Handler-Implementierung für das PublishFeedEntryCommand.
Listing 6
private final EventGateway eventGateway;
@CommandHandler
public void handle(PublishFeedEntryCommand command) {
if (!userService.isValidUser(command.userId()))
throw new RuntimeException("invalid user");
UUID feedEntryId = UUID.randomUUID();
Instant publishedAt = Instant.now();
feedEntryRepository.save(
new FeedEntryEntity(
feedEntryId,
command.userId(),
command.category(),
command.entry(),
publishedAt
)
);
eventGateway.publish(
new FeedEntryPublishedEvent(
feedEntryId,
command.userId(),
command.category(),
command.entry(),
publishedAt
)
);
}
-
Event Gateway: Das von Axon bereitgestellte Event Gateway verantwortet die Übermittlung und Speicherung der Events im Event Store entsprechend der Reihenfolge ihrer Veröffentlichung. Die Events werden dazu in ein konfigurierbares, serialisierbares Format, z. B. JSON, umgewandelt.
-
Event Handler: Mit @EventHandler annotierte Methoden verantworten die Projektion zuvor gespeicherter Events. Sie werden standardmäßig ebenfalls in der Reihenfolge der Veröffentlichung aufgerufen, jedoch asynchron. Axon überwacht zudem den Fortschritt der Verarbeitung, ruft fehlgeschlagene Handler ggf. erneut auf und setzt die Verarbeitung nach dem zuletzt erfolgreich verarbeiteten Event fort – auch über Systemgrenzen hinweg. Listing 7 zeigt exemplarisch die Eventverarbeitung zum Versenden von Ausgangsnachrichten.
Listing 7
@EventHandler
public void on(FeedEntryPublishedEvent event) {
FeedEntryPublishedMessage message = new FeedEntryPublishedMessage(
event.feedEntryId(),
event.userId(),
event.category(),
event.entry(),
event.publishedAt()
);
publish("feed-topic", message);
}
-
Query Gateway und QueryHandler: Der Vollständigkeit halber seien hier auch das Query Gateway und die mit @QueryHandler annotierten Methoden erwähnt. Diese ermöglichen es – vergleichbar mit dem Command Gateway – ein Routing entsprechender Clientabfragen an ein Lesemodell zu kapseln. Ihr Einsatz ist jedoch nicht verpflichtend, und es können – sofern das Routing nicht benötigt wird –zuvor persistierte Lesemodelle oftmals auch direkt vom Client abgefragt werden, sei es durch einen simplen REST-API-Controller oder eine direkt abfragbare Datenbank.
Stay tuned
Immer auf dem Laufenden bleiben! Alle News & Updates:
CQRS mit Spring Modulith
Das Axon Framework unterstützt die Erstellung CQRS-basierter Anwendungen in Java und integriert sich dabei hervorragend in Spring-Anwendungen. Dennoch bringt es auch zusätzliche Komplexität mit sich. Damit stellt sich die Frage, ob CQRS-Architekturen nicht auch mit Spring-Bordmitteln umgesetzt werden können.
Das Veröffentlichen von Events innerhalb einer Spring-Anwendung wird seit jeher vom Spring Framework unterstützt. Der ApplicationEventPublisher steht in jeder Spring-Anwendung als Bean zur Verfügung und kann per Dependency Injection zur Veröffentlichung von Events verwendet werden. Er bietet dazu eine Methode publishEvent an, die ein beliebiges Object als Event Payload akzeptiert. Der Code aus Listing 4 könnte also unter zu Hilfenahme von Spring Application Events stark vereinfacht werden, sodass er vergleichbar mit Listing 6 wird. Die Kapselung der einzelnen Methodenparameter in einem DTO (Command) kann durch Java-Standardmittel erreicht und damit ebenfalls in unserer Spring-Anwendung umgesetzt werden. Die klarere Benennung als Command macht die Änderungsabsicht deutlich, das DTO stellt somit ein präziseres API dar. Listing 8 zeigt diesen Code.
Listing 8
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void handle(PublishFeedEntryCommand command) {
if (!userService.isValidUser(command.userId()))
throw new RuntimeException("invalid user");
UUID feedEntryId = UUID.randomUUID();
Instant publishedAt = Instant.now();
feedEntryRepository.save(
new FeedEntryEntity(
feedEntryId,
command.userId(),
command.category(),
command.entry(),
publishedAt
)
);
eventPublisher.publishEvent(
new FeedEntryPublishedEvent(
feedEntryId,
command.userId(),
command.category(),
command.entry(),
publishedAt
)
);
}
Das Konsumieren dieser Events ist Spring-typisch über annotierte Methoden in Spring Beans (z. B. Services, Components oder Configurations) möglich. Die Standardannotation hierfür ist @EventListener. Entsprechend annotierte Methoden werden in dem Moment synchron aufgerufen, in dem ein Event veröffentlicht wird. Für die Entkopplung der Module ist dieser Ansatz jedoch noch nicht ausreichend, da Seiteneffekte unserer Businesslogik, z. B. eine fehlschlagende Nachrichtenveröffentlichung, immer noch unseren eigentlichen fachlichen Code unterbrechen würde.
Das Verlagern der Seiteneffekte an das Ende der Transaktion verringert diese Problematik und ist über die Annotation @TransactionalEventListener möglich. Hierbei wird die Event-Listener-Methode standardmäßig nach dem Commit der Transaktion ausgeführt, die den Event veröffentlicht hat. Nach dem Commit heißt dabei jedoch nicht, dass die Transaktion nicht verlängert würde (die Event-Listener-Methode wird in der Clean-up-Phase der Transaktion ausgeführt). Das bedeutet auch hier, wenn die eigentliche Fachlogik erfolgreich durchläuft, kann der Aufrufer dennoch durch Seiteneffekte Fehler erhalten. Um das zu verhindern, sollte der Event Listener dediziert in einer neuen Transaktion ausgeführt werden. Dies kann durch die zusätzliche Annotation @Transactional(propagation = Propagation.REQUIRES_NEW) erreicht werden.
Möchte man die Event-Listener-Methode zusätzlich vollständig asynchron ausführen, sodass der eigentliche Programmablauf weiterlaufen kann, während Seiteneffekte ausgeführt werden, kann die Annotation @Async benutzt werden. In Summe trägt eine typische Event-Listener-Methode dann drei Annotationen – wie in Listing 9.
Listing 9
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
public void on(FeedEntryPublishedEvent event) {
...
}
Möchte man Spring Application Events nutzen, um Businesslogik und Seiteneffekte voneinander zu trennen, ist dies eine sehr häufig genutzte Kombination von Annotationen. Spring Modulith bündelt diese daher in einer eigenen Annotation: @ApplicationModuleListener. Das Aktualisieren der letzten Aktivität eines Benutzers könnte entsprechend in einem eigenen Event Listener erfolgen, wie es in Listing 10 gezeigt ist.
Listing 10
@ApplicationModuleListener
public void on(FeedEntryPublishedEvent event) {
UserEntity user = this.userRepository.findByUserId(event.userId());
user.setLastUpdatedAt(event.publishedAt());
userRepository.save(user);
}
Wie auch im Axon-Beispiel, in dem wir unseren fachlichen Code bis auf die beidseitig bekannte Event-Klasse vollständig vom Messaging entkoppelt haben, haben wir einen sehr hohen Grad der Entkopplung zwischen den Modulen User und Feed erlangt. Der Feed muss nicht mehr wissen, dass die letzte Aktivität eines Benutzers getrackt wird, er muss nicht mehr wissen, wie diese zu aktualisieren ist oder Ähnliches. Die gesamte Schnittstelle beschränkt sich auf das veröffentlichte Event.
Ein weiteres Problem, das mit Axon sehr einfach zu lösen ist, ist die Persistierung der Events und die garantierte Zustellung an jeden Event Handler. Analog müssen wir auch mit Spring Application Events sicherstellen, dass jedes Event an jeden zuständigen Event Listener zugestellt wird und er es auch erfolgreich verarbeitet hat. Hier hilft Spring Modulith, da es den Event-Mechanismus von Spring um eine Persistenzschicht erweitert. Veröffentlichte Events werden dabei von Spring Modulith in derselben Transaktion persistiert, in der sie auch veröffentlicht werden.
Eine erfolgreiche Verarbeitung durch die Event-Listener-Methode wird ebenfalls in der Datenbank persistiert. Hierfür wird die Transaktion der Event-Verarbeitung genutzt. Es kann also nachvollzogen werden, dass jedes Event von jedem zuständigen Event Listener erfolgreich verarbeitet wurde. Ein Retry-Mechanismus wie bei Axon steht mit Spring Modulith bisher nicht zur Verfügung. Die Nachverarbeitung fehlerhafter Event-Listener-Ausführungen muss also explizit implementiert werden.
Eine Abstraktion zum Aufrufen der Fachlogik, wie das Command Gateway in Axon, fehlt im Spring-Ökosystem. Der Aufruf der passenden Methode, z. B. aus dem REST-Controller, muss explizit erfolgen. Hierfür kann Dependency Injection des Fachlogik-API genutzt werden, z. B. wie in Listing 11.
Listing 11
private final FeedService feedService;
@PostMapping
public void publishFeedEntry(@RequestBody PublishFeedEntryBody body) {
feedService.handle(
new PublishFeedEntryCommand(
body.userId(),
body.category(),
body.entry()));
}
Event Sourcing
Die Einführung von Events zur Entkopplung der Lese- und Schreibmodelle in CQRS-basierten Anwendungen (unabhängig davon, ob diese mit Axon oder Spring Modulith erstellt wurden) führt letztlich zu der Frage: Bedarf es überhaupt noch einer dedizierten Speicherung des Schreibmodells? Folgt man der Argumentation, dass Events sämtliche Änderungen am Schreibmodell vollständig beschreiben, so lässt sich aus der Summe aller Events in der korrekten Reihenfolge letztlich auch das Schreibmodell selbst erneut wiederherstellen. Das wird Event Sourcing genannt.
Dabei werden im Rahmen der Command-Ausführung die für die betreffende Instanz – also z. B. das Benutzerprofil – zuvor persistierten Events geladen, um damit eine Instanz des Schreibmodells wiederherzustellen. Das Modell selbst ist somit nicht direkt persistent, sondern wird im Bedarfsfall aus den vorausgegangenen Events instanziiert. Änderungen am Modell werden über die Veröffentlichung und Speicherung neuer Events erreicht, die für nachfolgende Command-Ausführungen dann erneut für das Event Sourcing in Betracht gezogen werden (Abb. 2).

Event Sourcing stellt somit die perfekte Ergänzung für CQRS-basierte Anwendungen dar bzw. wird durch deren forcierte Trennung der Lese- und Schreibmodelle überhaupt erst ermöglicht. Die wesentlichen Vor- und Nachteile von Event Sourcing lassen sich wie folgt zusammenfassen:
-
Die Nutzung von Events zur Wiederherstellung des Schreibmodells fördert die vollständige Speicherung sämtlicher Änderungen innerhalb der Events, da diese ansonsten verloren gingen. Dadurch bleiben, im Vergleich zu einer separaten Speicherung, Schreibmodell und Events ständig synchron.
-
Der Fokus auf Speicherung sämtlicher Daten in Form von Events wirkt sich in aller Regel positiv auf das Event-Design und die Struktur aus. Fachliche Anforderungen, aus denen die Events primär abgeleitet werden, müssen frühzeitig mit technischen Anforderungen abgeglichen werden, um eine geeignete Struktur festzulegen. Das fördert u. a. eine einheitliche Sprache beim Event-Design und in der Abstimmung zwischen Fachexperten und Entwicklern.
-
Dennoch kann der Einsatz von Event Sourcing schnell zu Performanceeinbußen bei der Command-Ausführung führen, da das Laden und Wiederherstellen des Schreibmodells bei steigender Anzahl von Events aufwendiger wird.
Event Sourcing wird aktuell (noch) nicht von Spring Modulith unterstützt, sodass im Folgenden lediglich die Umsetzung mit Axon im Fokus steht.
Axon setzt beim Event Sourcing auf den auch aus dem Domain-Driven Design bekannten Begriff des Aggregate. Dieses bildet die fachliche – und in diesem Fall auch technische – Klammer für sämtliche Command-Ausführungen und daraus resultierende Events. Dementsprechend bedarf es eines eindeutigen Aggregate-Identifiers, um verschiedene Instanzen auseinanderhalten zu können, also z. B. verschiedene Benutzerprofile anhand des Benutzernamens.
Eine mit @Aggregate annotierte Klasse kann folglich zur Laufzeit als Aggregate-Instanz fungieren. Dazu wird sie von Axon instanziiert und sämtliche zuvor veröffentlichten Events werden den darin enthaltenen, mit @EventSourcingHandler annotierten Methoden übergeben, sodass der Programmcode das Schreibmodell rekonstruieren kann. Schließlich werden die bereits bekannten Command Handler – ebenfalls Teil derselben Klasse – aufgerufen, die wiederum über AggregateLifecycle.apply() neue Events, die jeweilige Instanz betreffend, veröffentlichen. Listing 12 zeigt ein Event Sourced Aggregate mit Axon.
Listing 12
@Aggregate
public class FeedAggregate {
@AggregateIdentifier
private UUID id;
private FeedEntry latestEntry;
@CommandHandler
public FeedAggregate(PublishFeedEntryCommand command) {
AggregateLifecycle.apply(
new FeedEntryPublishedEvent(UUID.randomUUID())
);
}
@EventSourcingHandler
public void on(FeedEntryPublishedEvent event) {
this.id = event.feedEntry();
}
// more command and event sourcing handlers
}
Fazit
Größere Anwendungen bestehen, wissentlich oder nicht, in aller Regel aus mehreren Modulen. Diese müssen miteinander interagieren, damit der fachliche Nutzen der Anwendung erreicht werden kann. Bei der Modularisierung geht es folglich nicht darum, Module vollständig voneinander zu trennen, sondern darum, die Abgrenzung der Module und deren Interaktion kontrollieren zu können.
Dependency Injection hilft zwar, konkrete Modulimplementierungen und deren Initialisierung voneinander zu entkoppeln, verhindert aber nicht, dass Module untereinander ihre Schnittstellen unkontrolliert konsumieren – durch entsprechende Methodenaufrufe. Spring Modulith ermöglicht es, über entsprechende Tests sicherzustellen, dass keine internen, privaten Schnittstellen über Modulgrenzen hinweg aufgerufen werden.
Eine weitergehende Abstraktion der Schnittstellen kann durch den Versand entsprechender DTO-Nachrichten erreicht werden, die entweder Änderungsabsichten (Commands) oder erfolgte Änderungen (Events) repräsentieren. Dafür bedarf es einer geeigneten Komponente zur Nachrichtenübermittlung. In Spring übernimmt dies der Application Context, während Axon eigene Komponenten für den Command- und Event-Versand bereitstellt. Der wesentliche Vorteil dieser Art der Kommunikation ist die Möglichkeit, insbesondere Events in weiteren Modulen zu konsumieren, ohne das versendende Modul anpassen zu müssen.
Die Verarbeitung erfolgter Änderungen in Form von Events kann jedoch temporär zu Fehlern führen, z. B. zu gestörtem E-Mail-Versand, und stellt somit eine starke Kopplung zur Laufzeit dar. Folgerichtig sollten Events persistiert und asynchron verarbeitet werden, was u. a. zu einer geringeren Latenz in der Command-Verarbeitung führt. Eine derartige Entkopplung bedingt zwingend den Umgang mit der daraus resultierenden Eventual Consistency, macht das Gesamtsystem aber deutlich stabiler und skalierbarer. Die strikte asynchrone Trennung von Eventveröffentlichung und -verarbeitung führt letztlich zur Trennung von Schreib- und Lesemodellen – bekannt als CQRS.
CQRS – richtig angewendet – führt folgerichtig zu besserer Modularisierung von Anwendungen und lässt sich sowohl mit Spring Modulith als auch mit Axon umsetzen. Es ermöglicht darüber hinaus Event Sourcing, d. h. die Verwendung der zuvor veröffentlichten Events zur Rekonstruktion des Schreibmodells. Eine entsprechende Unterstützung dafür ist in Spring Modulith bisher jedoch nicht vorgesehen.