Über unsMediaKontaktImpressum
Roland König 02. Juli 2024

Clean Architecture und Microservices

In den letzten Jahren haben wir in der Software-Entwicklung viele Hype-Themen erlebt. Clean Architecture und Microservices sind zwei davon. Diese beiden habe ich einige Male in meinen Projekten in Kombination gesehen. Doch passen diese auch gut zusammen? Auf den ersten Blick spricht nichts dagegen. Clean Architecture fördert insbesondere die Wartbarkeit und Erweiterbarkeit eines Services. Microservices stellen sicher, dass das Software-System in kleine, unabhängig deploybare Services zerlegt wird. Es klingt zwar danach, dass sich beide Themen gut ergänzen. Wie so oft ist es im Detail aber nicht so einfach. Um uns eine Meinung zu bilden, wollen wir bei beiden Themen die Teilaspekte betrachten und anschließend praktischen Erfahrungen gegenüberstellen.

Microservices

Über Microservices haben wir in den letzten Jahren sehr viel gehört und gelesen, sie gelobt und verflucht. Allgemein kann man festhalten, dass es nach wie vor ein beliebter Architekturstil ist. Diese Aussage lässt sich durch verschiedene Wege untermauern. Ich als Freelancer finde den Begriff "Microservice" sehr häufig bei Projektausschreibungen. Eine Suche nach Literatur und Artikel über Microservices im Internet ergibt ebenfalls eine lange Liste von Ergebnissen. Demgegenüber taucht der Begriff "Monolith" oder etwas moderner "Modulith" in deutlich geringerer Häufigkeit auf.

Man sollte mich hier nicht falsch verstehen, ich bin kein erbitterter Fan von Microservices. Ganz im Gegenteil, ich sehe den Architekturstil "Microservices" im Wesentlichen als das, was er ist: Ein Architekturstil. Wie andere Stile hat dieser Vor- und Nachteile, die es beim Projekt zu bedenken gilt. Ich persönlich habe auch mit monolithischen Ansätzen sehr gute Erfahrungen gemacht.

In diesem Artikel beschäftigen wir uns mit Microservices. Damit werden in aller Regel folgende Eigenschaften verbunden:

  • Zerlegung in viele kleine (fachliche) Services
  • Lose Kopplung zwischen den Services
  • Unabhängiges Deployment einzelner Services
  • Dezentrale Datenhaltung
  • Hoher Freiheitsgrad bei Technologieauswahl pro Service

Wie erreichen wir das? Zunächst zerlegen wir das System vertikal entlang der Fachlichkeiten. Hierzu sind Methoden des Domain-driven Designs (DDD) populär. Jedes Stück Software, das bei der Zerlegung herauskommt, enthält alle notwendigen Schichten von UI (oder API) über Business-Logik bis zur Persistenz. Diese Stücke Software werden in eigene Services verpackt und sind klein genug, um von einem Team betreut werden zu können.

Die Services untereinander sollten nach Möglichkeit primär asynchron miteinander kommunizieren. Asynchron in diesem Kontext bedeutet, dass der aufrufende Service nicht synchron auf die Antwort des aufgerufenen Service wartet. Dadurch erreichen wir in Summe ein robusteres System und vermeiden kaskadierende Fehler. Asynchrone Kommunikation erreichen wir typischerweise über Messaging-Systeme wie RabbitMQ [1].

Ein gerne unterschätzter Punkt ist die dezentrale Datenhaltung. Im Kontext von Microservices wird davon ausgegangen, dass sich jeder Microservice selbst um Persistenz kümmert. Somit wird selbst auf Datenbankebene getrennt. Jeder Microservice bekommt seine eigene Datenbank, sofern er eine braucht. Auch das soll dafür sorgen, dass man in Summe ein robusteres System erhält. Fällt eine Datenbank beispielsweise wegen Überlast aus, ist nur der Service betroffen, der diese Datenbank verwendet. Alle anderen tangiert die Fehlersituation nicht. Zu bedenken sind allerdings die bei Microservices entstehenden Nachteile. Als Beispiele sind eine in Teilen redundante Datenhaltung und deutlich schwieriger umzusetzende verteilte Transaktionen zu nennen. Insbesondere letztere sollte man meiner Erfahrung nach über den richtigen fachlichen Schnitt bei Microservice-Systemen tunlichst vermeiden.

