Modularer Monolith mit SpringBoot & Maven

Max Beckers

Monolithen sind ein bekanntes Architekturmuster, bei dem die gesamte Software als ein Deployable bereitgestellt wird. Sie sind dafür bekannt, dass ihre  Wartbarkeit und Erweiterbarkeit im Laufe der Zeit zu einer Herausforderung werden können. Microservices sollen genau diesen technischen Schulden entgegenwirken. Dabei handelt es sich um einen Architekturansatz, bei dem die Fachlogik in einzelne Services aufgeteilt und separat bereitgestellt wird, was zu einer hohen Flexibilität führt. Diese Flexibilität hat jedoch auch ihren Preis: erhöhte Infrastukturkomplexität, zusätzlicher Netzwerktraffic, erschwertes Debugging von Fehlern und Bugs sowie kompliziertere Lösungen für Transaktionsprozesse, beispielsweise beim Schreiben in Datenbanken.

Es gibt jedoch noch eine weitere Architektur, die sich zwischen Monolithen und Microservices einordnen lässt: den modularen Monolithen. Vielleicht stellen sich nun Fragen wie „Was ist das?“, „Welche Vorteile bietet er?“ oder „Wie sieht das konkret aus?“. Genau diesen Fragen möchte ich in diesem Beitrag nachgehen und Antworten liefern.

Die Idee eines modularen Monolithen

