TL;DR

In der modernen Softwareentwicklung haben sich Microservices im Allgemeinen und Self-contained Systems als spezieller Ansatz für das Web etabliert. Module zu eigenständigen Deployment-Artefakten zu machen, verspricht technische Vorteile: unabhängige Deployments, gezielte Skalierbarkeit und bessere Wartbarkeit. Doch die technische Trennung hat auch Nachteile: Zum einen können Module, die Teil eines System of Systems sind, per Definition nicht unabhängig voneinander existieren. Zum anderen müssen einmal auseinandergeschnittene Systeme für Benutzerinnen und Benutzer wieder zusammengeführt werden, um eine möglichst angenehme, einheitliche User-Experience zu schaffen. Verteilte Systeme sprechen demnach aus verschiedenen Gründen miteinander. Um die erforderliche technische Kommunikation zu ermöglichen, gibt es mehrere Lösungsansätze, und es ist nicht trivial, für den konkreten Anwendungsfall den richtigen zu finden.

CAP der guten Hoffnung

Das CAP-Theorem (Abbildung 1) besagt, dass verteilte Systeme nur zwei der drei wünschenswerten Eigenschaften Konsistenz (Consistency), Verfügbarkeit (Availability) und Ausfallsicherheit (Partition Tolerance) erfüllen können.

Da die Einhaltung von Konsistenz- und Verfügbarkeitsgarantien bei nicht gewährleisteter Ausfallsicherheit gerade bei Webprojekten irrelevant ist, ist die oben genutzte populäre Beschreibung des Theorems in der Praxis irreführend. Die zu treffende Entscheidung besteht letztlich darin, ob das System in einem Ausfallszenario Consistency oder Availability gewährleistet.

Bei der Entscheidung für einen bestimmten Integrationsmechanismus zwischen Modulen in einem System sollte das CAP-Theorem eine Rolle spielen, denn es verhindert eine unterkomplexe Sicht auf die Welt verteilter Systeme. Tägliche Ausfälle von Netzwerkknoten gehören zum Alltag von IT-Abteilungen und sollten von Softwareschaffenden nicht ignoriert werden.

Diagram des CAP-Theorems mit den Beschriftungen 'Partition Tolerance', 'Consistency' und 'Availability' an den drei Spitzen eines Dreiecks.
Abbildung 1: Das CAP-Theorem besagt, dass es in verteilten Systemen eine Entscheidung zwischen Partition Tolerance + Consistency und Partition Tolerance + Availability geben muss.

Die Auswirkungen von Kopplungen

Eine starke Kopplung führt dazu, dass eine Änderung in einem Modul weitere Änderungen in anderen nötig macht. Was schon in monolithischen Systemen ein Problem ist, zeigt sich in verteilten und verteilt entwickelten Systemen noch deutlicher: Starke Kopplung erfordert hohen Kommunikationsaufwand und führt zu längeren Release-Zyklen. Eine Microservice-Architektur entwickelt sich durch zu starke Kopplung der einzelnen Systeme miteinander zu einem verteilten Monolithen, der die schlechten Eigenschaften beider Architekturansätze miteinander vereint.

Ein Modul kann als Teil eines System of Systems nicht vollkommen unabhängig existieren, der Grad der Kopplung sollte aber so klein wie möglich sein. Da die Entscheidung für einen Integrationsmechanismus den Grad maßgeblich beeinflussen kann, ist eine sorgfältige, auf ausgesuchten Qualitätskriterien basierende Entscheidungsfindung enorm wichtig.

Abbildung 2 zeigt am Beispiel von Shared Code, wie der Einsatz spezifischer Technologie den initialen Aufwand verringert, dafür aber den Grad an Kopplung erhöht. Den initialen Aufwand gering zu halten, ist ein Beispiel für ein schlecht gewähltes Qualitätskriterium, da dies ein einmaliger Effekt ist und sich negativ auf die mittel- bis langfristige Entwicklung auswirkt.

