Read in English · Leer en español · Lire en français
Abschnitt 5.3 von 5.8 · Mittelstufe · 26 Min. Lesezeit
Der modulare Monolith
Videolektion
5 Min. · auf Englisch gesprochen, mit UntertitelnNur anhören (Audio)
Transkript lesen
Ein modularer Monolith bietet viele der Vorteile, die man sich von Microservices verspricht, ohne ein verteiltes System zu betreiben. In dieser Lektion sehen Sie, wie er aufgebaut ist, wie seine Grenzen gewahrt werden und wo er am häufigsten scheitert.
Denken Sie an ein großes Kaufhaus. Es ist ein Gebäude, mit einem Eingang, einheitlichen Öffnungszeiten und einem Sicherheitsdienst. Innen ist es in Abteilungen gegliedert: Lebensmittel, Kleidung, Elektronik. Jede hat eigenes Personal, ein eigenes Lager und eine eigene Kasse. Braucht die Kleidung etwas von den Lebensmitteln, fragt sie an deren Theke, statt in deren Lager zu gehen. Ein modularer Monolith ist Software, die wie dieses Kaufhaus gebaut ist.
Ein modularer Monolith ist eine einzige Deployment-Einheit, deren oberste Ebene nach Fachdomänen in Module gegliedert ist: Bestellungen, Zahlungen, Lieferung, Speisekarten. Er verbindet die fachliche Partitionierung von Microservices mit der Einfachheit eines Monolithen: ein Build, ein Deployment, Aufrufe im Arbeitsspeicher und lokale Transaktionen.
So sieht einer von innen aus. Die äußere Grenze ist eine Deployment-Einheit, die Essensliefer-Anwendung, und darin liegen Module wie Bestellungen und Zahlungen. Jedes Modul hat eine kleine öffentliche API: die Funktionen, Daten und Ereignisse, deren Stabilität seine Verantwortlichen zusagen. Bestellungen nutzt Zahlungen nur, indem es diese API aufruft. Alles andere ist intern: das Domänenmodell, die Regeln und die Repositories. In die Interna eines anderen Moduls zu greifen ist verboten. Und jedes Modul besitzt sein eigenes Datenbankschema, ohne Joins über Schemas hinweg.
In einem Monolithen kann technisch jede Klasse jede andere aufrufen, daher halten Grenzen nur, wenn etwas sie durchsetzt. Sonst verfällt er innerhalb von ein, zwei Jahren zu einem Big Ball of Mud. Beginnen Sie mit der stärksten Kapselung, die Ihre Sprache bietet. Ergänzen Sie Architekturtests im Build, etwa ArchUnit für Java oder import-linter für Python, die fehlschlagen, wenn ein Modul die Interna eines anderen importiert. Frameworks können helfen. Spring Modulith prüft Modulregeln für Spring-Anwendungen, und Shopifys Packwerk prüft Abhängigkeiten zwischen Paketen in Ruby. Und ziehen Sie Datengrenzen: ein Schema pro Modul und idealerweise ein Datenbankbenutzer, der nur dieses Schema erreicht. Ein Code-Review verhindert keinen bequemen Join; Datenbankberechtigungen schon.
Bei der Datenhoheit scheitern modulare Monolithen am häufigsten. Einen Datenbankserver zu teilen ist in Ordnung; Tabellen zu teilen nicht. Jede Tabelle hat genau ein besitzendes Modul, und nur der Besitzer schreibt sie. Andere Module erhalten die Daten über die API des Besitzers oder aus Ereignissen, die er veröffentlicht. Ein Bericht, der die Tabellen dreier Module jointe, braucht nun API-Aufrufe oder ein eigenes Lesemodell. Das kostet wirklich etwas, aber es sind die Kosten, die ein späterer Umstieg auf Services mit sich brächte, früh und günstig bezahlt. Module kommunizieren auf zwei Arten, beide im Arbeitsspeicher. Rufen Sie auf, wenn Sie eine Antwort brauchen, etwa ob eine Zahlung autorisiert wurde. Veröffentlichen Sie ein Ereignis, wenn nicht, und wenn mehrere Module auf dieselbe Tatsache reagieren.
Hier ist ein ausgearbeitetes Beispiel. Ein Essensliefer-Start-up mit vier Teams, etwa fünfundzwanzig Entwicklern, möchte unabhängige Verantwortung, ohne Microservices zu betreiben. Der Architekt schlägt acht Module in einer deploybaren Python-Anwendung vor, mit einem Schema pro Modul. Bestellungen steht im Mittelpunkt. Es ruft drei Module über ihre APIs auf: Zahlungen zum Bezahlen, Speisekarten für die Preise und Kunden für die Adresse. Restaurants aktualisiert Speisekarten über die Speisekarten-API. Wird eine Bestellung aufgegeben, veröffentlicht Bestellungen ein Ereignis Order Placed, und Lieferung hört zu. Ebenso Reporting und Benachrichtigungen. Bestellungen muss nicht wissen, wer zuhört. Die Abhängigkeiten zeigen in eine Richtung, daher sind Zyklen unmöglich, und import-linter prüft das bei jedem Build. Alles wird als ein Container-Image ausgeliefert, betrieben als sechs Replikate, plus ein Worker für Ereignisse und Hintergrundjobs.
Ein bekannter realer Fall ist Shopify. Statt seinen riesigen Ruby-on-Rails-Monolithen in Microservices zu zerlegen, ordnete Shopify den Code ab 2017 in Komponenten um, die nach Fachdomänen benannt waren, etwa Bestellungen, Versand und Abrechnung. Ein einfacher Erfolg war es nicht: Jahre später hatte kaum eine Komponente eine vollständige, starke Grenze. Ein modularer Monolith braucht anhaltende Investitionen, und Grenzen nachträglich einzuziehen ist viel schwerer, als sie von Anfang an zu wahren. Der Lohn sind Optionen. Muss ein Modul später allein skalieren oder ausgeliefert werden, ist das Herauslösen viel leichter, wenn es schon eine explizite API hat, seine Daten besitzt und mit anderen nur über APIs und Ereignisse kommuniziert.
Der vollständige Abschnitt behandelt gute Modul-APIs, die Feinheiten von Ereignissen und Shopifys Geschichte ausführlicher. Als Nächstes, in Abschnitt fünf Punkt vier, wenden wir uns einer ganz anderen Form zu: Pipes and Filters. Bis gleich.
Was Sie wissen müssen
- Ein modularer Monolith ist eine einzige Deployment-Einheit, deren oberste Ebene nach Fachdomänen in Module unterteilt ist, jedes mit einer kleinen, expliziten öffentlichen API, privaten Interna und eigenen Daten.
- Er bewahrt die betriebliche Einfachheit eines Monolithen (ein Build, ein Deployment, prozessinterne Aufrufe, lokale Transaktionen) und bietet zugleich viele der Modularitätsvorteile, die man sich von Microservices verspricht.
- Grenzen erodieren, wenn sie nicht durchgesetzt werden: durch Sichtbarkeitsregeln der Sprache, Architekturtests (ArchUnit, import-linter), die Verifikation von Spring Modulith für Spring-Anwendungen, Packwerk für Ruby und getrennte Datenbankschemas je Modul.
- Jede Tabelle gehört genau einem Modul; andere Module nutzen dessen API oder reagieren auf dessen Ereignisse, greifen aber nie auf dessen Tabellen zu. Joins über Modulgrenzen hinweg werden durch API-Aufrufe oder Lesemodelle ersetzt.
- Module können über prozessinterne Aufrufe kommunizieren (einfach, unmittelbar, transaktional) oder über prozessinterne Ereignisse (lockerere Kopplung, aber die Arbeit des Listeners läuft getrennt ab und kann getrennt fehlschlagen).
- Shopify hat seinen großen Ruby-on-Rails-Monolithen ab 2017 in fachliche Komponenten umgebaut und dies 2019 beschrieben; spätere Beiträge zeigen sowohl den Wert des Ansatzes als auch, wie schwer vollständige Isolation ist.
- Ein gut abgegrenztes Modul lässt sich später mit überschaubarem Aufwand als Service herauslösen, weil seine API, seine Daten und seine Abhängigkeiten bereits explizit sind.
Definition: ein Deployment, viele Domänen
Ein modularer Monolith ist eine Anwendung, die als eine Einheit gebaut und deployt wird, deren Code aber auf oberster Ebene in Module unterteilt ist, die fachlichen Grenzen folgen: Bestellungen, Zahlungen, Lieferung, Speisekarten. Jedes Modul hat eine kleine, bewusst gestaltete Modul-API, die andere Module nutzen dürfen, einen größeren internen Teil, den niemand von außen anfassen darf, und eigene Daten. Er verbindet die fachliche Partitionierung von Microservices mit der einzigen Deployment-Einheit eines klassischen Monolithen.
Die Idee ist nicht neu. Module mit verborgenen Interna gehen auf David Parnas’ Information Hiding (1972) zurück, und große, gut strukturierte Monolithen hat es immer gegeben. Neu sind der Name und die Aufmerksamkeit. Nach einem Jahrzehnt, in dem viele Organisationen ihre Systeme in Microservices zerlegt und dabei die Kosten der Verteilung entdeckt haben, argumentierten Fachleute wie Simon Brown und Sam Newman, der modulare Monolith sei eine unterschätzte Option und viele Teams wären mit ihm besser bedient als mit einer Menge von Services. Richards und Ford widmen ihm in der zweiten Auflage von Fundamentals of Software Architecture (2025) ein eigenes Kapitel.
Der Unterschied zum Schichtenstil liegt im Schnitt auf oberster Ebene. Eine Schichtenanwendung ist in Präsentation, Geschäftslogik und Persistenz unterteilt, und jede Domäne verteilt sich über alle drei. Ein modularer Monolith ist nach Domänen unterteilt, und jedes Modul kann innen geschichtet sein. Der Unterschied zu Microservices liegt im Deployment: Die Module teilen sich einen Prozess, ein Release und meist einen Datenbankserver, und sie rufen einander im Arbeitsspeicher auf.
Aufbau eines modularen Monolithen
Modul-APIs und interner Code
Die mit Abstand wichtigste Regel lautet: Ein Modul wird nur über seine API genutzt. Die API ist die Menge der Funktionen, Data Transfer Objects und Ereignisse, deren Stabilität die Verantwortlichen des Moduls zusagen; alles andere, einschließlich Domänenmodell, Repositories und Datenbanktabellen, ist intern und darf sich jederzeit ändern. In der Praxis ist die API klein: Ein Zahlungsmodul bietet vielleicht authorise_payment, refund, einen Typ PaymentResult und ein Ereignis PaymentCaptured an und verbirgt Dutzende Klassen für Zahlungsanbieter, Wiederholungen, Buchungsjournale und Abstimmung.
Gute Modul-APIs haben einige Eigenschaften gemeinsam. Sie sprechen die Sprache der Domäne (authorise_payment, nicht insert_payment_row). Sie übergeben einfache Daten wie Kennungen und kleine unveränderliche Datensätze statt interner Objekte, damit Aufrufer nicht über ein zurückgegebenes Objekt in die Interna des Moduls greifen können. Sie sind grob genug, dass ein typischer Anwendungsfall ein oder zwei Aufrufe braucht, nicht zwanzig. Und sie sind so entworfen, als könnte eines Tages ein Netzwerk zwischen Aufrufer und Aufgerufenem liegen: keine gemeinsam genutzten veränderlichen Objekte, keine Abhängigkeit von der Datenbanktransaktion des Aufrufers, explizite Fehler. Diese letzte Eigenschaft hält die Option offen, das Modul später herauszulösen.
Innerhalb der Grenze ist alles erlaubt, was das Team des Moduls für nützlich hält: eine Schichtenstruktur, eine hexagonale, ein reichhaltiges Domänenmodell oder einfache Transaction Scripts. Verschiedene Module können sich unterschiedlich entscheiden, weil nichts außerhalb davon abhängt.
# delivery_platform/payments/__init__.py (the module's public API)
from .api import authorise_payment, refund, PaymentResult
from .events import PaymentCaptured
__all__ = ["authorise_payment", "refund", "PaymentResult", "PaymentCaptured"]
# delivery_platform/ordering/internal/checkout.py
from delivery_platform import payments # allowed: public API
# from delivery_platform.payments.internal import ledger # forbidden
def place_order(order, card_token):
result = payments.authorise_payment(order.id, order.total, card_token)
if not result.ok:
raise PaymentDeclined(result.reason)
order.confirm(result.payment_id)
events.publish(OrderPlaced(order.id, order.restaurant_id))Die Grenzen durchsetzen
In einem Monolithen kann technisch jede Klasse jede andere aufrufen, daher halten Grenzen nur, wenn etwas sie durchsetzt. Ohne Durchsetzung bleibt eine unter Termindruck gebaute Abkürzung in die Interna eines anderen Moduls im Review unsichtbar, und ein modularer Monolith verfällt innerhalb von ein, zwei Jahren zu einem Big Ball of Mud. Es gibt mehrere Verteidigungslinien, die man am besten gemeinsam einsetzt.
Sichtbarkeit in der Sprache. Nutzen Sie die stärkste Kapselung, die die Sprache bietet: paketprivate Klassen in Java, internal in Kotlin und C#, die exports des Java Platform Module System, getrennte Build-Module (Maven- oder Gradle-Teilprojekte, .NET-Projekte), deren Abhängigkeiten explizit deklariert werden. Python und Ruby bieten nur Konventionen (einen führenden Unterstrich, ein Unterpaket internal), daher müssen Werkzeuge die Arbeit übernehmen.
Architekturtests. Architekturtests prüfen Abhängigkeiten im Build. ArchUnit (Java) kann sicherstellen, dass keine Klasse außerhalb eines Moduls dessen interne Pakete berührt und dass die Modulabhängigkeiten zyklenfrei sind. import-linter (Python) bietet einen forbidden-Vertrag, der andere Module daran hindern kann, das interne Paket eines Moduls zu importieren, ihnen aber die Nutzung seiner öffentlichen API weiterhin erlaubt, einen layers-Vertrag, dessen Schichten mehrere voneinander unabhängige Geschwister enthalten können, getrennt durch |, womit sich eine Abhängigkeitsrichtung zwischen Modulen prüfen lässt, und einen independence-Vertrag für Pakete, die überhaupt nicht voneinander abhängen dürfen, auch nicht indirekt.
Unterstützung durch Frameworks. Spring Modulith für Spring-Boot-Anwendungen behandelt jedes direkte Unterpaket des Hauptpakets der Anwendung als Modul, das Basispaket des Moduls als dessen API und seine Unterpakete als intern, und bietet eine einzeilige Verifikation (ApplicationModules.of(Application.class).verify()), die fehlschlägt, wenn ein Modul auf die Interna eines anderen Moduls verweist, wenn Modulabhängigkeiten einen Zyklus bilden oder, falls ein Modul seine erlaubten Abhängigkeiten auflistet, wenn es eine nicht aufgeführte nutzt. Es kann außerdem Komponentendiagramme der Module im C4-Stil erzeugen. In der Ruby-Welt analysiert Shopifys quelloffenes Packwerk statisch die Verweise zwischen Paketen, die jeweils durch eine Datei package.yml deklariert sind, und meldet nicht deklarierte Abhängigkeiten.
Datengrenzen. Geben Sie jedem Modul ein eigenes Datenbankschema (oder zumindest eigene, klar mit Präfix versehene Tabellen) und idealerweise einen eigenen Datenbankbenutzer, der nur auf dieses Schema zugreifen kann. Ein Code-Review allein verhindert keinen bequemen JOIN über Modulgrenzen hinweg; Datenbankberechtigungen schon.
[importlinter:contract:payments-internals]
name = Only payments may use payments internals
type = forbidden
source_modules =
delivery_platform.ordering
delivery_platform.delivery
delivery_platform.reporting
forbidden_modules =
delivery_platform.payments.internal
# going through the public API (which itself uses internals) is fine
allow_indirect_imports = True
[importlinter:contract:module-order]
name = Module dependencies point one way
type = layers
layers =
delivery_platform.reporting | delivery_platform.notifications
delivery_platform.delivery
delivery_platform.ordering | delivery_platform.restaurants
delivery_platform.payments | delivery_platform.menus | delivery_platform.customersDatenhoheit innerhalb eines Monolithen
Bei der Datenhoheit scheitern modulare Monolithen am häufigsten. Einen Datenbankserver zu teilen ist in Ordnung; Tabellen zu teilen nicht. Liest das Liefermodul die Tabelle orders direkt, kann das Bestellteam diese Tabelle nicht mehr ändern, ohne sich mit dem Lieferteam abzustimmen, und die beiden Module sind so eng gekoppelt, als wären sie eins. Die Regel ist einfach formuliert: Jede Tabelle hat genau ein besitzendes Modul, nur der Besitzer schreibt sie, und andere Module beziehen die Daten über die API des Besitzers oder aus Ereignissen, die er veröffentlicht.
Die Regel hat Folgen, die Teams bewusst akzeptieren müssen. Ein Bericht, der früher eine einzige SQL-Abfrage über Bestellungen, Zahlungen und Kuriere war, braucht nun entweder Aufrufe an drei Modul-APIs oder ein Lesemodell: ein Reporting-Modul, das Ereignisse der anderen abonniert und eine eigene, denormalisierte, für Abfragen optimierte Kopie hält. Fremdschlüssel über Modulschemas hinweg werden meist entfernt, weil sie das Schema eines Moduls von dem eines anderen abhängig machen würden; die Integrität über Module hinweg sichert stattdessen die Anwendung. Diese Kosten sind real, aber es sind dieselben Kosten, die ein späterer Umstieg auf Services verursachen würde, nur früher und in einem günstigeren Umfeld bezahlt.
Ein Vorteil bleibt, den verteilte Systeme verlieren: Module in einem Prozess können, wenn es wirklich sein muss, eine einzige lokale Datenbanktransaktion teilen. Nutzen Sie das sparsam. Jede solche Nutzung koppelt die Daten der Module im Betrieb und muss rückgängig gemacht werden, falls ein Modul jemals herausgelöst wird.
Kommunikation zwischen Modulen: Aufrufe oder Ereignisse
Module kommunizieren auf zwei Arten, beide im Arbeitsspeicher. Ein prozessinterner Aufruf der API eines anderen Moduls ist einfach, schnell und unmittelbar: Das Bestellmodul ruft payments.authorise_payment auf und erhält sofort das Ergebnis, und eine Exception wird ganz normal weitergereicht. Das erzeugt eine direkte Abhängigkeit vom Aufrufer zum Aufgerufenen, und der Aufrufer kann erst fertig werden, wenn der Aufgerufene fertig ist. Nutzen Sie Aufrufe, wenn der Aufrufer eine Antwort braucht, um weiterzumachen, etwa ob die Zahlung autorisiert wurde.
Ein prozessinternes Ereignis kehrt die Abhängigkeit um. Das Bestellmodul veröffentlicht OrderPlaced und weiß nicht und kümmert sich nicht darum, wer zuhört; Lieferung, Benachrichtigungen und Reporting abonnieren es. Eine neue Reaktion hinzuzufügen heißt, einen Listener hinzuzufügen, ohne das Bestellmodul anzufassen. Nutzen Sie Ereignisse, wenn der Veröffentlichende keine Antwort braucht und wenn mehrere Module auf dieselbe Tatsache reagieren.
Ereignisse bringen Feinheiten mit sich, die Teams ohne Erfahrung damit oft übersehen. Läuft ein Listener innerhalb der Transaktion des Veröffentlichenden, kann ein Fehler im Listener die Bestellung zurückrollen; läuft er nach dem Commit, ist die Bestellung gespeichert, aber die Arbeit des Listeners kann verloren gehen, wenn der Prozess dazwischen abstürzt. Spring Modulith begegnet dem mit einer Event Publication Registry, die jedes Paar aus Ereignis und Listener in derselben Transaktion wie die fachliche Änderung festhält und erst als abgeschlossen markiert, wenn der Listener erfolgreich war, sodass unvollständige erneut versucht werden können; ein selbst gebautes Gegenstück ist die Transactional Outbox, beschrieben in Abschnitt 6.7. Listener sollten außerdem idempotent sein, damit ein wiederholtes Ereignis keinen Schaden anrichtet. Keiner der beiden Stile ist besser; die meisten modularen Monolithen nutzen Aufrufe für Abfragen und Befehle, die eine Antwort brauchen, und Ereignisse für Benachrichtigungen über bereits Geschehenes.
Shopifys modularer Monolith
Shopifys Kernanwendung ist eine der größten Ruby-on-Rails-Codebasen überhaupt, an der über mehr als ein Jahrzehnt mehr als tausend Entwickler gearbeitet haben. In Deconstructing the Monolith
(Shopify Engineering, Februar 2019) beschrieb Kirsten Westeinde, warum das Unternehmen sie nicht in Microservices zerlegte, sondern ab 2017 zu einem modularen Monolithen umbaute. Die Probleme waren bekannt: Code für voneinander unabhängige Geschäftsbereiche war verflochten, Änderungen hatten unerwartete Auswirkungen an anderer Stelle, und die Einarbeitung dauerte lange. Microservices wurden erwogen und als mit eigenen Kosten verbunden eingeschätzt (getrennte Pipelines und Infrastruktur, Netzwerklatenz und -ausfälle, schwierigeres Refactoring über Services hinweg), daher entschied sich das Team für Modularität ohne mehrere Deployment-Einheiten.
Der erste Schritt bestand darin, den Code in einer großen automatisierten Änderung vom Standardaufbau von Rails (Models, Views und Controller) in Komponenten umzuordnen, die nach Fachdomänen benannt waren, etwa Bestellungen, Versand, Lagerbestand und Abrechnung. Der nächste war die Isolation: Jede Komponente sollte eine öffentliche API und die alleinige Hoheit über ihre Daten erhalten, und ein internes Werkzeug, Wedge, verfolgte den Fortschritt jeder Komponente, indem es während der Testsuite Aufrufe über Grenzen hinweg aufzeichnete. Ein Update vom September 2020 meldete 37 Komponenten in einem Kernmonolithen von etwa 2,8 Millionen Zeilen Ruby, und im selben Monat veröffentlichte Shopify Packwerk als Open Source, ein Werkzeug zur statischen Analyse, mit dem sich Abhängigkeiten zwischen Paketen deklarieren und prüfen lassen.
Die Geschichte ist lehrreich, weil sie kein einfacher Erfolg ist. Das Update von 2020 hielt fest, dass die ursprünglichen Ideen noch galten, sich aber fast jedes Detail geändert hatte und kaum eine Komponente bereits eine vollständige, starke Grenze hatte. Ein Rückblick auf Packwerk von 2024 berichtete, dass dessen Privacy-Prüfungen entfernt worden waren, dass manche nach Namen statt nach Laufzeitverhalten organisierte Pakete Entwickler in die Irre führten und dass selbst die vollständige Isolation eines Basispakets viele Monate dauerte; Packwerk war zu einem Wächter über zentrale Grenzen geworden statt zum Kern der Strategie. Die Lehre für Architekten: Ein modularer Monolith ist eine Entwicklungsrichtung, die anhaltende Investitionen braucht, und Grenzen nachträglich in eine große bestehende Codebasis einzuziehen ist viel schwerer, als sie von Anfang an zu wahren.
Ausgearbeitetes Beispiel
Ausgearbeitetes Beispiel: die Essenslieferplattform als modularer Monolith
Ein Essensliefer-Start-up mit vier Teams (etwa 25 Entwicklerinnen und Entwicklern) möchte unabhängige Verantwortung ohne die Kosten des Betriebs von Microservices. Der Architekt schlägt acht Module in einer deploybaren Python-Anwendung vor, mit einem PostgreSQL-Server und einem Schema pro Modul.
| Kunden | Konten, Adressen, Präferenzen. API: get_customer, get_delivery_address. Team A. |
|---|---|
| Restaurants; Speisekarten (zwei Module) | Restaurants verwaltet Restaurantkonten und Öffnungszeiten und aktualisiert Speisekarten über die Speisekarten-API; Speisekarten liefert allen anderen Preise und Verfügbarkeit (price_items, is_open). Team B. |
| Bestellung | Warenkorb, Checkout, Lebenszyklus der Bestellung. Ruft Kunden, Speisekarten und Zahlungen auf; veröffentlicht OrderPlaced und OrderCancelled. Team C. |
| Zahlungen | Autorisierung, Einzug und Erstattungen über einen externen Anbieter. API: authorise_payment, refund; veröffentlicht PaymentCaptured. Team C. |
| Zustellung | Kurierzuordnung und Sendungsverfolgung. Lauscht auf OrderPlaced; veröffentlicht CourierAssigned und Delivered. Team D. |
| Benachrichtigungen und Reporting | Reine Listener: versenden Nachrichten und pflegen Lesemodelle. Sie rufen kein anderes Modul auf. Team A. |
| Abhängigkeitsregel (Dependency Rule) | Zahlungen, Speisekarten und Kunden hängen von niemandem ab; Bestellungen und Restaurants rufen sie auf; Lieferung reagiert auf die Ereignisse von Bestellungen; Benachrichtigungen und Reporting hören nur zu. Geprüft durch import-linter im Build. |
| Deployment | Ein Container-Image, betrieben als sechs Replikate hinter einem Load Balancer, plus ein Worker-Prozess (derselbe Code), der Event-Listener und Hintergrundjobs abarbeitet. |
Jedes Team besitzt ganze Module, daher landen die meisten Änderungen in einem Modul und bei einem Team.
Die Abhängigkeitsregel macht Zyklen unmöglich: Muss Zahlungen jemals etwas über Bestellungen wissen, erhält es eine Bestellkennung, keinen Import des Bestellmoduls.
Modulkarte der Essenslieferplattform
Merkmale des modularen Monolithen
| Merkmal | Bewertung | Begründung |
|---|---|---|
| Einfachheit | Mittel bis hoch | Ein Deployment und prozessinterne Aufrufe, aber gute Modulgrenzen zu finden erfordert Nachdenken. |
| Gesamtkosten | Hoch (günstig) | Infrastruktur und Betrieb eines Monolithen; etwas Mehraufwand für Werkzeuge zur Grenzprüfung. |
| Änderbarkeit bei fachlichen Änderungen | Hoch | Die fachliche Partitionierung hält die meisten Änderungen innerhalb eines Moduls, das einem Team gehört. |
| Testbarkeit | Mittel bis hoch | Module lassen sich über ihre APIs testen, wobei Nachbarn ersetzt werden; Tests des Gesamtsystems laufen in einem Prozess. |
| Deploybarkeit | Niedrig bis mittel | Weiterhin eine Deployment-Einheit: Jedes Release liefert alle Module aus, auch wenn ein kleinerer Blast Radius pro Änderung hilft. |
| Skalierbarkeit und Elastizität | Niedrig bis mittel | Replikate skalieren die ganze Anwendung; ein einzelnes Modul lässt sich nicht allein skalieren, doch Worker-Prozesse können Hintergrundlast abtrennen. |
| Fehlertoleranz | Niedrig | Ein Prozess: Ein Absturz oder Speicherleck in irgendeinem Modul betrifft alle. |
| Performance | Hoch | Aufrufe zwischen Modulen laufen im Arbeitsspeicher, ohne Serialisierung oder Netzwerklatenz. |
| Evolvierbarkeit | Hoch | Explizite APIs und Datenhoheit machen es leicht, Module später umzubauen oder herauszulösen. |
Wann man ihn einsetzt, wann nicht, und Anti-Patterns
Ein modularer Monolith ist eine starke Standardwahl für neue Produkte mit einigermaßen klarer Domäne, für Organisationen mit bis zu einer Handvoll Teams, für Systeme, die starke Konsistenz über Geschäftsbereiche hinweg brauchen, und für Teams mit begrenzter Betriebskapazität. Er ist auch das natürliche Ziel bei der Modernisierung eines verworrenen Monolithen: Grenzen darin einzuziehen ist meist billiger und weniger riskant, als ihn auf einen Schlag in Services zu zerlegen. Weniger geeignet ist er, wenn Teile des Systems wirklich unterschiedliche Qualitätsmerkmale brauchen (ein Strom von Kurierpositionen, der riesige Schreibvolumen aufnehmen muss, und ein Backoffice-Modul mit mäßiger Last), wenn Dutzende Teams viele Male am Tag unabhängig voneinander releasen müssen oder wenn Teile in verschiedenen Sprachen geschrieben sein oder aus Gründen der Datenresidenz an verschiedenen Orten laufen müssen. Diese Anforderungen verlangen getrennte Architekturquanten.
Häufige Anti-Patterns: Module nur dem Namen nach, bei denen die Ordner den Domänen folgen, aber alles alles importiert; die gemeinsame Datenbank als Selbstbedienungsladen, bei der Module ihren eigenen Code behalten, aber gegenseitig ihre Tabellen joinen; die geschwätzige Grenze, eine API, so feingranular, dass ein Anwendungsfall Dutzende Aufrufe braucht, was schmerzhaft wird, falls das Modul jemals herausgelöst wird; der wuchernde Shared Kernel, ein Modul common, das nach und nach Geschäftslogik aufsaugt und jedes Modul an sich koppelt; und Entitätsmodule, ein Schnitt nach Datenentitäten (ein Kundenmodul, ein Bestellmodul, ein Produktmodul, die jeder Anwendungsfall alle zugleich braucht) statt nach fachlichen Fähigkeiten, was Module hervorbringt, die allein nichts tun können.
Die Option offenhalten, Services herauszulösen
Ein modularer Monolith wird mal als Zwischenschritt zu Microservices dargestellt, mal als Ziel. Er kann beides sein, und der Punkt ist, dass sich die Entscheidung bis zum Last Responsible Moment aufschieben lässt. Muss ein Modul später getrennt skalieren oder unabhängig ausgeliefert werden, ist das Herauslösen viel leichter, wenn das Modul bereits eine explizite API hat, seine Daten besitzt und von anderen nur über APIs und Ereignisse abhängt. Die Arbeit wird dann weitgehend mechanisch: eine Netzwerkschnittstelle vor die API setzen, das Schema in eine eigene Datenbank verschieben, prozessinterne Ereignisse durch einen Message Broker ersetzen und jede gemeinsame Transaktion durch eine explizite Saga oder Kompensation ersetzen.
Sam Newman vertritt in Monolith to Microservices (2019) dasselbe Argument: Ein gut modularisierter Monolith ist der beste Ausgangspunkt für eine Zerlegung, weil der schwierige Teil, Grenzen zu finden und durchzusetzen, bereits erledigt ist. Abschnitt 5.8 behandelt die Anzeichen dafür, dass es Zeit zum Herauslösen ist, und Abschnitt 11.6 behandelt Techniken der schrittweisen Migration wie die Strangler Fig.
Prüfen Sie Ihr Verständnis
Was unterscheidet einen modularen Monolithen von einem geschichteten Monolithen?
Seine oberste Ebene ist nach Fachdomänen statt nach technischen Schichten unterteilt; jedes Modul hat eine explizite API, private Interna und eigene Daten und kann innen geschichtet sein.
Warum sollten Modul-APIs Kennungen und einfache Datensätze übergeben statt interner Domänenobjekte?
Zurückgegebene interne Objekte lassen Aufrufer in die Interna des Moduls greifen und koppeln sie an dessen Modell; einfache Daten lassen die Interna frei änderbar und halten die API bereit, später ein Netzwerk zu überqueren.
Ein Bericht braucht Daten aus den Schemas von Bestellungen und Zahlungen. Welche zwei Optionen respektieren die Datenhoheit?
Die APIs beider Module aufrufen und die Ergebnisse zusammenführen, oder in einem Reporting-Modul ein Lesemodell aufbauen, das auf die Ereignisse beider Module lauscht und eine eigene denormalisierte Kopie hält.
Welches Risiko bringt ein prozessinterner Event-Listener mit sich, der nach dem Commit der Transaktion des Veröffentlichenden läuft?
Stürzt der Prozess nach dem Commit, aber vor dem Lauf des Listeners ab, geht die Arbeit des Listeners verloren, sofern das Ereignis nicht dauerhaft festgehalten wird, etwa in einer Event Publication Registry oder einer Outbox, und erneut versucht wird.
Quellen und weiterführende Literatur
Haben Sie eine Frage zu diesem Abschnitt? ArchitectBot, der KI-Lernassistent des Kurses, antwortet zuerst aus dem Kurs.
Fragen: „Wir haben einen Django-Monolithen mit Apps für Bestellungen, Zahlungen und Lieferung, aber alle importieren gegenseitig ihre Models. Wie machen wir daraus Schritt für Schritt einen echten modularen Monolithen?“