In der Software-Architektur von Webanwendungen hat sich in den vergangenen Jahren ein Paradigmenwechsel vollzogen. Weg von monolithischen Strukturen hin zu heterogenen Architekturen, die sich durch die Implementierung verschiedener Microservices auszeichnen. Auch in größeren, komplexen, serverlosen und Cloud-native Webanwendungen für Amazon Web Services (AWS) wird dieses Vorgehen empfohlen. Dabei wird üblicherweise der Infrastrukturcode für unterschiedliche Technologien, der Businesscode sowie die notwendigen CI/CD-Prozesse mithilfe des AWS Cloud Development Kit (CDK) je Service in einer Applikation zusammengefasst. Diese Applikationen werden typischerweise jeweils in einem eigenen Repository untergebracht.
Die Aufteilung in verschiedene Microservices unter Anwendung von Domain-Driven Design erweist sich selbst für individuelle Teams, innerhalb von Teilprojekten, oder für kleinere Projekte als sinnvoll. Manchmal übernimmt ein Team auch aus organisatorischen Gründen die Verantwortung für mehrere Services.
Parallel zu dieser Strukturierung gibt es jedoch auch immer wiederkehrende Herausforderungen im Bereich Infrastrukturcode und technischer Lösungen für Querschnittsaspekte, die eine einheitliche und zentralisierte Bereitstellung erfordern. Hierbei entsteht für einzelne Teams jedoch ein erheblicher Mehraufwand in Bezug auf die Bereitstellung verschiedener Bibliotheksversionen, die Verwaltung von Abhängigkeiten und die Pflege von CI/CD-Prozessen in unterschiedlichen Repositories.
Ein Ansatz zur Bewältigung dieser Herausforderungen besteht darin, die Verwendung eines Monorepos in Betracht zu ziehen. Im Kontext von Microservices-Architekturen auf Basis des CDK stellt sich die Frage: Warum nicht alle Services und Abhängigkeiten in einem gemeinsamen Repository zusammenfassen, ohne jedoch eine monolithische Struktur zu schaffen?
Abbildung 1: Von Monolithen zu Microservices in Multi-Repos und wieder zurück zu Monorepos?
Abbildung 1 zeigt beispielhaft ein derartiges Szenario. Eine ursprünglich entwickelte und gewachsene Anwendung, die sich in einem Repository als Monolith befand, wurde zunächst in eine Microservices-Architektur mit mehreren Repositories umgewandelt. Diese Architektur besteht aus verschiedenen CDK-Applikationen und Bibliotheken. Schließlich erfolgte im Rahmen der Software-Evolution die Zusammenführung der einzelnen Komponenten und Services in ein Monorepo. Dies ist lediglich ein Beispiel aus einem existierenden Projekt und keine generelle Empfehlung, obwohl solche Migrationen in der Praxis häufig vorkommen. Bei Projektbeginn sollte man sich bewusst über die geeignete Strategie sein und kann, nach sorgfältiger Abwägung, direkt mit dem Monorepo-Ansatz starten. Alternativ kann es jedoch auch klare Gründe gegen ein Monorepo geben und eher für einen Multi-Repo-Ansatz sprechen. Betrachten wir zunächst näher, warum wir die Entscheidung für Monorepos treffen möchten und welche Konsequenzen sich daraus ergeben. Im zweiten Teil werden wir genauer beleuchten, wie eine Monorepo Strategie effektiv mit AWS CDK und dem Buildsystem Nx funktionieren kann.
Vorteile von Monorepos
Die zentrale Stärke eines Monorepos liegt in seiner Funktion als alleinige Quelle der Wahrheit. Hieraus ergeben sich diverse weitere Vorteile:
- Vereinfachtes Abhängigkeitsmanagement: Das Repository beherbergt die gesamte Codebasis und jede Komponente ist mit ihrer Hauptversion integriert, was das Abhängigkeitsmanagement erheblich vereinfacht. Dies macht den Einsatz von Artefakt-Repositories (z.B. CodeArtifact, Nexus) überflüssig.
- Vereinfachter Zugriff: Teams können mühelos zusammenarbeiten, da sie Einblick in das gesamte Repository haben.
- Groß angelegte Code-Refaktorisierung: Atomare Commits im gesamten Code erleichtern moduleübergreifende Refaktorisierungen und Implementierungen erheblich.
- Continuous Deployment Pipeline: Keine bzw. wenig neue Konfiguration erforderlich, um weitere Services hinzuzufügen.
Umgang mit möglichen Nachteilen von Monorepos
Natürlich gibt es auch Konsequenzen bei der Verwendung von Monorepos. Einige Nachteile können von vornherein aufgefangen werden, wenn bestimmte Voraussetzungen erfüllt sind. Dazu gehören idealerweise eine Einigung auf die verwendeten Technologien und die Wahl ähnlicher CI/CD-Prozesse über alle Services hinweg. Auch muss berücksichtigt werden, dass die Zugriffkontrolle i.d.R. nicht feingranular eingestellt werden kann und jeder Zugriff auf die gesamte Codebasis hat. Dies kann besonders wichtig werden, wenn mehrere Teams in einem Monorepo arbeiten. Andernfalls können viele der Vorteile von Monorepos schnell verloren gehen und ein Multi-Repo-Ansatz könnte besser geeignet sein.
Es gibt drei weitere Einschränkungen oder Gefahren, die berücksichtigt werden müssen:
Eingeschränkte Versionierung
Alle Service liegen immer in ihren jeweiligen aktuellen Versionen vor. Ein Service kann nicht einfach eine ältere Version eines anderen Services referenzieren. Bei der Konzeption von Microservices ist daher besonderes Augenmerk auf die Schnittstellenverträge der Services innerhalb des Monorepos zu legen. Diese sollten im Idealfall semantisch versioniert und dokumentiert werden. Etablierte Standards wie OpenAPI, GraphQL und JSON Schema unterstützen dies. Bei der Verwendung gemeinsamer Bibliotheken ist auf Abwärtskompatibilität zu achten, da Änderungen sonst Anpassungen in allen Modulen erfordern, die die Bibliothek verwenden.
Hohe Kopplung möglich
Die Vorteile eines Monorepos, nämlich die schnelle und effiziente Zusammenarbeit durch eine zentrale Codebasis, können sich schnell ins Gegenteil verkehren. Dies geschieht, wenn Services direkt auf Bausteine anderer Services referenzieren oder wenn die Wiederverwendbarkeit von Code falsch verstanden wird und dazu führt, dass die Geschäftslogik der Services in gemeinsame Bibliotheken ausgelagert wird. Dadurch entsteht schnell eine Architektur mit hoher Kopplung zwischen den einzelnen Bausteinen. Insbesondere unter Zeitdruck bei der Entwicklung neuer Features ist die Versuchung groß, technische Schulden einzugehen. Häufen sich diese, besteht die Gefahr, dass aus Angst vor Breaking Changes keine Refactorings mehr durchgeführt werden, was wiederum die Wartbarkeit des Gesamtsystems erheblich beeinträchtigt.
Es ist daher wichtig, klare Regeln zu definieren und sicherzustellen, dass die Einhaltung dieser Regeln idealerweise durch statische Codeanalysen überwacht und gemessen wird. Es ist anzustreben, dass die Services während der Build-Zeit keine Abhängigkeiten untereinander haben und stattdessen zur Laufzeit über klar definierte Schnittstellen miteinander kommunizieren. Die Schnittstellenverträge können effizient als Bibliotheken zentral im Monorepo abgelegt werden.
Monolithische, langwierige CI/CD-Prozesse
Wenn sich der gesamte Code in einem einzigen Repository befindet, müssen die CI/CD-Prozesse für automatisiertes Testen, statische Codeanalyse, Build und Deployment im schlimmsten Fall bei jeder Änderung den gesamten Code durchlaufen. Mit wachsender Projektgröße führen diese längeren Wartezeiten zu Frustration, was sich negativ auf die Teamleistung auswirken kann. In einer Microservices-Architektur sollte dies jedoch nicht der Fall sein, denn dies widerspricht dem Ziel, jeden Service individuell zu betrachten. Bei Codeänderungen in einem Service sollten nur die notwendigen CI/CD-Prozesse für diesen Service und seine abhängigen Bibliotheken durchgeführt werden. Die Entwicklung sollte so schnell und isoliert erfolgen, als würde man nur an der Codebasis eines Services in einem Repository arbeiten. Es gibt geeignete Werkzeuge wie Nx um dies in einem Monorepos zu realisieren.
Mehr dazu in Teil 2.