Diagramm mit den Achsen 'Initialer Aufwand' und 'Kopplung', unterteilt in 'Shared Docs' mit 'Geteilte Spezifikation im Dokumentenformat' und 'Shared Code' mit drei Elementen: 'Geteilte Konfiguration (Entwicklungszeit)', 'Geteilte Implementierung (Programmiersprache)' und 'Geteilte Implementierung (Framework)'.
Abbildung 2: Mögliche Implementierungen von Shared Code und deren Einfluss auf den initialen Aufwand und den Grad an Kopplung.

Lauf- oder Entwicklungszeit?

Es gibt viele verschiedene Ansätze, verteilte Systeme miteinander zu integrieren. Sehr abstrakt lassen sie sich in zwei Kategorien unterteilen: Während die Integration zur Entwicklungszeit in Bezug auf das CAP-Theorem eine klare Entscheidung für das Merkmal Availability ist, gilt es bei der Integration zur Laufzeit weiter zu differenzieren.

Die Integration zur Entwicklungszeit geschieht in der Regel über geteilten Code. Man stellt entweder Bibliotheken mit einem Paketmanager zur Verfügung oder nutzt Versionskontrollsysteme. Das löst Abhängigkeiten auf, bevor ein Deployment-Artefakt des integrierenden Systems in Produktion geht – spätestens während des Build-Prozesses.

Da es sich bei der Integration zur Entwicklungszeit um eine klare Entscheidung für das Merkmal Availability handelt, garantiert das System keine Konsistenz über die integrierten Inhalte; deren Aktualisierung geschieht frühestens mit der nächsten Auslieferung in die Produktion.

Ein typischer Anwendungsfall dieser Art der Integration stellt die Nutzung einer systemübergreifenden Pattern-Library dar, die den Teilsystemen UI-Komponenten zur Verfügung stellt. Das führt zu einheitlicher User-Experience über alle Teilsysteme hinweg und senkt den Aufwand bei den Entwicklungsteams. Hier ist die Konsistenz hoch, da unterschiedliche Farbtöne oder die Positionierung eines Buttons wenig Einfluss auf die Funktionsfähigkeit der Software haben.

Mechanismen zur Laufzeitintegration lassen sich in zwei weitere Kategorien unterteilen: synchron und asynchron. Beide haben ein Merkmal gemeinsam: Sie lösen Abhängigkeiten erst während des Betriebs in einer Laufzeitumgebung auf. Daraus folgt ein etwas höherer Kopplungsgrad im Vergleich zu dem Kopplungsgrad, der bei einer Integration während der Entwicklungszeit entsteht. Denn Änderungen an der Schnittstelle eines Systems können das andere System beeinträchtigen, ohne dass das bereits während des Entwicklungs- oder Deployment-Prozesses erkannt wird.

Wird ein System asynchron zur Laufzeit integriert, geschieht das zeitlich unabhängig von User-Interaktionen. Auslöser können beispielsweise Benachrichtigungen über Ereignisse in anderen Systemen oder geplante Zeitintervalle sein. Nach dem Auslösen greift das integrierende Modul Daten des zu integrierenden Moduls ab und übersetzt sie in ein Modell, das im Hinblick auf funktionale und cross-funktionale Anforderungen auf seinen Bedarf zugeschnitten ist. Die Entscheidung für asynchrone Laufzeitintegration ist eine Entscheidung für das Merkmal Availability. Für eine hohe Verfügbarkeit und – im Gegensatz zu synchroner Laufzeitintegration – geringe Laufzeitkopplung wird Eventual Consistency in Kauf genommen. Das bedeutet, dass Konsistenz nicht sofort vorhanden ist, aber dafür das Versprechen auf einen konsistenten Zustand über Module hinweg zu einem Zeitpunkt in der Zukunft. Dies kann situationsabhängig innerhalb von Millisekunden, Minuten oder Stunden der Fall sein.

Synchrone Laufzeitintegration ist im Gegensatz zu ihrer asynchronen Schwester häufig an die User-Interaktionszeit gekoppelt. Jede Interaktion einer Benutzerin oder eines Benutzers mit dem System stellt einen Auslöser dar: Sei es das Laden einer Website oder der Klick auf einen Hyperlink. Nach dem Eintreten des Auslösers lädt das System beispielsweise frontendseitig Inhalte aus einem anderen System nach oder schickt gar eine ganze Reihe an logisch aufeinanderaufbauenden Anfragen an andere Systeme. Eine Entscheidung für eine synchrone Laufzeitintegration ist eine für Consistency, denn jede Anfrage hat aufgrund des Verhaltens der zu integrierenden Module Einfluss auf Availability. Die Auswirkung auf die Laufzeitkopplung zwischen den Modulen ist höher und kann abhängig vom spezifischen Integrationsmechanismus marginal bis dramatisch sein.

