Modularisierung leicht gemacht: Spring Modulith mit Kotlin und Hexagonale Architektur
Modularisierung ist ein Schlüsselkonzept in der modernen Softwareentwicklung, um Anwendungen wartbar, testbar und flexibel zu gestalten. In diesem Artikel zeigen wir, wie Spring Modulith in Verbindung mit der hexagonalen Architektur und Kotlin eine solide Grundlage für die Modularisierung schaffen kann. Wir betrachten die Herausforderungen bestehender monolithischer Anwendungen und wie durch klar definierte Module eine schrittweise Entkopplung gelingt.
Was ist ein Modulith?
Der Name deutet es schon an. Ein Modulith bezeichnet eine Architektur, bei der ein Monolith in klar abgegrenzte Module unterteilt wird. Diese Module sind so gestaltet, dass sie möglichst stark voneinander entkoppelt sind und interne Abhängigkeiten minimiert werden. Ihr könnt es euch so vorstellen, als ob ihr mehrere Microservices in ein Repository packt und unter einer Anwendung handhabt. Zum Beispiel könnte ein Modul die Benutzerdaten und ein anderes Modul die Rechnungsstellung verwalten, wobei beide Module klar voneinander getrennt sind, aber in derselben Anwendung laufen. Der Vorteil liegt auf der Hand: Es sind keine aufwendigen HTTP-Schnittstellen oder Kafka-Broker notwendig. Keine Authentifizierungen, um zwischen den Modulen zu kommunizieren, und dennoch ist der Vorteil einer losen Kopplung gegeben.
Modularisierung kann also als ein sanfter Einstieg in die Microservice-Welt gesehen werden.
Was macht Spring Modulith
Um eine Anwendung in Module zu unterteilen gibt es verschiedene Optionen. Zum einen kann man einfach den Code in verschiedene Packages packen. Zum anderen bieten Maven bzw Gradle die Option Module mit eigenen Dependencies und eigenem Build-Script zu erzeugen. Und auch die Aufteilung in Microservices ist eine Art Modularisierung.
Schaut man sich die drei Optionen an, fällt auf, dass deren Umsetzung geordnet in ihrem Aufwand steigt. Während Pakete noch sehr einfach anzulegen sind, braucht es für Maven/Gradle-Module schon jeweils separate Dependency-Definitionen. Der Aufwand, Microservices in die Welt zu senden und deren Kommunikation untereinander zu ermöglichen, legt den anderen beiden noch einmal eine ganze Schippe oben drauf.
Wir wollen also eigentlich erst einmal nur mit Paketen starten. Aber hier bekommen wir schnell ein Problem. Stellt euch vor, ihr habt eine Legacy-Anwendung, bei der ein Domain-Modell auf das andere zugreift und das wiederum auf das nächste. Packt ihr nun alle eure passend zusammenhängenden Klassen in die jeweiligen Pakete, sieht es zwar nach verschiedenen Modulen aus, aber in Wirklichkeit ist dem nicht so.
Wir wollen eine lose Kopplung erreichen. Vom Prinzip her wollen wir vielleicht später mal in der Lage sein, eines der Module herauszulösen und als Microservice zu deployen. Dann wollen wir natürlich so wenig wie möglich Kopplung haben. Somit wird der Refactoring-Aufwand reduziert.
Leider sieht man diese Kopplung nur sehr schwer, da sie oft erst bei einer detaillierten Analyse des Quellcodes oder bei der Beobachtung von unerwartetem Verhalten in der Anwendung deutlich wird. Im Zweifel fällt es erst auf, wenn versucht wird, das Modul herauszulösen und als eigenen Microservice zu deployen. Häufig verbirgt sich diese Kopplung in subtilen Abhängigkeiten zwischen Modulen, die durch direkte Zugriffe entstehen. Selbst wenn ihr anfangt aufzuräumen und in einer großen Hauruck-Aktion die Klassen voneinander entkoppelt, werdet ihr spätestens in der weiteren Entwicklung wieder Kopplungen erhalten.
Und genau hier hilft Spring Modulith. Es prüft, ob ihr Dependencies von einem Modul auf ein anderes habt, welche nicht erlaubt sind.
Grundlagen
Stellen wir uns vor, wir haben folgende Struktur:
1src
2├── Application.kt
3├── module-1
4│ ├── ModuleOneInterface.kt
5│ └── ModuleOneService.kt
6└── module-2
7 ├── ModuleTwoInterface.kt
8 └── ModuleTwoService.kt
In unserem src
-Package liegt also eine Application.kt
und daneben zwei Pakete: module-1
und module-2
. In den beiden Modulen befindet sich zum einen ein Interface und ein Service, welcher die Implementierung des Interfaces darstellt.
Das Erste, was Spring Modulith macht, ist, alle Pakete, welche auf der gleichen Ebene liegen wie die Application.kt
, als Modul zu definieren. Das passiert von ganz alleine, und ihr müsst dafür nichts weiter tun. Alle sich darin befindlichen Klassen und Interfaces werden von Spring Modulith standardmäßig als public angesehen. Es ist also erlaubt, von allen anderen Modulen auf diese Klassen zuzugreifen.
Nun wollen wir aber nicht, dass der ModuleOneService
direkt auf den ModuleTwoService
und vice versa zugreifen kann, sondern das Ganze soll nur über die Interfaces möglich sein. Um das zu erreichen, packen wir die Services einfach in ein Sub-Package:
1src
2├── Application.kt
3├── module-1
4│ ├── ModuleOneInterface.kt
5│ └── services
6│ └── ModuleOneService.kt
7└── module-2
8 ├── ModuleTwoInterface.kt
9 └── services
10 └── ModuleTwoService.kt
Greifen wir nun von einem der Services auf den anderen Service zu, wird der Spring Modulith-Test fehlschlagen.
Spring Modulith Test
Da wir nun schon den Test erwähnt haben, möchte ich hier noch einmal kurz aufzeigen, wie diese Tests aussehen. Im Grunde stellt euch Spring Modulith drei "Test"-Funktionalitäten zur Verfügung. Zum einen haben wir den eigentlichen Test, der unsere Module analysiert und verifiziert, ob wir die lose Kopplung, die wir haben wollen, einhalten.
1@Test
2fun `verifies modular structure`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 modules.verify()
5}
Als Nächstes haben wir die Option, uns einmal die Module mit deren Definitionen anzuschauen. Hierin werden alle wesentlichen Inhalte eines Moduls wie zum Beispiel die NamedInterfaces
, auf die wir gleich noch zu sprechen kommen, ausgegeben.
1@Test
2fun `print module structure`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 modules.forEach(Consumer { module: ApplicationModule? -> println(module) })
5}
Und als Letztes bietet Spring Modulith die Möglichkeit, euch eine Doku zu generieren. Diese beinhaltet verschiedene PlantUML-Diagramme der Module und deren Beziehungen zueinander. Aber auch eine AsciiDoc-Dokumentation mit den Diagrammen und allen Klassen sowie Interfaces, welche nach außen öffentlich gemacht sind.
1@Test
2fun `create module documentation`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 Documenter(modules)
5 .writeDocumentation()
6 .writeIndividualModulesAsPlantUml()
7}
Hexagonale Architektur
Selten werden wir so einfache Strukturen in unseren Modulen haben, wie in dem Beispiel weiter oben zu sehen. Gerade wenn wir mit Strukturen wie der hexagonalen Architektur arbeiten, entstehen viele Sub-Packages. Einige von ihnen wollen wir ggf. nach außen hin zugänglich machen. Eine solche Struktur könnte dann so aussehen:
1src
2├── Application.kt
3├── module-1
4│ ├── adapter
5│ │ ├── inbound
6│ │ └── outbound
7│ ├── domain
8│ │ ├── services
9│ │ │ └── ModuleOneService.kt
10│ │ ├── model
11│ │ └── ports
12│ │ ├── inbound
13│ │ │ └── ModuleOneInterface.kt
14│ │ └── outbound
15│ └── services
16└── module-2
17 ├── adapter
18 │ ├── inbound
19 │ └── outbound
20 └── domain
21 ├── services
22 │ └── ModuleTwoService.kt
23 ├── model
24 └── ports
25 ├── inbound
26 │ └── ModuleTwoInterface.kt
27 └── outbound
Nun können die Services weiterhin nicht auf andere Services zugreifen, da sie in einem Sub-Package liegen. Jedoch können die Services nun auch nicht mehr auf die Interfaces zugreifen, da diese nun ebenfalls in einem Sub-Package liegen. Wir müssen an dieser Stelle also klar definieren, dass wir zum Beispiel das Package module-1.domain.ports.inbound
nach außen öffnen wollen. Dazu legt man in Java eine package-info.java
in das Package und kann hier sein Package definieren.
In Kotlin funktioniert dieser Weg nicht. Hier müssen wir eine Klasse erstellen und dieser gewisse Annotationen mitgeben. Wir erzeugen uns also die ModuleMetadata.kt
und legen sie in den zu definierenden Ordner. Der Name der Klasse ist dabei nicht von Bedeutung. Ihr könnt die Klasse auch Drotbohm.kt
oder Weihnachtsmann.kt
nennen. Viel wichtiger ist der Inhalt.
1└── ports
2 ├── inbound
3 │ ├── ModuleOneInterface.kt
4 │ └── ModuleMetadata.kt
Hier sind zwei Annotationen zu sehen: Zum einen @PackageInfo
und @NamedInterface
.
1@PackageInfo
2@NamedInterface(name = ["inbound-ports"])
3class ModuleMetadata
@PackageInfo
ist in der Java-Variante nicht nötig, da dies durch den Dateityp bereits gegeben ist. In Kotlin wird dies benötigt, damit Spring Modulith weiß, dass es sich um die PackageInfo-Datei handelt und hier die Deklaration für das Package zu finden ist.
@NamedInterface
definiert den Namen, unter dem das Package später von Spring Modulith gefunden werden kann. Auch diese Annotation ist nötig, ihr müsst jedoch keinen Namen angeben. Lasst ihr den Parameter weg, wird der Name des Packages genutzt. Wollt ihr einen expliziten Namen angeben, müsst ihr in Kotlin mit Named-Parametern arbeiten und das Ganze als Array definieren.
Der Name ist gerade in der hexagonalen Architektur nicht ganz unwichtig. Schaut ihr euch ein wenig weiter oben den Projektbaum an, werdet ihr feststellen, dass wir zwei Packages haben, welche inbound
heißen. Einmal in der Domain und einmal in den Adaptern. Lasst ihr den Namen weg, werden beide Packages öffentlich gemacht, da sie den gleichen Namen besitzen.
Explizite Zugriffe auf Module
Stellt euch vor, wir haben einige Module in unserer Anwendung. Wir haben in jedem dieser Module einige Packages öffentlich für andere Module gemacht.
Nun kann jedoch jedes Modul auf alle der geöffneten Packages aus allen anderen Modulen zugreifen. Das wollen wir aber nicht. Wir öffnen ein Package ja ggf. nur für ein spezifisches Modul. Auch hier kann man eine Definition vornehmen, um dies sicherzustellen.
Nehmen wir an, wir haben neben unseren module-1
und dem module-2
auch noch ein module-3
. In allen drei Modulen haben wir gewisse Packages öffentlich gemacht. Nun wollen wir, dass module-1
ausschließlich auf Packages von module-2
zugreifen darf. Und innerhalb von module-2
soll es auch nur auf das inbound-port
-Package zugreifen dürfen.
Um das zu tun, erstellen wir wieder eine Module-Metadata in der obersten Ebene von module-1
:
1└── module-1 2 ├── ModuleMetadata.kt 3 ├── adapter 4 │ ├── ... 5 ├── domain 6 ├── ...
In der ModuleMetadata.kt
fügen wir nun die @ApplicationModule
-Annotation hinzu. Hierin können wir dann über allowedDependencies
die Module und Packages deklarieren, auf die das Modul Zugriff haben darf. Das Ganze erfolgt nach dem Schema: Module :: Package
. Der Paketname ist entweder der Name des Packages, in dem eure ModuleMetadata.kt
des anderen Moduls liegt, oder derjenige, den ihr in der ModuleMetadata.kt
als NamedInterface deklariert habt.
1@ApplicationModule(
2 allowedDependencies = ["module-2 :: inbound-port"]
3)
4class ModuleMetadata
Wenn ihr weitere Module und/oder NamedInterfaces erlauben wollt, könnt ihr dies hier einfach kommasepariert angeben. Solltet ihr zwar ein Modul erlauben, aber keine Einschränkungen der einzelnen Packages wollen, könnt ihr dies mit dem Sternchen (*
) ausdrücken: allowedDependencies = ["module-2 :: *"]
.
Legacy Anwendung
Nun wissen wir, wie wir einzelne Packages freigeben und den Zugriff auf diese für andere Module explizit angeben können. Setzen wir das Ganze in einer Legacy-Anwendung um, wird die Liste an Fehlern im Spring Modulith-Test sehr lang. Das alles mit einem Mal zu beheben, ist oft sehr aufwändig.
Aber auch hierfür bietet Spring Modulith eine Option, um euch auch beim Modularisieren von Legacy-Anwendungen zu unterstützen.
Ihr könnt in der Moduldefinition, in der ihr die allowedDependencies
eingefügt habt, noch einen weiteren Parameter ergänzen. Und zwar den type
. Mit diesem könnt ihr das gesamte Modul auf öffentlich setzen. Das heißt, alle Packages sind ab dann für alle anderen Module verfügbar, auch wenn ihr darin keine Module-Metadata definiert habt.
1@ApplicationModule(
2 type = ApplicationModule.Type.OPEN,
3 allowedDependencies = ["module-1 :: inbound-port"]
4)
5class ModuleMetadata
Es ist also möglich, zunächst die Module mit ihrer Paketstruktur zu erstellen und die bis dato starke Kopplung noch zu behalten. Eure Tests bleiben weiterhin grün, ihr könnt andere Änderungen committen und eure Pipeline läuft sauber durch. Das ermöglicht euch, eure Module nacheinander zu entkoppeln. Die Öffnung eines Moduls sollte aber nur so lange Bestand haben, wie ihr euer Modul entkoppelt. Sobald das Modul entkoppelt ist, kann man den Typ entweder auf CLOSED setzen oder einfach aus der @ApplicationModule
-Annotation entfernen.
Fazit
Ein Modulith ist ein Monolith, der in klar abgegrenzte Module unterteilt wird, um lose Kopplung und bessere Wartbarkeit zu erreichen. Diese Module laufen zwar in einer gemeinsamen Anwendung, sind aber so strukturiert, dass sie weitgehend voneinander getrennt funktionieren. Gerade bei Legacy-Anwendungen, die viele unübersichtliche Abhängigkeiten aufweisen, bietet das modulare Konzept einen schrittweisen Übergang zu einer entkoppelten Architektur.
Spring Modulith unterstützt diesen Ansatz, indem es auf Packagings in einer Spring-Anwendung aufsetzt und automatisch Module erkennt. Im Default-Zustand sind alle Pakete eines Moduls öffentlich, doch über spezielle „Metadata“-Dateien können unerwünschte Abhängigkeiten blockiert oder nur bestimmte Bereiche (z.B. Interfaces) öffentlich gemacht werden. Das sorgt dafür, dass Zugriffe zwischen Modulen nur explizit erlaubt erfolgen.
Zur Überprüfung der modularen Struktur stellt Spring Modulith drei zentrale Test-Funktionen bereit:
- Verifikation: Ein Test, der prüft, ob alle definierten Modulgrenzen eingehalten werden.
- Modul-Übersicht: Eine Ausgabe der erkannten Module und deren Abhängigkeiten.
- Dokumentation: Eine automatisierte Erzeugung von AsciiDoc-Dateien und PlantUML-Diagrammen.
Gerade bei komplexen Strukturen wie in der hexagonalen Architektur (Ports & Adapters) legen sich Services, Interfaces und Adapterschichten häufig in mehreren Sub-Packages ab. In Kotlin kennzeichnet man die öffentlichen Bereiche dann mit einer speziellen Klasse (z.B. ModuleMetadata.kt
), die Spring Modulith mit den Annotationen @PackageInfo
und @NamedInterface
eindeutig identifiziert. Außerdem lässt sich der Zugriff anderer Module auf genau diese Bereiche durch @ApplicationModule(allowedDependencies = […])
explizit steuern.
Für Legacy-Systeme, die sofortige Trennung in „saubere“ Module nicht erlauben, können einzelne Module auch offen deklariert werden (type = ApplicationModule.Type.OPEN
). Dadurch bleiben vorhandene Abhängigkeiten vorerst bestehen. So lässt sich Schritt für Schritt vorgehen: Zuerst werden Module nur grob definiert, später werden einzelne Teile geschlossen, bis eine solide Modularisierung erreicht ist.
Mit diesen Bausteinen habt ihr alles an der Hand, um einen guten Einstieg in die Modularisierung mit Spring Modulith zu schaffen.
Spring Modulith bietet neben den genannten Dingen auch noch weitere Funktionalitäten, wie Application Events, welche einen weiteren spannenden Aspekt darstellen. Da solltet ihr ebenfalls einen Blick hineinwerfen.
Eine kleine Anwendung, in der wir einige der Aspekte umgesetzt habe, findet ihr hier: https://github.com/darthkali/spring-modulith-hex-test
Weitere Beiträge
von Danny Steinbrecher
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
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.
Blog-Autor*in
Danny Steinbrecher
IT-Consultant & Developer
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.