13. August 2024 von Christian Ortiz
Spring Modulith als Alternative zu Microservices
Microservices haben sich in den letzten Jahren als dominierender Trend in der Softwarearchitektur etabliert. Sie werden oft als Universallösung für komplexe Anwendungen angepriesen, die es ermöglicht, große Systeme in unabhängige, klar definierte Einheiten zu zerlegen. Microservices bieten zweifellos Vorteile, sind aber nicht immer sinnvoll oder notwendig.
Bevor man sich voll und ganz auf diesen Architekturansatz einlässt, lohnt es sich, alternative Ansätze in Betracht zu ziehen. Insbesondere kann ein modularisierter Monolith als Ausgangspunkt dienen, wenn die Grenzen und Beziehungen zwischen den Microservices unscharf sind und sich voraussichtlich ändern werden. In diesem Blog-Beitrag werde ich mich mit Modularisierungskonzepten im Java-Ökosystem befassen, wobei ich mich insbesondere auf Spring Modulith konzentrieren.
Modularisierung
Die Aufteilung und Kapselung von technisch komplexen Zusammenhängen verfolgt in erster Linie das Ziel, besonders gut erweiterbare und modifizierbare Systeme zu schaffen. Um dieses Ziel zu erreichen, sollten Entwicklerinnen und Entwickler besonders auf eine möglichst lose Kopplung und hohe Kohäsion bei den Modulen und deren Beziehungen achten. Dies bedeutet, dass Abhängigkeiten bewusst austariert werden müssen und unter anderem Zyklenfreiheit zwischen den Modulen herrschen sollte.
Kohäsion und lose Kopplung
Kohäsion bezeichnet das Prinzip, dass Dinge, die zusammengehören, auch kohärent strukturiert sein sollten. Ein Modul sollte eine klar definierte Aufgabe oder Verantwortung haben und alle Elemente enthalten, die zur Erfüllung dieser Aufgabe notwendig sind. Hohe Kohäsion führt zu leicht verständlichen und wartbaren Modulen.
Lose Kopplung bedeutet, dass Änderungen in einem Modul möglichst wenige oder keine Änderungen in anderen Modulen nach sich ziehen sollten. Dies erleichtert die Wartung und Erweiterung des Systems, da die Module unabhängig voneinander entwickelt, getestet und aktualisiert werden können.
Kognitive Komplexität
Ein wesentliches Element der Modularisierung ist die Reduktion kognitiver Komplexität: Inhalte sollen innerhalb eines Moduls sinnvoll gekapselt werden, ohne dass Details nach außen dringen, wodurch die Menge an Informationen, die auf den ersten Blick erfasst und verstanden werden muss, begrenzt wird.
Bounded Contexts
Eine bewährte Methode zur Definition von Modulen ist der Ansatz der Bounded Contexts aus dem Domain-Driven Design (DDD). Ein Bounded Context ist ein abgegrenzter Bereich innerhalb einer Domäne, in dem eine einheitliche und konsistente Ubiquitous Language - also eine gemeinsame Sprache, die von Entwickler:innen und Fachexpert:innen gleichermaßen verstanden und verwendet wird - verwendet wird.Innerhalb eines Bounded Contexts können Modelle, Entitäten und Services klar definiert und voneinander getrennt werden.
Durch die Verwendung von Bounded Contexts können Entwickler:innen sicherstellen, dass jedes Modul eine spezifische Fachlogik kapselt und unabhängig von anderen Modulen entwickelt und gewartet werden kann. Dies führt zu einer klaren Abgrenzung der Verantwortlichkeiten und fördert sowohl die Kohäsion innerhalb der Module als auch die lose Kopplung zwischen ihnen.
Microservices
Microservices können als Ansatz zur Implementierung von Modulen gewählt werden und bieten insbesondere durch die harte physische Trennung zahlreiche Vorteile. Diese Trennung ermöglicht eine bessere Isolation in Bezug auf Technologiewahl und Fehlerbehandlung. Die Teams können so losgelöst und unabhängig voneinander entwickeln.
Allerdings bringt dieser Ansatz auch erhebliche Nachteile mit sich. Dazu zählen der erhöhte Infrastrukturaufwand für Deployment, Monitoring, Tracing, Logging und in ereignisgetriebenen Architekturen die Notwendigkeit einer Event-Infrastruktur. Auch die Integration zwischen den Diensten und das Management der Datenhaltung sind komplexer. Zudem ist die Flexibilität bei der Verschiebung von Grenzen zwischen Services eingeschränkt.
Modularer Monolith
Ein modularisierter Monolith bietet eine attraktive Alternative zur klassischen Monolithen- oder Microservices-Architektur. Auch in einem Monolithen ist es möglich, eine klare Trennung der Komponenten zu erreichen und gleichzeitig eine hohe initiale Entwicklungsgeschwindigkeit zu ermöglichen, ohne eine saubere Strukturierung zu vernachlässigen.
Im Java-Ökosystem gibt es mehrere Möglichkeiten, eine solche Modularisierung zu erreichen, unter anderem
- Packages: Strukturelle Trennung von Klassen und Schnittstellen innerhalb eines Projekts.
- ArchUnit: Validierung und Sicherstellung der Einhaltung von Architekturregeln.
- Java Module System: Einführung von Modulen auf Sprachebene seit Java 9, um Abhängigkeiten und Zugriffsrechte explizit zu definieren.
- Multi-JAR-Projekte: Aufteilung der Anwendung in mehrere JAR-Dateien, um eine physische Trennung der Module zu erreichen.
Spring Modulith
Spring Modulith ist ein "opinionated" Java Framework, gibt also den Entwicklerinnen und Entwicklern eine bestimmte Vorgehensweise, Konventionen und Standards vor, welche eine klare Strukturierung und Modularisierung von Anwendungen fördert.
Es ermöglicht die strukturelle Validierung der Anwendung, unterstützt ereignisbasierte Kommunikation und bietet integrierte Testbarkeit von einzelnen Modulen.
Installation
Um Spring Modulith in einem Spring-Boot-Gradle-Projekt zu verwenden, sind folgende Abhängigkeiten notwendig:
dependencyManagement {
imports {
mavenBom 'org.springframework.modulith:spring-modulith-bom:1.2.2'
}
}
dependencies {
implementation 'org.springframework.modulith:spring-modulith-starter-core'
testImplementation 'org.springframework.modulith:spring-modulith-test'
}
Verifikation durch Tests
Die folgenden Code-Beispiele beziehen sich auf Lombok und sind aus Gründen der Lesbarkeit unvollständig- Spring Modulith bietet spezielle Testunterstützung, um die Modulstruktur und die Einhaltung der Regeln zu überprüfen:
@SpringBootTest
class ModulithTest {
@Test
void verifyModularStructure() {
Modulith modulith = Modulith.of(Application.class);
modulith.verify();
}
}
Bei Verletzung der Modulith-Regeln schlägt der Test fehl und gibt detaillierte Informationen zur Art des Verstoßes.
Strukturelle Validierung
In Spring Module werden Packages auf der Ebene der mit @SpringBootApplication annotierten Klasse als Module betrachtet. Diese Module können untereinander nur über die erste Ebene des jeweiligen Moduls kommunizieren, welche die öffentliche API des Moduls darstellt.
Um die Funktionsweise von Spring Modulith zu demonstrieren, betrachten wir ein Szenario mit den beiden Modulen Order und Product mit folgender Package-Struktur
de.adesso.monolith
├── order
| |
| ├── internal
| | └── OrderRepository.java
│ |── OrderService.java
| |── Order.java
| └── LineItem.java
├── product
│ ├── Product.java
│ ├── ProductService.java
│ └── internal
| └── ProductRepository.java
└── App.java
Stellen wir uns nun vor, dass das Produktmodul in der Lage sein soll, Bestellungen aufzugeben. Eine findinge Entwicklerinnen oder ein findiger Entwickler könnte nun auf die Idee kommen, das OrderRepository im ProductService zu verwenden.
package de.adesso.monolith.product;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public List<Product> getProducts() {
return productRepository.findAll();
}
public void createProduct(Product product) {
productRepository.save(product);
}
public void orderProductWithoutCheckout(Product product) {
orderRepository.save(
new Order("Customer-1", List.of(
new .LineItem("SomeProduct", BigDecimal.valueOf(10), 1))));
}
}
- Direkter Zugriff auf Package zweiter Modulebene.
Führen wir nun den Test für die Modulverifikation durch, erhalten wir den folgenden Testfehler
- Module 'product' depends on non-exposed type de.adesso.monolith.order.internal.OrderRepository within module 'order'!
ProductService declares constructor ProductService(ProductRepository, OrderRepository) in (ProductService.java:0)
Der Zugriff auf Klassen unterhalb der ersten Ebene des Moduls ist verboten, auch wenn sie nicht package-private sind. Korrekterweise sollte eine Bestellung über den OrderService erfolgen, der ebenfalls auf der ersten Ebene des Moduls Order liegt:
public void orderProductWithoutCheckout(Product product) {
orderService.createOrder("Customer-1", List.of(
new LineItem("SomeProduct", BigDecimal.valueOf(10), 1)));
}
Fehler bei Zyklen
Im nächsten Anwendungsfall soll das Order-Modul dem Product-Modul mitteilen, dass der Bestand der bestellten Produkte reduziert werden muss.
package de.adesso.monolith.order;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final LineItemMapper lineItemMapper;
private final ProductService productService;
@Transactional
public void createOrder(String customerId, List<LineItem> lineItems) {
var order = new Order(customerId, lineItems);
orderRepository.save(order);
lineItems.forEach(l ->
productService.decreaseStockCount(new Product.ProductId(l.productId()),
l.amount()));
}
}
Führen wir nun erneut die Modulverifikation durch, erhalten wir die folgende Fehlermeldung:
- Cycle detected: Slice order ->
Slice product ->
Slice order
Es ist eine zirkuläre Abhängigkeit zwischen den Modulen entstanden. Dies kann erhebliche Auswirkungen auf die Modifizierbarkeit der Anwendung haben und ist daher nicht zulässig.
Abhängigkeiten Invertieren - Application Events
Das Problem der zirkulären Abhängigkeit kann in diesem Fall leicht gelöst werden. Anstelle einer direkten Abhängigkeit zwischen den Modulen kann ein alternativer Ansatz gewählt werden. Spring bietet bereits seit Spring 1.0 mit dem ApplicationEventPublisher die Möglichkeit, Events über einen prozessinternen Eventbus auszugeben.
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public void createOrder(String customerId, List<LineItemDTO> lineItems) {
var order = new Order(customerId, lineItemMapper.map(lineItems));
var storedOrder = orderRepository.save(order);
applicationEventPublisher.publishEvent(new OrderCreated(storedOrder.orderId(), lineItems));
}
Spring Modulith dockt an diesen Mechanismus an und erweitert ihn durch die Annotation ApplicationModuleListener um Persistenz und Asynchronität. Ereignisse können so minimalinvasiv im Modul Product konsumiert und verarbeitet werden. Die fachliche Logik zur Reduktion des Bestandes wandert damit in das Product:
package de.adesso.monolith.product.internal;
@Slf4j
@Component
@RequiredArgsConstructor
class OrderListener {
private final ProductService productService;
@ApplicationModuleListener
public void handle(OrderCreated orderCreated) {
log.info("received order event {}", orderCreated);
orderCreated.lineItems().forEach(l -> productService.decreaseStockCount(
new Product.ProductId(l.productId()), l.amount()));
}
}
Um den persistenten Event-Mechanismus in Spring Modulith nutzen zu können, muss eine zusätzliche Gradle-Abhängigkeit hinzugefügt werden. Spring Modulith bietet hierfür verschiedene Starter-POMs an, die je nach verwendeter Persistenztechnologie ausgewählt werden können. Zur Auswahl stehen Lösungen für JPA, JDBC, MongoDB und neo4j, je nachdem welche Datenbanktechnologie in der Anwendung verwendet wird.
Externalisierte Events
Spring Modulith bietet eine elegante Lösung für das Versenden von Ereignissen an externe Messaging-Dienste. Derzeit werden Kafka, AMQP Broker und JMS unterstützt. Die @Externalized Annotation ist der Schlüssel zu dieser Funktionalität:
@Externalized("order-created::#{#this.orderId()}")
public record OrderCreated(int orderId, List<LineItemDTO> lineItems) {
}
Der Wert der Annotation setzt sich aus dem Routing-Ziel und dem Nachrichtenschlüssel zusammen, wobei letzterer vom Zielsystem abhängig ist. Standardmäßig erfolgt die Serialisierung des Events mittels ObjectMapper nach JSON.
Für erweiterte Konfigurationen kann eine programmatische EventExternalizationConfiguration implementiert werden, um den Serialisierungsprozess anzupassen oder zusätzliche Metadaten hinzuzufügen.
Integrationstests
Ein zentraler Aspekt der Modularisierung in der Softwareentwicklung ist die Möglichkeit, unabhängige Tests für einzelne Module durchzuführen. Spring Modulith unterstützt dieses Konzept durch die Annotation @ApplicationModuleTest, die es ermöglicht, Integrationstests für einzelne Module zu implementieren.
Diese Integrationstests ähneln in ihrer Funktionsweise den mit @SpringBootTest annotierten Tests, mit einem entscheidenden Unterschied: Der Spring ApplicationContext ist genau auf den Umfang des zu testenden Moduls beschränkt.
package de.adesso.monolith.product;
@ApplicationModuleTest
class ProductIntegrationTest {
@MockBean
OrderService orderService;
@Autowired
ProductService productService;
Copy@Test
void testOrderProductWithoutCheckout() {
var product = mock(Product.class);
productService.orderProductWithoutCheckout(product);
verify(orderService).createOrder(any(), any());
}
}
- Das Package definiert den Modulkontext für den Test.
- @MockBean ermöglicht das Mocken von Abhängigkeiten außerhalb des getesteten Moduls.
Event Versand testen
Um die Funktionalität der eventbasierten Kommunikation zu überprüfen, bietet Spring Modulith die Möglichkeit, szenariobasierte Tests durchzuführen. Im folgenden Beispiel wird der Versand des OrderCreated-Events überprüft:
@Test
void testOrderEmission(Scenario scenario) {
scenario
.stimulate(() -> orderService.createOrder("customerId",
List.of(mock(LineItem.class))))
.andWaitForEventOfType(OrderCreated.class)
.matching(orderCreated -> orderCreated.customerId().equals("customerId"))
.toArrive();
}
- Auslösen der zu testenden Aktion: Hier wird eine Bestellung mit einem Mock-LineItem erstellt.
- Warten auf ein Event vom Typ OrderCreated.
- Überprüfen, ob das empfangene Event die erwarteten Eigenschaften aufweist, in diesem Fall die korrekte customerId.
Event Empfang testen
Szenariobasierte Tests in Spring Modulith bieten auch die Möglichkeit, den Zustand zu überprüfen, der sich als Reaktion auf ein Ereignis ändert. Dies ermöglicht es, die Verarbeitung von Ereignissen und deren Auswirkungen auf das zu testende Modul zu testen.
@Test
void testStockReduction(Scenario scenario) {
var productId = new Product.ProductId("productId");
productService.createProduct(new Product(productId, "Some Product",
BigDecimal.valueOf(10), 2, null));
scenario.publish(new OrderCreated("customerId", 1,
List.of(new LineItemDTO("productId", "Some Product",
BigDecimal.valueOf(10), 2))))
.andWaitForStateChange(
() -> productService.getProduct(productId).stockCount(),
newStockCount -> newStockCount != 2)
.andVerify(stockCount -> assertEquals(0, stockCount));
}
- Erstellen eines Testprodukts mit einem anfänglichen Bestand von 2.
- Veröffentlichen eines OrderCreated-Events, das den Kauf von 2 Einheiten des Produkts simuliert.
- Warten auf eine Änderung des Produktbestands. Alternativ kann hier auch auf ein Event gewartet werden, siehe andWaitForEventOfType im vorigen Beispiel.
- Überprüfen, ob der neue Bestand nicht mehr 2 beträgt (also geändert wurde).
- Verifizieren, dass der neue Bestand 0 ist, was der erwarteten Reduktion entspricht.
Generierung von Dokumentation
Spring Modulith bietet auch die Möglichkeit, über die Documenter-Abstraktion automatisch Dokumentationen zu erzeugen. Diese basiert auf dem von ApplicationModules erzeugten Anwendungsmodulmodell und kann in die Asciidoc-Entwicklungsdokumentation integriert werden.
Zwei Arten von Dokumentationsschnipseln werden unterstützt: C4- und UML-Komponentendiagramme, die die Beziehungen zwischen den Modulen darstellen, und ein Application Module Canvas, das einen tabellarischen Überblick über jedes Modul und seine wichtigsten Elemente (wie Spring Beans, Aggregate-Wurzeln, veröffentlichte und subskribierte Events und Konfigurationseigenschaften) bietet.
@Test
void documentation() {
var modules = ApplicationModules.of(App.class).verify();
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases();
}
- Erstellt ein PlantUML-Diagramm, das alle Module und ihre Beziehungen zueinander darstellt.
- Generiert separate PlantUML-Diagramme für jedes individuelle Modul.
- Erzeugt tabellarische "Module Canvases" mit detaillierten Informationen zu jedem Modul.
Fazit
Der Schlüssel zu anpassungsfähigen und wartbaren Architekturen liegt in der effektiven Modularisierung und der sorgfältigen Gestaltung der Beziehungen zwischen den Modulen - nicht unbedingt in ihrer physischen Isolation. Unser Ziel sollte es sein, verständliche und kontrollierbare Anwendungen zu schaffen, die sich flexibel an sich ändernde Anforderungen anpassen können.
Spring Modulith erweist sich als wertvolle Ergänzung für Spring Boot-Projekte und eignet sich nicht nur als erster Proof of Concept für Projekte mit komplexer Fachlichkeit. Es ermöglicht eine klare Strukturierung durch lose gekoppelte und unabhängig testbare Module, was sowohl die Wartbarkeit als auch die Verständlichkeit erhöht.
Durch die Möglichkeit, Ereignisse zu externalisieren, können Teile des modularen Monolithen bei Bedarf mit überschaubarem Aufwand in Microservices überführt werden. Darüber hinaus unterstützen die Konventionen und Regeln die Entwicklung langfristig wartbarer Anwendungen, indem eine saubere Architektur von Anfang an gefördert und durchgesetzt wird.