Softwarearchitektur verstehen und entscheiden

Aaron KreisÜber mich
Architekturstile

Modular Monolith

Eine Anwendung mit klar getrennten fachlichen Modulen

Die Idee und ihr Nutzen

Die Schadenanwendung einer Versicherung umfasst drei Fachgebiete: Schaden, Vertrag und Zahlung. Sie wird als ein einziges Programm gebaut und ausgeliefert, also in Betrieb genommen. Innen ist sie aber in drei streng getrennte Teile gegliedert, die Module. Jedes Modul verwaltet seine eigenen Daten und Regeln und bietet den anderen Modulen nur wenige, fest vereinbarte Aufrufe an. Ändern die Entwickler zum Beispiel, wie das Modul Vertrag seine Vertragsdaten speichert, bleiben die Module Schaden und Zahlung unverändert. Trotzdem betreibt die Versicherung nur ein Programm statt mehrerer getrennter Programme. Diese Bauweise heißt Modular Monolith, auf Deutsch modularer Monolith. „Monolith“ bezeichnet ein Programm, das als Ganzes gebaut und ausgeliefert wird.

Bausteine: Modul Vertrag: eigene Daten und Regeln; Modul Schaden: eigene Daten und Regeln; Modul Zahlung: eigene Daten und Regeln; Gemeinsame Datenbank: getrennte Bereiche je Modul. Verbindungen: Modul Schaden — Modul Vertrag (vereinbarter Aufruf); Modul Schaden — Modul Zahlung (vereinbarter Aufruf); Modul Vertrag — Gemeinsame Datenbank; Modul Schaden — Gemeinsame Datenbank; Modul Zahlung — Gemeinsame Datenbank. Die Module laufen in einem Programm; jedes bietet den anderen nur wenige vereinbarte Aufrufe an.

Wie funktioniert es?

Das Modul Schaden, das Modul Vertrag und das Modul Zahlung laufen alle in einem Programm. Ein Kunde meldet einen Schaden vom 3. Mai zu Vertrag 88123, und das Modul Schaden nimmt die Meldung an. Das Modul Schaden fragt über die veröffentlichte Schnittstelle des Moduls Vertrag, ob Vertrag 88123 am Schadentag gültig ist. Die Schnittstelle eines Moduls besteht aus den wenigen, veröffentlichten Aufrufen, die das Modul den anderen Modulen anbietet. Das Modul Vertrag liest seine eigenen Vertragsdaten und antwortet „gültig“. Das Modul Schaden liest die Vertragstabelle nicht selbst. Das Modul Schaden speichert den Schaden in seinen Schadendaten. Nach der fachlichen Freigabe übergibt das Modul Schaden dem Modul Zahlung über dessen Schnittstelle einen Zahlungsauftrag, den das Modul Zahlung in seinen Zahlungsdaten ablegt. Später will ein Entwickler im Modul Schaden eine Angabe direkt aus der Vertragstabelle lesen, um sich die Erweiterung der Schnittstelle des Moduls Vertrag zu sparen. Solche Zugriffe prüft der Architekturtest, ein automatischer Test für die Grenzen zwischen den Modulen. Der Architekturtest schlägt fehl und meldet den unerlaubten Zugriff, bevor der Code ausgeliefert wird.

Die Module richten sich nach den Fachgebieten Schaden, Vertrag und Zahlung, nicht nach technischen Aufgaben wie Oberfläche oder Datenbankzugriff. Die Schnittstelle des Moduls Vertrag legen dessen Entwickler fest; das Modul Vertrag bietet sie an, und das Modul Schaden ruft sie auf. Alles andere im Modul Vertrag ist intern, und die anderen Module dürfen es nicht verwenden. Für jede Tabelle ist genau ein Modul zuständig. Andere Module bekommen die Daten einer Tabelle nur über die Schnittstelle dieses Moduls. Zusätzlich kann jedes Modul einen eigenen Bereich in der Datenbank mit eigenen Zugriffsrechten bekommen. Dann weist die Datenbank selbst eine Abfrage des Moduls Schaden auf die Vertragstabelle ab. Statt eines Architekturtests kann auch der Compiler unerlaubte Zugriffe abweisen. Der Compiler ist das Werkzeug, das den Code in ein lauffähiges Programm übersetzt.

