Wer in den letzten Jahren eine grössere Applikation gebaut hat, hat diese vermutlich mit Microservices umgesetzt. Dieser Architekturstil wurde sehr erfolgreich in grossen Tech-Konzernen wie Netflix, Uber und Amazon eingeführt und hat eine regelrechte Revolution ausgelöst. Diese Big-Tech-Firmen hatten tausende Entwickler, die alle an derselben Codebasis arbeiteten. Grosse, monolithische Applikationen waren so komplex geworden, dass selbst kleinste Änderungen einen grossen Aufwand verursachten und ein hohes Risiko bestand, dass neue Bugs eingeführt worden wären.
Die vorgeschlagene Lösung war simpel und effektiv: Anstelle einer grossen, monolithischen Applikation hat man mehrere kleine, unabhängige Applikationen. Diese Micro-Applikationen sind so klein, dass eine Person sie komplett verstehen kann. Sie sind so unabhängig, dass ein Team daran arbeiten kann, ohne sich mit allen anderen Teams, die am selben System arbeiten, absprechen zu müssen. Das Versprechen war, dass solche Services schneller und effizienter entwickelt werden können, als wenn alle an derselben grossen Codebasis arbeiten.
Der Hype geht heute so weit, dass bereits für mittelgrosse Projekte eine Microservice-Architektur verlangt wird, selbst wenn nur ein Team daran arbeitet.
Das falsche Versprechen
Die Absicht dahinter ist gut, doch bei all dem Versprechen, komplexe Systeme mit einer Microservice-Architektur beherrschbar zu machen, wird gerne heruntergespielt, dass mit einem solchen Ansatz neue Probleme geschaffen werden. Die Top-5-Probleme hat Google in einem 2023 erschienenen Paper angesprochen:
- Eine Microservice-Architektur ist oft langsamer, da die einzelnen Services über ein Netzwerk miteinander kommunizieren und Daten mehrfach serialisiert werden müssen. Dies wird ein Flaschenhals, besonders wenn es viele Microservices gibt.
- Bei einem System müssen mehrere Microservices zusammenarbeiten und miteinander kommunizieren. So unabhängig wie in der Theorie sind diese Microservices meistens nicht. Für das Gesamtsystem ist es schwierig zu beurteilen, ob die verwendeten Services in der verwendeten Version zusammen korrekt funktionieren, selbst wenn die einzelnen Services korrekt funktionieren.
- Das Management des Systems ist aufwändiger. Jemand muss klären, welche Version von welchem Service mit den anderen Services kompatibel ist und verwendet werden soll. Es braucht Integrationstests mit allen Services. Wegen Abhängigkeiten benötigen Updates eines Service auch Updates von anderen Services. Für das Deployment und den Betrieb verteilter Systeme braucht es neue, komplexe Technologien. Fehler in einem verteilten System zu finden ist deutlich aufwändiger, weil es mehr bewegliche Teile gibt.
- Die Schnittstellen müssen Entwicklerteams untereinander aushandeln. Nicht nur einmal, sondern bei jeder Anpassung erneut. Dieses Aushandeln der Schnittstellen reduziert die Entwicklungsgeschwindigkeit deutlich, besonders am Anfang eines Projekts.
- Das führt auch dazu, dass Schnittstellen sehr früh statisch werden. Das heisst, mögliche Verbesserungen werden nicht oder erst viel später umgesetzt, weil entweder schon mehrere andere Teams die Schnittstelle verwenden oder man nicht mit einem anderen Team neu verhandeln möchte.
Modularität geht auch anders
Einen Teil dieser unerwünschten Komplexität kann man eliminieren, wenn man alle Microservices in dasselbe Code-Repository verpackt und immer als Einheit veröffentlicht. Eine weitere Reduzierung der Komplexität erreicht man, wenn die Services nicht über ein Netzwerk kommunizieren, sondern über einen internen Bus.
Ein solches Gebilde nennt man modularer Monolith.
Dieser verwendet das Konzept der Modularisierung aus der Microservice-Architektur, aber behält alles in einer Codebasis. Einzelne Module dürfen nur über definierte Schnittstellen miteinander kommunizieren, Transaktionen über Modulgrenzen hinweg sind wie bei Microservices nicht erlaubt. Eine automatische Prüfung forciert die Einhaltung dieser Regeln. Das Einzige, was man aus technischer Sicht mit diesem Ansatz verliert, ist die dynamische Skalierung der einzelnen Services. Bis zur ersten Million Nutzer sollte das aber keine Rolle spielen. Falls nötig, kann ein Modul später ohne grossen Aufwand in einen separaten Service ausgelagert werden.
Weil alle Komponenten auf der gleichen Codebasis beruhen, kann ein Team bei einer Änderung einer Schnittstelle die entsprechende Schnittstelle im Code eines anderen Teams gleich mit anpassen.
Das Deployment ist ebenfalls atomar: Alle Module werden gemeinsam veröffentlicht. Somit braucht es keine Verwaltung von kompatiblen Versionen, weil es keine unterschiedlichen Versionen gibt.
Ein wichtiger Punkt bei dieser Architektur ist auch, dass es im Gegensatz zu Microservices und klassischen Monolithen keine 1:1-Beziehung zwischen Code und Deployment gibt. Aus einer Codebasis können mehrere Services gebaut und deployt werden.
Das populäre Java-Framework Spring bietet eine Unterstützung für diesen Ansatz mit dem Modul Spring Modulith. Es bietet einen eigenen Event-Bus für die zuverlässige Kommunikation zwischen den Modulen sowie ein Test-Modul basierend auf ArchUnit, das verhindert, dass Module auf internen Code von anderen Modulen zugreifen. Es visualisiert auch die Architektur zum einfacheren Verständnis.
Dass ein solches Konzept gut funktioniert, zeigen die grossen Monorepos von Google und Meta. Fast alle Applikationen dieser Konzerne sind seit Jahren in einer einzigen Codebasis, welche mit Versionierung mehrere Terabyte gross ist, zusammengefasst.
Martin Fowler, einer der frühen Verfechter der Microservice-Architektur, sagte, dass alle erfolgreichen Microservice-Architekturen, die er gesehen hat, aus einer monolithischen Architektur hervorgegangen sind. Projekte, die mit einer Microservice-Architektur angefangen haben, seien alle in ernste Schwierigkeiten geraten.
Wenn also ein neues Projekt ansteht, sollte man die Option eines modularen Monolithen genau prüfen.