Beim Stichwort "fachlicher Schnitt" möchte ich noch ein paar Worte zur Größe von Microservices verlieren. Ein Microservice kann sehr klein sein, ggf. sogar nur eine Funktion. Als Maßgröße sollte man aber nicht der Devise "so klein wie möglich" folgen, sondern sich stattdessen an der Fachlichkeit und an der Teamgröße orientieren. Ein Microservice sollte für sich eine Fachlichkeit kapseln und klein genug sein, dass er von einem Team entwickelt und gewartet werden kann. Mit Blick auf die Teamgröße kann "klein" damit auch deutlich größer bedeuten als eine einzelne Funktion. Der etwas neuere Begriff "Macroservice" hebt diesen Umstand hervor.

Clean Architecture

Klassischerweise haben wir unsere Software in Schichten strukturiert. Ich habe das auch selbst noch so in der Ausbildung gelernt. Ganz oben finden wir die Präsentations-Schicht, darunter die Business-Schicht und ganz unten die Persistenz-Schicht. Abhängigkeiten gehen entsprechend von oben nach unten. Grundsätzlich scheint das ein guter Weg zu sein, es entstehen dabei allerdings auch Probleme.

Ein Punkt ist die Testbarkeit. Möchte man die Business-Schicht testen, wird dazu die Persistenz-Schicht benötigt. Soll die UI-Schicht getestet werden, benötigen wir gar Business- und Persistenz-Schicht. Automatisierte Tests zu schreiben ist dadurch schwieriger, als es sein müsste.

Das Dependency Inversion Principle (DIP) hat uns an dieser Stelle eine Alternative in die Hand gegeben. Anstelle dessen, dass die Business-Schicht eine Abhängigkeit auf die Persistenz-Schicht hat, definiert sie lediglich eine Schnittstelle dafür. Die Persistenz-Schicht implementiert diese anschließend. Der Abhängigkeitsgraph zwischen Business-Schicht und Persistenz-Schicht dreht sich somit um.

Abb. 1 zeigt die Anwendung des DIP an einem einfachen Beispiel. Hierzu nehmen wir eine Klasse EinkaufUseCase, die Einkäufe einer beliebigen Quelle bearbeitet. Quelle kann etwa ein Online-Shop sein. Innerhalb der Verarbeitung von EinkaufUseCase werden viele für diesen Artikel nicht relevante Aufgaben abgearbeitet, am Ende muss der Einkauf schließlich gespeichert werden. Hierzu nutzt EinkaufUseCase nicht direkt die Datenbank, sondern die Klasse EinkaufSqlRepository aus der Persistenz-Schicht. In der klassischen Herangehensweise würde EinkaufUseCase direkt die Klasse EinkaufSqlRepository verwenden. Unter Anwendung des DIP gebraucht EinkaufUseCase lediglich die Schnittstelle IEinkaufRepository. Die Persistenz-Schicht implementiert diese Schnittstelle in der Klasse EinkaufSqlRepository. Die Richtung der Abhängigkeit wird somit umgekehrt.

Dieses Vorgehen bietet mehrere Vorteile. Die Testbarkeit erhöht sich, da für das Testen der Klasse EinkaufUseCase lediglich das Interface IEinkaufRepository gemockt werden muss. Ebenso gibt es jetzt die Möglichkeit, verschiedene Implementierungen von IEinkaufRepository bereitzustellen. Bei einem Kunden kann das mittels SQL geschehen, bei einem anderen Kunden durch NoSQL. Für erste Tests sind ggf. sogar nur Dateien im Dateisystem möglich.

Wenn man das Fluglevel etwas erhöht, so landet man bei Abb. 2. Das vorher beschriebene gilt nicht nur für Persistenz, sondern auch für andere Abhängigkeiten. Beispiele wären ein Mail- und ein Notification-Sender, die verantwortlich für Benachrichtigungen an Benutzer sind. Auch diese implementieren Schnittstellen, die in der Business-Schicht definiert werden.

Etwas allgemeiner gefasst sehen wir diesen Ansatz in Abb. 3. Clean Architecture nach Robert C. Martin geht davon aus, dass sich Entities (bzw. Models) und Use Cases in der Mitte einer Zwiebel befinden. Diese repräsentieren das, was ich weiter oben als Business-Schicht bezeichnet habe. Komponenten, die die Business-Logik mit der Außenwelt verbinden, bilden entsprechend die äußeren Ringe der Zwiebel. Der Abhängigkeitsgraph geht stets von außen nach innen.

In meinen eigenen Projekten wurde häufig das Architekturmuster "Hexagonale Architektur" bzw. "Ports and Adapter" von Alistair Cockburn genutzt. Dieses gab es schon vor Clean Architecture und es ist aus meiner Sicht ein hervorragender Einstiegspunkt. Im Wesentlichen wird die Zwiebel dort als Hexagon dargestellt. In der Mitte befindet sich die Business-Schicht (Applikation/Domäne), links davon eingehende und rechts davon ausgehende Adapter. Eingehend bedeutet, unser Service wird über diese Adapter aufgerufen. Ausgehend meint dagegen die Adapter, über die wir Funktionen in der Außenwelt aufrufen.