Module arbeiten über Aufrufe ihrer Schnittstellen zusammen oder über Ereignisse, also Nachrichten wie „Schaden freigegeben“, die innerhalb des Programms verteilt werden. Der modulare Monolith trennt die Module nur im Code und in den Daten. Im Betrieb laufen alle Module im selben Programm und werden gemeinsam gestartet und ausgeliefert.

Antwortet das Modul Vertrag „ungültig“, legt das Modul Schaden keinen Zahlungsauftrag an.

Modularer Monolith: Module sprechen nur über ihre Schnittstellen

Beteiligte: Kunde: meldet einen Schaden vom 3. Mai; Modul Schaden: eigene Schadendaten; Modul Vertrag: eigene Vertragsdaten; Modul Zahlung: eigene Zahlungsdaten; Architekturtest: weist Zugriffe auf fremde interne Teile ab. Ablauf: 1. Der Kunde meldet beim Modul Schaden einen Schaden zu Vertrag 88123. Alle drei Module laufen in einem Programm. 2. Das Modul Schaden fragt über die veröffentlichte Schnittstelle des Moduls Vertrag, ob Vertrag 88123 zum Schadentag gültig ist. 3. Das Modul Vertrag liest seine eigenen Vertragsdaten und antwortet „gültig“. 4. Das Modul Schaden speichert den Schaden in seinen Schadendaten. 5. Nach der fachlichen Freigabe übergibt das Modul Schaden dem Modul Zahlung einen Zahlungsauftrag. 6. Ein Entwickler schreibt im Modul Schaden Code, der direkt aus der Vertragstabelle liest. 7. Der Architekturtest schlägt fehl und meldet den unerlaubten Zugriff auf die Vertragstabelle, bevor der Code ausgeliefert wird.

Wann ist es sinnvoll und wann nicht?

Ein Modular Monolith lohnt sich

  • Eine neue Geschäftsanwendung entsteht. Martin Fowler empfiehlt in seinem Beitrag „MonolithFirst“, solche Anwendungen zuerst als ein in Module gegliedertes Programm zu bauen, weil sich Grenzen zwischen Diensten am Anfang schwer richtig ziehen lassen. Wer gleich mit getrennten Diensten beginnt, muss eine falsch gezogene Grenze später in den Schnittstellen über das Netz verschieben.
  • Ein Team oder wenige Teams entwickeln und betreiben die Anwendung gemeinsam. Die Alternative wäre, jedes Fachgebiet als eigenen Dienst zu betreiben, also als eigenes Programm, das die anderen über das Netz aufrufen. Dann kämen Netzaufrufe und verteilte Daten hinzu, und das Team müsste drei Programme statt einem bauen, ausliefern und betreiben. Im modularen Monolithen ist die Frage nach der Gültigkeit von Vertrag 88123 ein Aufruf innerhalb des Programms und kein Netzaufruf, der hängen bleiben oder scheitern kann.
  • Die fachlichen Grenzen stehen noch nicht fest. Es ist zum Beispiel noch offen, ob die Prüfung der Vertragsgültigkeit zum Modul Schaden oder zum Modul Vertrag gehört. Im modularen Monolithen verschiebt das Team den Code dafür innerhalb des Programms. Zwischen getrennten Diensten müsste das Team Schnittstellen über das Netz ändern und zwei Dienste gemeinsam ausliefern.
  • Mehrere Fachgebiete hängen eng zusammen und sollen trotzdem getrennt änderbar bleiben. Ein Schaden braucht einen gültigen Vertrag, und ein freigegebener Schaden führt zu einer Zahlung. Eine Änderung an der Speicherung der Vertragsdaten betrifft nur das Modul Vertrag, solange seine Schnittstelle gleich bleibt. Ohne Module entstehen dagegen mit den Jahren immer mehr direkte Abhängigkeiten: Der Code für Schäden liest direkt aus den Vertragstabellen, und der Code für Zahlungen ruft interne Hilfsfunktionen des Schadencodes auf. Schließlich kann niemand mehr etwas am Code für Verträge ändern, ohne zu befürchten, dass Schadenbearbeitung oder Zahlung nicht mehr funktionieren.
  • Einzelne Module sollen später vielleicht als eigene Dienste laufen. Ein Beispiel: Zwei Jahre später soll das Modul Zahlung als eigener Dienst betrieben werden, weil eine Bank eine eigene Anbindung verlangt. Die bestehende Schnittstelle des Moduls Zahlung ist dafür ein guter Ausgangspunkt, weil die anderen Module das Modul Zahlung schon heute nur über diese Schnittstelle aufrufen. Ohne Module müsste das Team vor dem Herauslösen erst allen Code finden, der direkt auf die Zahlungstabellen oder interne Funktionen der Zahlung zugreift.

Ein Modular Monolith ist nicht das Richtige

  • Einzelne Teile werden ganz unterschiedlich stark belastet, etwa das Modul Schaden nach einem Unwetter. Im modularen Monolithen lässt sich das Modul Schaden nicht allein mehrfach starten, und die Versicherung müsste das ganze Programm mit allen Modulen mehrfach starten. Dann betreibt die Versicherung jedes Fachgebiet als eigenen Dienst, also als eigenes Programm, und startet nur vom stark belasteten Dienst weitere Exemplare. Diese Bauweise heißt Microservices. Weitere Exemplare von Vertrag und Zahlung, die gerade wenig zu tun haben, entfallen. Dafür kommen Aufrufe über das Netz und getrennte Datenbanken hinzu.
  • Einzelne Teile müssen mit anderer Technik gebaut werden. Ein Beispiel ist eine andere Programmiersprache. In einem gemeinsamen Programm müssten alle Module dieselbe Programmiersprache verwenden. Dann baut das zuständige Team diesen Teil als eigenen Dienst in der anderen Programmiersprache, und die übrigen Module rufen den Dienst über das Netz auf.
  • Viele Teams wollen völlig unabhängig voneinander ausliefern. Im modularen Monolithen müssten alle Teams ihre Änderungen gemeinsam ausliefern. Ein Fehler des Teams für das Modul Schaden hielte dann auch die fertige Änderung des Teams für das Modul Zahlung auf. Dann bekommt jedes Team einen eigenen Dienst, den es an einem beliebigen Tag ausliefern kann.
  • Die Anwendung ist sehr klein und hat ein einziges Fachgebiet. Dann baut das Team ein Programm ohne Module und ohne strenge innere Grenzen, eine Monolithic Architecture. Architekturtests und Schnittstellen zwischen Modulen, die bei einem einzigen Fachgebiet nichts trennen würden, entfallen.

Nachteile und typische Fehler

Alle Module werden gemeinsam ausgeliefert. Alle Module laufen im selben Programm und teilen sich Arbeitsspeicher und Rechenleistung; ein einzelnes Modul lässt sich nicht getrennt in mehreren Exemplaren betreiben. Ohne Prüfung greift früher oder später doch ein Modul auf die Tabellen oder internen Funktionen eines anderen Moduls zu. Das Team muss deshalb Architekturtests schreiben und pflegen oder die Module so anlegen, dass der Compiler unerlaubte Zugriffe ablehnt. Eine gemeinsame Datenbank erleichtert die Arbeit, weil das Programm Schaden und Zahlung in einer gemeinsamen Datenbanktransaktion speichern kann, also so, dass die Änderungen beider Module nur zusammen oder gar nicht gespeichert werden. Diese gemeinsame Speicherung wird zum Hindernis, sobald ein Modul herausgelöst werden soll. Wird das Modul Zahlung ein eigener Dienst, muss das Team zusätzlich klären, wie Zahlungsaufträge zuverlässig ankommen, wenn die gemeinsame Datenbanktransaktion wegfällt. Außerdem muss das Team den neuen Dienst dann einzeln ausliefern, überwachen und betreiben.