Die Grundidee eines modularen Monolithen – auch Modulith genannt – besteht darin, weiterhin ein einziges Deployable wie bei einem klassischen Monolithen zu erstellen. Gleichzeitig wird jedoch durch eine klare Definition von Modulen darauf geachtet, die Logik sauber zu trennen und zu kapseln, ähnlich wie bei Microservices. Dadurch wird die Wartbarkeit der Software verbessert, ohne dass eine aufwändige Netzwerkkommunikation erforderlich ist. Jedes Modul definiert eine API, die von anderen Modulen genutzt werden kann. Diese API kann Folgendes beinhalten:

  1. DTOs (Data Transfer Objects): Ähnlich den Objekten in einer OpenAPI-Definition. Sie beschreiben die Daten, die an der Schnittstelle ausgetauscht werden. Notwendige Enums zählen in diesem Fall zu den DTOs.
  2. Interfaces: Vergleichbar mit den Endpunkten einer API-Definition, also den Funktionen, die das Modul bereitstellt.
  3. Exceptions: Vergleichbar mit den Error-Responses und -Codes in einer RESTful API.
  4. Messages: Definitionen von Datenstrukturen für asynchrone Kommunikation (z.B. über Message Queues wie Kafka).

    Durch diese Struktur entstehen ähnlich wie in einer Microservice-Architektur sauber abgegrenzte Module. Für den Aufrufer bleibt das Innere eines Moduls eine Blackbox, und es darf nicht direkt aufgerufen werden.

    Es gibt viele Möglichkeiten, die Trennung zwischen der API eines Moduls und dessen Fachlogik umzusetzen. Ich bevorzuge es, die API in unterschiedliche Namespaces wie DTO, Interface, Exception und Message direkt auf oberster Ebene im Modul zu platzieren, während die gesamte interne Logik in einem “Internal”-Namespace bleibt. Dies ermöglicht es, schnell zu erkennen und auch automatisiert sicherzustellen, dass keine interne Logik eines anderen Moduls direkt aufgerufen wird. Alternativ kann man auch die API und die Logik in zwei separate Module aufteilen.

    Ein Aspekt, den ich bei der Modularisierung noch nicht angesprochen habe, betrifft die eingehende HTTP-Kommunikation. Wo sollten die Controller platziert werden? Sollte jedes Modul seine eigenen Controller haben, um direkt aufgerufen werden zu können, oder gibt es ein zentrales Modul mit allen Controllern, das als eine Art API-Gateway fungiert und die Anfragen an die entsprechenden Module weiterleitet? Wie so oft ist dies eine Architekturentscheidung, die getroffen werden muss, und sie lässt sich nicht pauschal beantworten. Persönlich ziehe ich es vor, die Anfragen in einem „API-Gateway“ zu bündeln und von dort aus an die entsprechenden Module zu verteilen. Dies ist insbesondere dann sinnvoll, wenn eine Anfrage an verschiedene Module gerichtet werden soll. Letztlich hängt die Entscheidung stark von den Anforderungen ab. Es ist ebenso möglich, dass jedes Modul einen eigenen Controller-Namespace erhält, in dem die Controller für dieses Modul verwaltet werden und von dort aus die modulinterne Logik aufrufen.

    Mit einem Monolith anfangen

    Ein bekanntes Zitat, wenn es um Microservices geht, stammt von Martin Fowler:

    „You shouldn’t start a new project with microservices, even if you’re sure your application will be big enough to make it worthwhile.“

    Martin Fowler, MonolithFirst

    Diese Aussage impliziert indirekt das Konzept eines Modulithen. Zu Beginn eines Projekts sollte man, um die Software einfach handhaben zu können, mit einem Monolithen und seinen Vorteilen arbeiten. Dabei ist es wichtig, bereits von Anfang an Module zu definieren, die später als eigenständige Deployables (Microservices) ausgegliedert werden können. Der große Vorteil liegt darin, dass Anpassungen an Modulgrenzen und Schnittstellen schnell und unkompliziert vorgenommen werden können. Sobald sich ein stabiles Fundament etabliert hat, kann man beginnen, einzelne Teile der Anwendung in separate Deployables zu überführen.

    Auch bei einem gewachsenen Monolithen ohne klar definierte Module ist es sinnvoll, diesen zunächst in einen Modulithen zu transformieren, bevor man möglicherweise einzelne Microservices herauszieht. Ein wichtiger Schritt bei einem gewachsenen Monolithen ist, den Code sinnvoll aufzubrechen. Ein mindestens ebenso wichtiger und oft schwierigerer Schritt ist es, die Daten zu trennen. Jedes Modul sollte seine eigene Datenhaltung haben, wobei die Daten von anderen Modulen getrennt sind und ein direkter Zugriff mittels Berechtigungen ausgeschlossen wird. Es gibt verschiedene Ansätze zur Datentrennung: von einem Datenbankschema mit unterschiedlichen Tabellen-Präfixen über verschiedene Schemas bis hin zu unterschiedlichen Datenbanken (je nach Anwendungsfall eventuell auch verschiedene Datenbanktypen wie Postgres, MySQL, MongoDB). Wenn möglich, empfehle ich, Tabellen innerhalb eines Schemas zu vermeiden, um eine klare Trennung zu gewährleisten.

    Eine wichtige Frage in diesem Zusammenhang ist: Brauche ich wirklich die Flexibilität von Microservices? Oder geht es in den meisten Systemen nicht vielmehr darum, eine gute Wartbarkeit und Änderbarkeit sicherzustellen? Meiner Erfahrung nach ist ein Modulith oft eine gute Lösung. In den meisten Fällen muss bei erhöhter Last das gesamte System skaliert werden, und nicht nur einzelne Teile der Anwendung.

    Ein weiterer Grund, sich für Microservices zu entscheiden, kann sein, dass verschiedene Module von unterschiedlichen Teams verwaltet werden. In einer solchen modulithischen Anwendung kann die Koordination von Releases mit mehreren Entwicklungsteams ein guter Grund für die Entkopplung in unabhängige Deployables sein. Grundsätzlich ermöglicht die Trennung der fachlichen Module jedoch auch die Entwicklung durch mehrere Teams innerhalb derselben Software.

    Modulith mit SpringBoot und Maven

    Wie sieht ein Modulith konkret aus? In diesem Abschnitt möchte ich das genauer beleuchten. Da wir über verschiedene Module sprechen, beginnen wir mit einem Multi-Maven-Projekt. Hierbei fungiert das Hauptprojekt als Wrapper und die fachlich definierten Module als Sub-Module.

    Nehmen wir zur besseren Veranschaulichung folgendes Beispiel: Wir haben vier Module – Ordering (Bestellmanagement), Inventory (Inventarmanagement), Shipping (Versandmanagement) und Payment (Bezahlmanagement).

    Die Projektstruktur sieht dann folgendermaßen aus:

    • pom.xml (main pom)
      • api-gateway
        • pom.xml
        • main (including the Controllers)
        • test
      • ordering
        • pom.xml
        • main
        • test
      • inventory
        • pom.xml
        • main
        • test
      • shipping
        • pom.xml
        • main
        • test
      • payment
        • pom.xml
        • main
        • test

    Die Main-POM (pom.xml) sieht so aus:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.2</version>
        <relativePath/>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>modulith-example</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>
    <name>Modulith Example</name>
    
    <modules>
        <module>api-gateway</module>
        <module>ordering</module>
        <module>inventory</module>
        <module>shipping</module>
        <module>payment</module>
    </modules>
    ...
    

    In den POM-Dateien der Sub-Module wird der Main-POM als Parent definiert. Um die Struktur eines Moduls verständlicher zu machen, schauen wir uns das Modul “Ordering” genauer an:

    • com.example.ordering
      • DTO
        • Address.java
        • Customer.java
        • Order.java
        • ShoppingCart.java
      • Exception
        • InvalidOrderException.java
      • Interface
        • OrderInitiator.java
      • Internal
        • Consumer
          • ChargebackConsumer.java (handles ChargebackMessage.java)
        • Entity
        • Repository
        • Service
          • OrderService.java (implements OrderInitiator.java)
      • Message
        • ChargebackMessage.java

    Diese Übersicht halte ich absichtlich schlank, um die grundsätzliche Idee des Modulaufbaus zu zeigen. Es gibt DTOs für den Datentransfer, Exceptions für Fehlerfälle, Interfaces, die dem Client die Logik bereitstellen, sowie Messages für die asynchrone Kommunikation. In diesem Beispiel wird ein synchroner Ablauf zur Erstellung neuer Bestellungen über den OrderService dargestellt. Diese Funktionalität wird dem Client über das OrderInitiator-Interface bereitgestellt. Zudem verarbeitet der ChargebackConsumer Nachrichten, die asynchron über Chargebacks in das System gelangen.

    Dieser Ansatz lässt sich auf jedes System übertragen. Selbstverständlich bieten nicht alle Module sowohl synchrone als auch asynchrone Schnittstellen an.

    Eine weitere Möglichkeit zur Implementierung eines Modulithen ist das Projekt Spring Modulith. Dieses Projekt verfolgt einen etwas anderen Ansatz: Es verwendet nicht mehrere Module, sondern definiert Module über Namespaces innerhalb eines einzigen Moduls. Der Aufbau innerhalb der Module ist jedoch identisch oder ähnlich zu dem zuvor gezeigten.

    Fazit

    Der modulare Monolith ist kein Allheilmittel gegen alle Herausforderungen, die auf Software zukommen. Dennoch bietet er eine wertvolle Alternative zu Microservices, indem er die Komplexität an einigen Stellen reduziert. Bei der Entwicklung neuer Software sollte ein Modulith als Ausgangspunkt in Betracht gezogen werden. Dies umfasst sauber getrennte Module mit eigener Datenhaltung. Einzelne Module können dann bei Bedarf als Microservices aus der Code-Basis herausgelöst werden. Dank der klar definierten APIs ist dies mit geringem Aufwand möglich.

    Bei der Umsetzung eines Modulithen gibt es viele Freiheiten, wie der Ansatz im Detail gestaltet wird. Wichtig ist jedoch sicherzustellen, dass die Kommunikation nur über die öffentlichen APIs der Module erfolgt und dass die Daten sauber getrennt sind, ohne direkte Integrationen auf Datenbankebene.

    Total
    0
    Shares
    Previous Post

    Die Kunst der statischen Codeanalyse

    Related Posts