Microservices sind heutzutage eine beliebte Softwarearchitektur, auch bei der Tallence. Ihre Vorteile sprechen für sich: Sie skalieren gut, sorgen für eine modulare Struktur, sind fehlerresistent, einfach zu verstehen und schnell zu entwickeln. Das wird erreicht indem das Gesamtsystem in möglichst kleine (micro) lose gekoppelte und eigenständige Backend-Anwendungen (services) unterteilt wird, die meistens über REST-Schnittstellen kommunizieren.
Als Beispiel dient ein kleiner Versandhandel. Statt in einem Online-Shop wird per Telefon bestellt. Die Verkäufer greifen auf einen internen Produktkatalog zu und tragen Bestellungen in ein internes Bestellsystem ein. Eine Versandabteilung kümmert sich um das Verpacken und Versenden der Produkte.
Die Architektur besteht aus folgenden Microservices:
- ProductCatalogService: Verwaltet welche Produkte es gibt und wie viele davon auf Lager sind
- CustomerService: Beinhaltet Kundendaten wie Telefonnummer, Anschrift, Interessen
- OrderService: Verwaltet Bestellungen und deren Zustand (in Vorbereitung, versendet, …). Eine Bestellung referenziert Produkte aus dem ProductCatalogService und Kunden aus dem CustomerService
- SalesCampaignService: Für Rabatte, Kombo-Angebote und sonstiges. Referenziert Produkte aus dem ProductCatalogService
Die Services müssen stellenweise zusammenarbeiten. Zum Beispiel: Wenn Produkte aus dem Lager geholt wurden, um verpackt zu werden muss, als Gesamt-Transaktion, der Lagerbestand im ProductCatalogService verringert und der Bestellstatus im OrderService auf “in Vorbereitung” gesetzt werden.
Ein anderer Blickwinkel
Betrachtet man das Ganze aus einem anderen Winkel, zeigen sich Schwächen auf. In der obigen Architektur referenzieren Microservices Daten anderer Services, jedoch sollten sie lose gekoppelt sein. Die verteilte Transaktion (Verpackung) kann nur unter Beteiligung mehrerer Services abgeschlossen werden. Sie ist komplex und koppelt Services eng aneinander.
Ist das noch im Sinne von Microservices? Um das zu beurteilen, betrachten wir, wie ihre Eigenschaften zustande kommen.
Ein Schritt zurück zu den Grundlagen
Bei der Frage wie die Eigenschaften von Microservices zustande kommen ist es hilfreich das Konzept des Bounded Context zu betrachten.1
Ein Bounded Context ist ein fachlicher Kontext, in dem Begriffe eine bestimmte Bedeutung haben und bestimmte Konzepte und Regeln gelten. Solche Kontexte gibt es immer, auch wenn sie nicht bewusst und nicht explizit definiert werden. Viele Missverständnisse (und Bugs) basieren darauf, dass man dieselben Begriffe benutzt, aber unbewusst nicht über dasselbe redet.
Der Versandhandel aus der Einleitung hat, beispielsweise, die zwei Bounded Contexts Verkauf und Versand. Der Begriff Kunde ist ein guter Kandidat für Missverständnisse. Er existiert in beiden Kontexten, aber in unterschiedlicher Bedeutung: Verkauf sieht einen Kunden als jemanden der für Angebote und Beratungen per E-Mail-Adresse und Telefonnummer erreichbar ist. In Versand hingegen ist ein Kunde jemand mit Lieferadresse der Produkte bestellt hat.
Andererseits gehören Begriffe wie Rabatt oder Paketgröße und Regeln wie “20% auf alles außer Tiernahrung” und Verpackungsrichtlinien zu jeweils nur einem der beiden Kontexte.
Bounded Contexts kommunizieren über Ereignisse miteinander. Zwei Beispiele sind: Eine Bestellung wurde abgeschlossen. Ein Warenpaket wurde versendet.
Gute Kontextgrenzen und Ereignisse sind immens wichtig. Dann sind Bounded Contexts eigenständig, lose gegenseitig gekoppelt und stark intern kohärent.
Von Bounded Contexts zu Microservices
Um Microservices und Bounded Contexts in Beziehung zu setzen, hilft folgende Betrachtungsweise: “Micro” bedeutet, ein Service bezieht sich auf genau einen Bounded Context. “Service” bedeutet “fachliche Dienstleistungen”, statt technische wie etwa CRUD-Operationen.2 Ein Bounded Context ist das Modell, ein Microservice dessen Umsetzung als Applikation.
Damit die Applikation so eigenständig ist wie das Modell, beinhaltet sie alles. Frontend, Backend, Datenhaltung, alle Ressourcen und alle Daten.3 Aus dieser Betrachtung ist, zum Beispiel, eine Datenbank alleine kein Microservice. Aus ihr folgt auch, dass synchrone Aufrufe, und insbesondere REST-Schnittstellen, zwischen Microservices keinen Sinn ergeben. Ein synchroner Aufruf würde bedeuten, ein Service wartet auf Bestätigung oder Daten eines anderen. REST (und ähnliche Technologien, wie GraphQL) modelliert Daten. Beides steht im Widerspruch dazu, dass die Applikation alles besitzt, was sie braucht.
Ein Microservice ist also eine vollständige Applikation, die alle fachlichen Funktionen zur Verfügung stellt, die für genau einen Bounded Context notwendig sind. Nach dieser Terminologie ist ein Monolith eine Applikation, die mehrere Bounded Contexts abdeckt. Daraus erklären sich typische Probleme mit Monolithen. Etwa, dass ein Begriff in verschiedenen Kontexten unterschiedliche Bedeutungen hat, aber in der Implementierung unter einen Hut gebracht werden muss. Sie wird dadurch komplex und Änderungen müssen mit vielen Stakeholdern koordiniert werden. Oder dass die Gefahr besteht, dass fachlich eigentlich unabhängige Komponenten technisch voneinander abhängen und so die Modularisierung aufweicht. Für Software kleiner als ein Bounded Context, wie im ursprünglichen Beispiel, brauchen wir auch noch einen Begriff.
Dazu gleich mehr, vorher betrachten wir einen weiteren wichtigen Faktor: Teams.
Teamstrukturen
Wenn es um Teams geht, kommt man um das Gesetz von Conway nicht herum. Es besagt: Organisationen entwerfen Systeme, die die Kommunikationsstrukturen der Organisation abbilden.4 Kurz: “You’re gonna ship your org chart”.
Angenommen, eine Organisation trennt ihre Mitarbeiter nach technischem Fachgebiet: Frontend-Team, Backend-Team, Datenbank-Team, … Das fördert z.B. die Kommunikation der Frontend-Mitarbeiter unter sich, sodass sie sich informell austauschen und eng zusammenarbeiten. Es schottet sie aber von den Backend-Mitarbeitern ab, mit denen sie sich daher viel über Dokumentation und Spezifikation abstimmen. Was dazu führt, dass die Software genau so strukturiert wird: Strikt voneinander getrennte, aber sehr breite Schichten. Diese Organisation produziert auf ganz “natürliche” Weise Monolithen.
Das bedeutet: Wenn Microservices alles von Frontend bis Datenhaltung abdecken sollen, dann gehört es zu den Grundvoraussetzungen, dass alle Fachgebiete im Team zusammenarbeiten. Das sind sogenannte “cross-funktionale Teams”.
Interessanterweise gilt das Gesetz von Conway auch umgekehrt: Die Struktur der Software bestimmt, wie Teams und ihre Mitglieder kommunizieren müssen.
Da Teamgrenzen die Kommunikation bremsen, ist es nicht sinnvoll mehrere Teams an einem Microservice gemeinsam arbeiten zu lassen. Beim umgekehrten Extrem, ein Microservice pro Entwickler, sind häufige Abstimmungen zwischen eigenständigen Services mit Dokumentation und Spezifikation notwendig. Dies bremst Teamwork effektiv aus, das Team wird zu einer Ansammlung von Ein-Personen-Silos.
Zusammengefasst kann man sagen: Für erfolgreiche Microservices sind alle drei Faktoren wichtig: Der Bounded Context, die Teamstruktur und auf Platz drei die Technik.
Kleiner als ein Bounded Context
Zurück zum ursprünglichen, vorwiegend technisch geschnittenen Beispiel. Jeder Service ist ein kleines CRUD-Backend, dass kaum eigenständige fachliche Konzepte oder Regeln umsetzt. Die beiden Kontexte Verkauf und Versand sind über Servicegrenzen verteilt. Dafür gibt es zwei Namen. Der vorteilhaftere ist “Nanoservices”.
Nanoservices decken einzelne technische Funktionen ab. Der fachliche Zusammenhang ist aber nicht weg! Das führt zu einigen Problemen. Zum Beispiel zur verteilten Transaktion, die nicht nur zwei Services eng koppelt, sondern auch neue Fehlerquellen einführt.
Der Widerspruch zwischen fachlichem Zusammenhang aber technischer Trennung führt, unter anderem, zu drei Dingen:
- Fachliche Zusammenhänge koppeln Nanoservices zwangsläufig auch technisch eng. Fachliche Änderungen ziehen meistens Änderung, Test und Deployment mehrerer Nanoservices nach sich.
- Fachliche Zusammenhänge sind verstreut, Nanoservices haben dadurch eine schwache Kohäsion. Um Zusammenhänge zu verstehen, öffnet man mehr Codebasen, liest mehr Logs, usw.
- Alle Probleme verteilter Systeme (asynchrone Uhren, Events in verschiedener Reihenfolge, unzuverlässiges Netzwerk, Teilausfälle) werden auf ganz tiefe Ebene ins System hereingeholt.
Eine andere Sichtweise ist, dass diese Architektur die schlechten Eigenschaften von Monolithen mit denen verteilter Systeme vereinigt. Der weniger vorteilhafte Name ist daher “verteilter Monolith”.
Die drei Faktoren als Denkrahmen
Wenn man die Nachteile von Nanoservices vermeiden möchte, stellt sich die Frage wie man vorgehen kann.
Angenommen, bei den drei Faktoren besteht Unklarheit, zum Beispiel zu Beginn von Projekten. Je größer die Unklarheit, desto größer die Gefahr, aus Unwissenheit Entscheidungen zu treffen, die sich später als suboptimal und schwer rückgängig zu machen herausstellen.
Hier kann es helfen, den Fokus zunächst darauf zu setzen Bounded Contexts und Teamstrukturen auszuarbeiten, Entscheidungen nur so weit zu treffen, wie die Klarheit es zulässt oder manche vorläufig zu vertagen.5 Eine mögliche Umsetzung davon ist, mit einem nicht-verteilten, modularen System anzufangen. Dieses lässt sich einfacher an den aktuellen Kenntnisstand anpassen und später trennen.
Falls man bereits in der Situation ist, dass Widersprüche zwischen fachlichem Zusammenhang und technischer Trennung zu Problem führen, können die beschriebenen drei Faktoren als “Denkrahmen” dienen. Mit ihrer Hilfe können die Widersprüche erkannt und Ansätze zur Behebung entwickelt werden.
Artikel von: Christian Harnisch
[1] Domain-Driven Design: Tackling Complexity in the Heart of Software, Chapter 14: Maintaining Model Integrity
[2] Create Read Update Delete, Wikipedia
[3] Microservices, Martin Fowler
[4] Law of Conway, Wikipedia
[5] Lean Principles 4 Defer Commitment, 101 Ways
[6] MonolithFirst, Martin Fowler