Typische Fehler und woran man sie erkennt:

  • Aufgeweichte Grenzen: Ohne automatische Prüfungen greifen Entwickler mit der Zeit direkt auf interne Teile anderer Module zu, weil ein direkter Zugriff schneller geschrieben ist als eine Erweiterung der Schnittstelle. Das Modul Zahlung ruft zum Beispiel eine interne Hilfsfunktion des Moduls Vertrag auf, die nicht zur Schnittstelle gehört. Benennen die Entwickler des Moduls Vertrag diese Funktion um, schlägt das Modul Zahlung fehl. Erkennbar ist das an Änderungen im Modul Vertrag, nach denen plötzlich das Modul Schaden oder das Modul Zahlung fehlschlägt.
  • Direkter Tabellenzugriff: Eine gemeinsame Datenbank verleitet dazu, die Tabellen anderer Module doch direkt zu lesen, wenn keine getrennten Zugriffsrechte das verhindern. Das Modul Schaden liest zum Beispiel das Vertragsende direkt aus der Vertragstabelle. Teilen die Entwickler des Moduls Vertrag die Spalte für das Vertragsende später in zwei Spalten auf, scheitert die Abfrage im Modul Schaden. Erkennbar ist das an Abfragen im Modul Schaden, die Vertragstabellen nennen.
  • Absturz aller Module: Ein Modul verbraucht durch einen Fehler nach und nach den ganzen Arbeitsspeicher. Das Modul Zahlung behält zum Beispiel jeden Zahlungsauftrag im Arbeitsspeicher und gibt ihn nie wieder frei. Weil alle Module denselben Arbeitsspeicher nutzen, stürzt das ganze Programm mit allen Modulen ab. Erkennbar ist das an einem gleichzeitigen Ausfall von Schaden, Vertrag und Zahlung nach stetig steigendem Speicherverbrauch.
  • Aufgehaltene Auslieferung: Ein Fehler im Modul Schaden hält eine fertige Änderung im Modul Zahlung zurück, weil beide nur gemeinsam ausgeliefert werden können. Erkennbar ist das an fertigen Änderungen, die auf die Behebung von Fehlern in anderen Modulen warten.
  • Technisch geschnittene Module: Das Team teilt das Programm nach Oberfläche, fachlichen Regeln und Datenzugriff statt nach Fachgebieten. Jede fachliche Änderung betrifft dann alle Module. Eine neue Zahlungsart braucht zum Beispiel ein neues Feld in der Oberfläche, eine neue Regel und eine neue Spalte in der Datenbank. Das Team ändert dafür das Modul für die Oberfläche, das Modul für die Regeln und das Modul für den Datenzugriff. Erkennbar ist das, wenn eine kleine Änderung an der Zahlung Code in jedem Modul berührt.

Weiterführend

Verwandte Ansätze

  • Bounded Context: ein fachlicher Bereich mit eigenen Begriffen, der sich als Modulgrenze eignet.
  • Loose Coupling High Cohesion: der Grundsatz, dass Teile wenig voneinander abhängen und innen eng zusammengehören sollen.
  • Strangler Fig: beschreibt, wie man einzelne Funktionen schrittweise aus einer Anwendung herauslöst, falls getrennte Dienste später sinnvoll werden.

Werkzeuge und Umsetzungen

  • Spring Modulith: prüft in Spring-Anwendungen die Grenzen zwischen Modulen, verteilt Ereignisse zwischen ihnen und erzeugt eine Dokumentation der Module.
  • ArchUnit: prüft Zugriffe zwischen Modulen in automatischen Tests von Java-Programmen.
  • Packwerk: prüft Modulgrenzen in Anwendungen, die in der Programmiersprache Ruby geschrieben sind.
  • Java Platform Module System: in Java eingebaute Module, bei denen schon der Compiler unerlaubte Zugriffe ablehnt.

Quellen

  • Martin Fowler: MonolithFirst: Beitrag von 2015, der empfiehlt, neue Anwendungen zuerst als sorgfältig in Module gegliedertes Programm zu bauen, weil sich Grenzen zwischen Diensten am Anfang schwer richtig ziehen lassen.
  • Spring Modulith: Fundamentals: Projektdokumentation, die Module mit veröffentlichter Schnittstelle und internem Teil beschreibt und erklärt, dass die Prüfung von Spring Modulith auch Zugriffe auf interne Teile erkennt, die der Compiler zulässt.