Frontendintegration und Micro-Frontends

Neben den theoretischen Architekturentscheidungen gibt es eine Reihe technischer Fragen zu lösen: Dabei ist eine wichtige Überlegung, Mechanismen zur Integration im Frontend und im Backend zu unterscheiden. Die im World Wide Web wohl am meisten verbreitete Methode zur Frontend-Integration sind Hyperlinks: Das integrierende Modul verweist mit einem HTML-a-Tag auf eine URL, die das integrierte Modul zur Verfügung stellt. Beim Klick auf den Link, öffnet der Browser das hinter der URL liegende Dokument, hinter dem sich wiederum das Frontend des anderen Moduls verbirgt. Auch die Datenübertragung via HTML-form-Element ist eine bereits im HTML-Standard verankerte Möglichkeit, die eine Übertragung von Medientypen, außer reinem Text, erlaubt. Ein großer Vorteil der Frontendintegration über Hyperlinks oder HTML-Formulare liegt in den etablierten Standards und der daraus folgenden niedrigen Komplexität. Durch die direkte Nutzung der zugrundeliegenden Plattform kommt eine weitgehend technologieagnostische Integration und somit geringe Kopplung zustande.

Die Integration via Links erschlägt jedoch nicht jede Anforderung an Usability: Soll die Seite Inhalte aus der Verantwortlichkeit eines anderen Moduls anzeigen, um gegebenenfalls Kontext aus vorausgegangenen Schritten eines Prozesses zu liefern, reichen die Fähigkeiten von HTML-Links und -Formularen nicht mehr aus.

Micro-Frontends sind ein aktuell polarisierendes Thema für die Lösung dieser Probleme. Sie greifen die Idee der Self-contained Systems auf: Möglichst unabhängige Teams sollen komplexe, monolithische Systeme anhand klarer fachlicher Grenzen getrennt entwickeln. Jedes Modul stellt bestimmte, zu seinen fachlichen Anforderungen passende Frontend-Fragmente – auch Micro-Frontends genannt – zur Verfügung (siehe Abbildung 3). Andere Module können diese Micro-Frontends in ihrem Frontend integrieren. Die Einbindung dieser Fragmente sollte zugunsten loser Kopplung möglichst technologie-agnostisch geschehen.

Diagram mit vier Systemen: System A (links) verbindet sich durch Pfeile mit System B, System C und System D (rechts). System D zeigt eine Rückverbindung zu System A.
Abbildung 3: Im Frontend von System A werden Frontend-Fragmente aus System B, C und D eingebunden.

Es ist möglich, sie mit dem im HTML Standard verankerten iframe zu implementieren. Das ist aufgrund diverser Nachteile im responsiven Styling und ihrer eingeschränkten Kommunikationsmöglichkeiten nicht grundlos umstritten. Dennoch kann es manchmal sinnvoll sein, insbesondere aufgrund ihrer einfachen Schnittstelle, ihrer breiten Browserunterstützung und der Freiheit von zusätzlichen Abhängigkeiten in einigen Anwendungsfällen.

Transklusion ist eine mögliche Alternative zum iFrame. Hierbei rendert eine Seite ein von einem Fremdsystem zur Verfügung gestelltes HTML-Fragment client- oder serverseitig im eigenen DOM. Bei clientseitiger Transklusion bedienen Developer sich wieder eines Webstandards und ersetzen einen Hyperlink via AJAX durch das hinter der Ziel-URI befindliche HTML. Diese Methode benötigt nur einen minimalen Anteil an JavaScript und eignet sich sehr gut für Progressive Enhancement: Alle grundlegenden Funktionen sind für alle Benutzerinnen und Benutzer unabhängig von Browserversion oder -einstellungen nutzbar, während erweiterte Funktionalität wie UX-Verbesserungen nur für modernere Systeme bereitgestellt werden.

Listing 1 zeigt, dass nur wenig JavaScript-Code notwendig ist, um Inhalte clientseitig zu transkludieren. Das prototypische Minimalbeispiel mithilfe des Web-Component-Standard zeigt, dass das Skript durch die Nutzung des a-Tags innerhalb der Komponente anstatt einer via Parameter übergebenen URL Progressive Enhancement unterstützt.

Listing 1:

class Transclude extends HTMLElement {
    connectedCallback() {
        const link = this.querySelector("a");
        fetch(link.href)
            .then(response => response.text())
            .then(html => this.outerHTML = html);
    }
}

customElements.define("transclude", Transclude);

Für serverseitige Transklusion kommen Techniken wie Server Side Includes (SSI) oder Edge Side Includes (ESI) zum Einsatz. Developer nutzen statt eines a-Tags eine von der entsprechenden Technologie spezifizierte Markup-Language. Im Falle von ESI sieht das wie folgt aus: <esi:include src="my-targeturi" alt="alt-target-uri" onerror="continue"/>. Zwischen Client und Server sitzt ein Reverse-Proxy, der das vom Server gelieferte Markup an den entsprechend gekennzeichneten Stellen mit den Zielinhalten anreichert, bevor er die Antwort an den Client liefert. Einige Web- und Application-Server wie der Apache HTTP Server oder Tomcat unterstützen SSI bei entsprechender Konfiguration auch direkt.

Ein weiterer Mechanismus zur Integration von Modulen, der je nach Funktionsweise der Implementierung der Laufzeitintegration oder der Integration zur Entwicklungszeit zugeordnet wird, sind Rich Components. Das sind eigenständige JavaScript-Komponenten, die ein bestimmtes Subset an Geschäftsoder Darstellungslogik enthalten und die Module für die Integration in andere Module zur Verfügung stellen. Für die Implementierung eignet sich wahlweise der mittlerweile sehr mächtige WebComponent-Standard oder die proprietäre API eines gängigen SPA-Frameworks.

Rich Components lassen sich entweder über das script-Tag von einer Remote-Quelle oder über einen Paketmanager wie npm in das eigene Projekt einbinden und nutzen. Ob es sich dabei um Entwicklungs- oder Laufzeitintegration handelt, entscheidet die Funktionsweise der Komponenten. Kommt ein Wrapper um die Remote-API eines Fremdsystems herum zum Einsatz, wird die Komponente zwar zur Entwicklungszeit als Abhängigkeit eingebunden, erzeugt aber dennoch Kopplung zur Laufzeit, da sie während der User-Interaktionszeit weitere synchrone Abhängigkeiten auflöst. Sie ist ein typischer Fall für den Wolf im Schafspelz, insbesondere da sie zu mehr Kommunikationsaufwand und somit eben zu einer stärkeren Kopplung führt als andere Integrationsmechanismen, deren Funktionsweise eindeutig ist. Seit 2020, dem Release der fünften Major-Version des beliebten JavaScript-Bundlers Webpack, hat mit Module Federation ein neuer Mechanismus zum Teilen von Assets für einen Hype gesorgt. Webpack Module Federation ermöglicht es verteilten Systemen, Web-Assets wie JavaScript und CSS zur Laufzeit über HTTP-Endpunkte miteinander auszutauschen. Einzelne Rich Components lassen sich so zur Laufzeit und zu präzisen Zeitpunkten einbinden.

Vereinfacht ausgedrückt ist Module Federation ein Paketmanager zur Laufzeit mit allen Vor- und Nachteilen synchroner Laufzeitintegration. Vor allem die Notwendigkeit zur expliziten Auflösung transitiver Abhängigkeiten zur Laufzeit sorgt für eine außerordentlich starke Kopplung. Probleme, die diese Technologie bereinigen soll, sind beispielsweise ein großer Footprint durch die Nutzung großer, aber möglicherweise ungenutzter Module im eigenen Bundle oder verschiedene Major-Abhängigkeiten von Frameworks. Oft lassen sich Probleme dieser Art aber auf andere technische oder organisatorische Entscheidungen zurückführen: zum Beispiel einen Team- und Systemschnitt, der sofortigen Konsistenzanforderungen nicht entspricht, oder die forcierte Nutzung spezifischer Frameworks und Bibliotheken.

Mechanismen zur Backend-Integration

In Zeiten von Single-Page Applications (SPA) und täglich neuen Trends im Bereich der Frontend-Entwicklung gerät das Backend als Integrationsebene oft in Vergessenheit. Dabei gibt es viele Gründe, auf Backend-Integration zu setzen, wenn da oberste CAP-Ziel nicht Consistency sein soll. Fällt die Entscheidung zugunsten von Availability aus, hält die Integration über das Backend attraktive Möglichkeiten bereit. Eine davon nennt sich Datenreplikation oder redundante Datenhaltung, was in der Umsetzung bedeutet, dass jedes Modul die Daten, die es selbst benötigt, in einem für die eigenen Zwecke optimierten Format vorhält.

Die Produktsuche eines Onlineshops benötigt beispielsweise ein Subset der Produktdaten einer Produktdetailseite. Divergierende Anforderungen an Lesezugriffe benötigen neben abweichenden Datenformaten möglicherweise andere Speichertechnologien – die Produktsuche würde ihre Produktdaten mithilfe einer Search Engine verarbeiten. Die Detailseite hingegen soll auf hohe Performance optimiert sein, was einen hochperformanten Key-Value-Store wie redis erfordert (Abbildung 4).

Diagramm mit 'Produktsuche' und 'Produkt-Detailseite', jeweils mit Produkt-Datenfeldern und den Datenbanken 'ElasticSearch' und 'redis'.
Abbildung 4: Die Module Produktsuche und Produkt-Detailseite halten Produktdaten aufgrund divergierender Anforderungen in anderen Modellen und mit anderen Technologien vor.

Die Kommunikation zwischen Modulen erfolgt hierbei wahlweise über Messaging oder über synchrone Schnittstellen. Messaging ermöglicht bidirektionale, asynchrone Kommunikation: Es können sowohl Nachrichten verschickt als auch empfangen werden. Nutzen Softwarearchitektinnen und -architekten Messaging für die Integration via Datenreplikation, empfiehlt sich das Event Carried State Transfer Pattern. Hier enthalten Events eine detaillierte Aufstellung der geänderten Daten, damit Konsumenten ihre eigene Kopie der Daten ohne zusätzliche Nachfrage aktualisieren können.

Die Implementierung von Datenreplikation über synchrone Schnittstellen erfolgt meist über einen zeitlichen Auslöser, zum Beispiel einen Cronjob. In festgelegten Zeitintervallen fragt das integrierende Modul Daten über eine definierte Schnittstelle des integrierten Moduls ab.

Der Vorteil asynchroner Backend-Integration liegt in der schwachen Laufzeitkopplung. Ändert sich eine konsumierte Schnittstelle, führt das nicht automatisch zu Laufzeitproblemen während der User-Interaktionszeit. Inhalte sind – wenn auch nur Eventual Consistent – bei Ausfällen von Fremdsystemen für Benutzerinnen und Benutzer sichtbar. Nachteilig hingegen ist ein höherer initialer Aufwand für Developer, da sie die Replikation und eine eigene Datenhaltung implementieren müssen.

Ist Konsistenz jedoch das oberste Ziel, gibt es auch im Backend die Möglichkeit von synchroner Kommunikation mit anderen Modulen. In diesem Fall schickt ein Modul während eines Requests weitere Aufrufe an andere Module und wartet auf deren Antworten, um die eigentliche Geschäftslogik ausführen zu können. Das vermutlich prominenteste Beispiel dafür war Netflix als einer der Early Adopter von Microservices. Um mit der starken Kopplung umgehen zu können, die durch Aneinanderreihung vieler Microservices innerhalb eines User-Requests entsteht, hat Netflix die Hystrix-Bibliothek entworfen. Sie soll einige der Probleme auf technischer Ebene abmildern, sorgt als Kehrseite aber für einen Anstieg an Komplexität.

Eine weitere Methode zur Aufrechterhaltung der Konsistenz im Backend ist die Integration über eine zentrale Datenbank und ein kanonisches Datenmodell, das alle Module nutzen. Das große Problem liegt hier auf der Hand: Anpassungen am Datenmodell lösen einen immensen Kommunikationsaufwand aus. Eine einzelne Änderung kann dazu führen, dass viele Teams Anpassungen vornehmen müssen, die sie zudem im Worst Case gleichzeitig ausrollen müssen. Von Datenbanken zur Integration über Teamgrenzen hinweg ist abzuraten. Sind die Vorteile einer zentralen Datenbank nach sorgfältiger Abwägung dennoch ausschlaggebend, sollten die Teams die Module mit eigenen Schemata voneinander trennen.

Ist sofortige Konsistenz immer notwendig?

Die Diskussion um die Gewichtung von Consistency und Availability wird hitzig geführt. Ein Verzicht auf Consistency hat in vielen Fällen einen positiven Einfluss auf die Kopplung zweier Module. Problemen lässt sich durch Eventual Consistency und die Implementierung von Zwischenzuständen leichter vorbeugen, als viele Entwicklerinnen und Entwicklern befürchten.

Als Beispiel kann die Änderung einer Lieferadresse in einem Onlineshop dienen (Abbildung 5): Ändert eine Besucherin oder ein Besucher während des Bestellprozesses im Warenkorb die Lieferadresse, speichert der Warenkorb das in seiner eigenen Datenbank und informiert anschließend andere Module über ein Event. Auch das Modul Benutzerprofil, das die Hoheit über Adressdaten und deren Validierungslogik innehat, empfängt das Event.

Flussdiagramm zur Verwaltung von Lieferadressen im Warenkorb und Benutzerprofil, mit Schritten wie 'Neue Lieferadresse', 'Speichere Lieferadresse' und 'Validiere Adresse'.
Abbildung 5: Event-basierter Flow und die Implementierung eines Zwischenzustands: „Lieferadresse nicht validiert“

Bis das Benutzerprofil die Korrektheit der Adressdaten über ein weiteres Event bestätigt, zeigt der Warenkorb den Zwischenzustand „In Prüfung“ an, während die Besucherin oder der Besucher weitere Schritte im Prozess ohne Weiteres durchführen können. Der Warenkorb muss weder die Benutzererfahrung durch das Warten auf ein kurzzeitig ausgefallenes Modul trüben noch die Validierungslogik für Adressen, die in der Fachlichkeit des Moduls Benutzerprofil liegt, duplizieren. Im besten Fall dauert der Austausch der Events mitsamt Validierung der Adressdaten nur Millisekunden. Im schlimmsten Fall können die Besucher trotz eines Systemausfalls von Benutzerprofil einstweilen im Warenkorb weiter fortschreiten.

Conclusion

Die Qual der Wahl

Verteilte Systeme sind wie andere Architekturansätze Trade-offs und technische Integrationslösungen sind dabei unumgänglich. Bei der Entscheidung für einen der im Artikel vorgestellten Integrationsansätze sind die teilweise verheerenden Auswirkungen auf organisatorische und technische Kopplung zu berücksichtigen, damit die teuer erkauften Vorteile verteilter Systeme nicht konterkariert werden.

Das CAP-Theorem gibt Starthilfe bei der Entscheidungsfindung, darf aber nicht als alleiniges Kriterium gelten. Kompromisse wie Eventual Consistency und die Frage, was eine Entscheidung für den Grad an Kopplung zwischen den einzelnen Modulen bedeutet, sind bei der Wahl eines Integrationsmechanismus zu berücksichtigen.

Liegt der weiteren Entwicklung ein problematischer, unüberdachter Systemschnitt zugrunde, ist dieses Problem technisch eine Zeit lang kaschierbar, aber nicht lösbar. Im schlimmsten Fall wird ein komplexes Problem mit einer noch komplexeren Lösung erschlagen und erzeugt wiederum weitaus komplexere Probleme. Es ist auch vorteilhaft, Systemgrenzen regelmäßig auf den Prüfstand zu stellen, um unnötige Abhängigkeiten und damit unnötig hohe Komplexität und Kommunikationsaufwand frühzeitig zu erkennen.