Abb. 4 zeigt die Hexagonale Architektur schematisch. Gehen wir von einem Microservice aus, der folgende Schnittstellen zu Umsystemen hat:

Eingehend

  • Eine API für die Web-UI
  • Eine API für eine App
  • Ein zyklischer Trigger eines Jobs

Ausgehend

  • Fremde Webservices
  • Eine eigene Datenbank
  • Eine Benachrichtigungsschnittstelle

In all diesen Fällen implementieren wir einen eigenen Adapter, der sich um das Mapping zwischen Außenwelt und Business-Schicht kümmert. Die Business-Schicht in der Mitte ist unabhängig davon. Sie bietet Funktionen, die durch eingehende Adapter aufgerufen werden können. Gleichzeitig definiert sie Schnittstellen, die durch ausgehende Adapter implementiert werden müssen.

Der alternative Name "Ports and Adapters" kommt entsprechend genau von dieser Beziehung. Die Business-Schicht ist unabhängig in Domänensprache geschrieben und definiert Ports. Diese Ports sind dabei meist schlicht Interfaces in der favorisierten Programmiersprache. Die Adapter implementieren diese bzw. rufen diese auf.

Noch ein Wort zu Data Transfer Objects (DTOs). Hierbei handelt es sich um Objekte, die von außen an die Adapter gesendet werden bzw. die Adapter nach außen senden. Sie dienen dem Zweck, die Business-Schicht im Inneren des Hexagons unabhängig von der Außenwelt zu machen. Sie können sehr ähnlich zu den Models der Business-Schicht sein, sich aber auch durch Aspekte wie Datentypen, Umfang (Anzahl Eigenschaften), Sprache (Deutsch/Englisch), Version usw. unterscheiden.

Ein (passendes) Szenario

Schauen wir uns die Hexagonale Architektur in Verbindung mit Microservices an einem passenden Beispiel an. Hierzu erinnern wir uns an das oben genannte Einkaufs-Beispiel und denken es weiter zu einem vollständigen Microservice, der sich um Einkäufe kümmert. Ein solcher Microservice könnte folgende eingehende und ausgehende Adapter haben:

Eingehende Adapter

  • Kassensystem: schickt uns Einkaufsdaten zu jedem dort durchgeführten Einkauf
  • Online-Shop: ein System, das uns ebenfalls Einkäufe in Form von Bestellungen übermittelt

Ausgehende Adapter

  • Bonuspunkte-System: Verwaltung der durch den Einkauf gesammelten Bonuspunkte
  • Datenbank: Sicherung, Abfrage und Aufräumen der Einkaufsdaten
  • Mail-System: Auslösen von Mails an Benutzer, die eingekauft haben

Innerhalb der Business-Schicht würde man den folgenden Anwendungsfall umsetzen:

Einkauf verarbeiten

  • Ein Einkauf kommt an, wird gespeichert und anschließend werden die Umsysteme benachrichtigt.

Dieser Anwendungsfall wird durch zwei verschiedene eingehende Adapter aufgerufen und triggert je nach Vorhandensein von Bonuspunkten zwei oder drei der ausgehenden Adapter. Die DTOs von Kassensystem und Onlineshop können sich dabei völlig unterscheiden, ohne dass es einen Unterschied in der Business-Schicht gibt. Dieses Beispiel sieht in der Realität noch umfangreicher aus. So können auch eingelöste Coupons und viele weitere Dinge an einem Einkauf hängen. Ebenso wären diverse Anwendungsfälle für eine Suche von Einkaufsdaten denkbar. Insgesamt ist es aus meiner Sicht ein gutes Beispiel für einen Microservice, der unter der Haube die Hexagonale Architektur nutzt und dadurch den Prinzipien der Clean Architecture folgt.

Clean Architecture: Andere Szenarien

Die obige Beschreibung und das Beispielszenario können nun dazu verleiten, die Clean Architecture als Standardmuster für alle Microservices zu sehen. Bevor wir uns zu diesem Schluss wagen, möchte ich an dieser Stelle auf Arten von Microservices eingehen, die in meinen Projekten bis dato vorgekommen sind. Die Kategorisierung bezieht sich primär auf meine letzten Projekte, ist also sicher nicht für alle Projekte repräsentativ. Sie soll aber im Rahmen dieses Artikels dabei helfen, sich eine Meinung bilden zu können.

Backend-Services sind weitgehend ähnlich zum beschriebenen Beispiel. Sie bilden ein Set von Use Cases ab, haben in der Regel eine Datenbank und eine API, über die diese Services aufgerufen werden. Die Clean Architecture kann hier ein gutes Werkzeug sein.

Facaden-Services befinden sich am Rand unseres Systems. Sie verbinden Microservices innerhalb unseres Systems mit der Außenwelt. Hier ist aber vielmehr hervorzuheben, was sie nicht haben: Business-Logik. Facaden dienen vielmehr dem Zweck, Besonderheiten der Außenwelt an einer Stelle abzubilden. Beispiele sind Authentifizierung, Last-Beschränkung, Security-Aspekte oder andere Kommunikationstechnologien. Man könnte Facaden auch als Adapter im Sinne der Hexagonalen Architektur betrachten, nur befinden wir uns hier am Fluglevel unseres gesamten Microservices-Systems.

Backend-for-Frontend-(BFF-)Services sind vergleichbar zu Facaden-Services. Auch hier befindet sich keine bis wenig Business-Logik. Der Unterschied zu Facaden-Services liegt darin, dass sie sich konkret auf eine UI beziehen und diese ggf. sogar in Form einer Web-Applikation selbst mitbringen.

Abb. 5 gibt eine schematische Übersicht über die Rolle der beschriebenen Arten von Microservices. Daneben gibt es in meinen Projekten auch noch einige speziellere Beispiele, auf die ich aber nicht näher eingehen möchte.

Nun stellt sich die Frage: Macht Clean Architecture auch bei den BFF-Services und bei den Facaden-Services Sinn? Aufgrund der fehlenden Business-Logik deutlich weniger als bei Backend-Services. Häufig sorgt eine Clean Architecture an diesen Stellen für viel Tipparbeit und bringt wenig Vorteile. Schließlich existiert keine Business-Logik, die unabhängig getestet werden soll.

Welche Kriterien lassen sich nun heranziehen, um sich im Zweifel für oder gegen Clean Architecture in einem Microservice zu entscheiden? Diese Frage ist tatsächlich nicht so einfach und lässt sich schwer an klaren Zahlen festmachen. Nach meinen Erfahrungen hilft es aber, sich über folgende Punkte Gedanken zu machen:

  • Wie viele Anwendungsfälle bildet der Microservice über seine Business-Schicht ab? Je weniger, desto weniger Sinn macht Clean Architecture in der Regel.
  • Wie viele Adapter wären notwendig? Je geringer die Anzahl, desto weniger Sinn macht ein Aufbrechen über Prinzipien der Clean Architecture.
  • An welchen Stellen sind in Zukunft Änderungen zu erwarten? Selbstverständlich können wir nicht in die Zukunft schauen. Bei der Trennung eines Microservices in Komponenten ist es durchaus sinnvoll, sich zu überlegen, welche Stellen sich ändern können (und warum). Idealerweise möchte man bei einer möglichen Änderung nur die Business-Schicht oder nur den betroffenen Adapter bearbeiten. Wäre das der Fall, passt Clean Architecture gut.
  • Befinden wir uns in einer Core Domain oder einer Supporting Domain? Hierbei handelt es sich um Begriffe des Domain-driven Designs. In kurzen Worten bedeutet das folgendes: Bildet der Microservice Geschäftsfälle aus dem Kern des Geschäfts ab (=Core Domain) oder befindet sich der Microservice aus Business-Sicht eher auf einem "Nebenkriegsschauplatz" (=Supporting Domain)? In der Core Domain sind in Zukunft allgemein mehr Änderungen zu erwarten, daher kann Clean Architecture hier mehr Sinn machen.

Diese Liste könnte man noch deutlich weiterführen. Alle Punkte zielen darauf ab, sich darüber Gedanken zu machen, ob die Struktur einer Clean Architecture in Summe Vorteile bringt.

Clean Architecture: Fazit

An dieser Stelle möchte ich die Frage aus den einleitenden Sätzen dieses Artikels aufgreifen. Passen Microservices und Clean Architecture immer zusammen? An sich schon, aus meiner Sicht bin ich aber mit dem Wort "Immer" nicht einverstanden. Es gibt Ausnahmen von der Regel, die zu berücksichtigen sind. Mit diesem Artikel möchte ich zum Nachdenken anregen, falls der Annahme eines "immer" gefolgt wird.

Ein Vorteil von "immer" oder zumindest "häufig" liegt in der schnellen Einarbeitung durch Entwickler:innen in die Logik eines Microservices. Einen Nachteil stellt ein mögliches "Over-Engineering" an verschiedenen Stellen des Microservice-Systems dar. Over-Engineering ist dabei in Projekten häufig schlimmer als Under-Engineering. Es frustriert Entwickler, wirft Fragen nach der Sinnhaftigkeit auf und macht Änderungen am System schwieriger.

Autor
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben