Was Microservices sind und was sie nicht sind
Microservices sind ein Architekturstil, bei dem eine Anwendung aus unabhängig deploybaren Services besteht, die um Geschäftsfunktionen (Business Capabilities) organisiert sind. Jeder Service besitzt seine eigenen Daten und kommuniziert über das Netzwerk. Das entscheidende Wort ist hier unabhängig. Ein Service, der nicht eigenständig deployed werden kann, ist kein Microservice; es ist ein Modul mit einer Netzwerkgrenze.
Es ist wichtig zu betonen, was dieser Stil nicht ist. Es ist keine Regel, dass Services klein sein müssen. Es ist keine Voraussetzung, Container, Kubernetes oder ein Service Mesh zu verwenden, obwohl diese Tools existieren, weil dieser Stil ohne sie mühsam wäre. Microservices sind nicht automatisch skalierbarer, zuverlässiger oder moderner als ein Monolith. Das sind Eigenschaften, die man aktiv aufbauen muss, und keine Eigenschaften, die man automatisch durch eine Topologie erhält.
Das einzige definierende Merkmal ist, dass eine Änderung an einem Service die Produktion erreichen kann, ohne dass ein koordinierter Release der anderen Services erforderlich ist. Alles andere – die Grenzen, die Contracts, die Events, die Plattform – existiert nur, um dieses Merkmal zu ermöglichen und die daraus resultierenden Konsequenzen zu bewältigen.
Ein Beispiel für die Dekomposition
Nehmen wir einen Online-Shop. Die Geschäftsfunktionen lassen sich leicht benennen, und jede davon wird zu einem Service mit einem klaren Owner.
catalog products, prices, availability
cart the pre-purchase basket
orders the placed order and its lifecycle
inventory stock levels and reservations
payments charges, refunds, payment methods
shipping labels, carriers, tracking
notifications email, push, SMS
identity accounts, sessions, API keys
Beachten Sie, was in dieser Liste fehlt. Es gibt keinen „Datenbank-Service“, keinen „E-Mail-Versand-Service“ – Benachrichtigungen sind eine Geschäftsfunktion, kein Transportmedium – und keinen „User-Service“, den jeder andere Service nur aufruft, um einen Namen darzustellen. Jeder Eintrag repräsentiert einen Teil des Geschäftsmodells, und ein Team kann einen oder zwei dieser Services End-to-End verantworten.
Die Interaktionen sind genauso wichtig wie die Liste selbst. Das Durchstöbern des Katalogs ist leseintensiv und kann aggressiv gecached werden. Das Aufgeben einer Bestellung ist ein Schreibvorgang, der Inventar, Zahlungen und Versand koordiniert. Benachrichtigungen sind ein reiner Consumer: Sie reagieren auf Events und werden niemals direkt aufgerufen. Diese unterschiedlichen Anforderungen rechtfertigen unterschiedliche Ansätze – Caching, Sagas, Queues – obwohl alle als „Services“ bezeichnet werden.
Der eigentliche Treiber ist die unabhängige Deploybarkeit
Teams führen Microservices oft ein, „um zu skalieren“, nur um dann festzustellen, dass sie diese Skalierung nie benötigt haben. Der ehrlichere Grund für eine Aufteilung ist organisatorischer Natur. Wenn zehn Engineers an einem einzigen deploybaren Artefakt arbeiten, muss jedes Release auf die langsamste Änderung warten, und ein fehlerhafter Test in einem Bereich blockiert alle anderen. Eine Aufteilung nach Capabilities gibt jedem Team seine eigene Pipeline, seinen eigenen On-call-Plan und sein eigenes Tempo.
Das ist Conways Gesetz in umgekehrter Form: Man gestaltet das System so, dass seine Schnittstellen mit der Art und Weise übereinstimmen, wie die Teams kommunizieren. Wenn ein einziges Team das gesamte Produkt verantwortet, erkaufen sich Microservices lediglich Netzwerkfehler und eine zu wartende Plattform im Austausch für nichts. Wenn jedoch sechs Teams unabhängig voneinander releasen müssen, beginnen sich die Schnittstellen zu rentieren.
Die erste Frage ist also nicht „Wie zerlegen wir diese Domain?“, sondern „Welche Teile dieses Systems müssen sich tatsächlich in unterschiedlichen Geschwindigkeiten ändern und in der Verantwortung unterschiedlicher Personen liegen?“. Nur die Antworten darauf werden zu Services.
Grenzen folgen Business Capabilities
Die richtige Schnittstelle ist eine Business Capability, keine technische Schicht. Abrechnung, Versand, Katalog, Identität und Benachrichtigungen sind Capabilities. Ein „Datenbank-Service“, ein „E-Mail-Service“ oder ein „Validierungs-Service“ ist lediglich eine technische Schicht im Gewand eines Services – und jedes neue Feature wird voraussichtlich drei dieser Services gleichzeitig ändern müssen.
Domain-driven Design liefert hierfür das Vokabular. Ein Bounded Context ist eine Grenze, innerhalb derer ein bestimmtes Modell und dessen Sprache konsistent sind. „Bestellung“ bedeutet im Vertriebskontext ein Warenkorb, der bezahlt werden soll; im Fulfillment-Kontext bedeutet es ein Paket, das kommissioniert und versendet werden muss. Beides ist innerhalb des jeweiligen Kontextes korrekt, und der Versuch, eine einzige gemeinsame Order Klasse über beide zu stülpen, ist der Grund, warum ein Monolith im Chaos endet.
Eine gute Grenze weist drei Eigenschaften auf:
- Sie besitzt einen kohärenten Satz an Regeln, die gemeinsam geändert werden.
- Sie verfügt über ein kleines, stabiles Interface im Verhältnis zur Logik, die dahintersteckt.
- Sie kann von einem Team verstanden werden, ohne dass der Rest des Systems gelesen werden muss.
Grenzen sind teuer zu verschieben, sobald andere Services von ihnen abhängen. Neigen Sie daher zu Beginn eher zu weniger, größeren Services. Einen Service aufzuteilen ist einfach; zwei Services zusammenzuführen, die bereits auseinandergelaufen sind, ist schmerzhaft.
Synchrone Kommunikation: REST und gRPC
Die einfachste Form der Interaktion ist eine Anfrage und eine Antwort. Nutzen Sie REST mit JSON für alles, was ein Browser oder ein externer Client berührt, und gRPC, wenn beide Seiten intern sind und Sie einen typisierten, kompakten Vertrag mit geringer Latenz wünschen. Die generierten Clients von gRPC machen es schwierig, vom Schema abzuweichen, weshalb viele interne Service Meshes darauf standardisieren.
Synchrone Aufrufe sind leicht nachvollziehbar, aber unmöglich vollständig abzusichern. Zwei Services sind nun temporal gekoppelt: Der Aufrufer kann nicht fortfahren, solange der Aufgerufene nicht verfügbar und reaktionsfähig ist. Drei Regeln verhindern, dass daraus ein Systemausfall wird.
- Setzen Sie immer einen Timeout. Ein Aufruf ohne Deadline kann so lange hängen bleiben, bis Ihre eigenen Ressourcen erschöpft sind. Wählen Sie ein Budget, das zur eigenen Deadline des Aufrufers passt, und ziehen Sie die restliche Arbeitszeit ab.
- Wiederholen Sie nur idempotente Operationen. Ein wiederholter
GETist harmlos; ein wiederholterPOST /chargeführt zu einer zweiten Abbuchung, es sei denn, der Endpunkt akzeptiert einen Idempotency Key und beachtet diesen. - Unterbrechen Sie den Stromkreis. Wenn eine Abhängigkeit ausfällt, stellen Sie die Aufrufe für eine Weile ein, anstatt Anfragen zu stapeln, die ebenfalls fehlschlagen werden. Das ist der Circuit Breaker, und er verwandelt eine langsame Abhängigkeit in einen schnellen, ehrlichen Fehler.
Retries verstärken die Last. Wenn drei Ebenen jeweils dreimal wiederholen, erhält ein überlasteter Service siebenundzwanzig Anfragen für jede ursprüngliche Anfrage. Begrenzen Sie Retries an der Edge, wo Sie das Gesamtbild sehen können, und fügen Sie Jitter hinzu, damit Retries nicht in einer synchronisierten Welle eintreffen.
Asynchrone Kommunikation: Events
Die Alternative zum Abfragen ist das Bekanntgeben. Ein Service veröffentlicht eine Tatsache — orders.created, payment.captured — und eine beliebige Anzahl von Consumern reagiert darauf, ohne dass der Producer von ihrer Existenz weiß. Dies eliminiert die zeitliche Kopplung: Der Payment-Service kann down sein, und die Bestellung wird trotzdem erstellt, da das Event auf dem Broker wartet.
Asynchrone Kommunikation erkauft Resilienz und Flexibilität auf Kosten von Unmittelbarkeit und Klarheit. Die Antwort des Producers enthält nicht mehr den nachgelagerten Effekt, sodass das System eventually consistent ist. Das Tracing eines Requests bedeutet nun, einer Spur von Events zu folgen, anstatt einem einzigen Call-Stack. Zudem wird der Broker zu einer kritischen Infrastruktur, die dauerhaft verfügbar und überwacht sein muss.
Es gibt zwei Wege, um einen mehrstufigen Prozess zu koordinieren. Bei der Choreografie hört jeder Service auf Events und entscheidet selbst, was als Nächstes zu tun ist; es gibt kein zentrales Gehirn, was elegant ist, bis niemand mehr sagen kann, wie der gesamte Flow aussieht. Bei der Orchestrierung steuert ein Process Manager oder Saga Coordinator die Schritte explizit. Choreografie eignet sich besser für einfache Reaktionen, Orchestrierung für Flows mit Kompensationen und Timeouts. Die Guides zu Event-Driven Architecture und Kafka gehen tiefer auf die Mechaniken ein.
Die „Steuer“ für verteilte Systeme
In dem Moment, in dem ein Aufruf über ein Netzwerk geht, werden Fehlermodi, die innerhalb eines Prozesses nicht existieren, zu Ihrer Verantwortung. Das ist kein Grund, auf Services zu verzichten; es ist schlicht der Preis, den man dafür zahlt.
- Das Netzwerk ist nicht zuverlässig. Pakete gehen verloren, DNS liefert veraltete Adressen zurück, Verbindungen werden mitten in einem Request zurückgesetzt.
- Ausfälle sind partiell. Service A ist gesund, während B down ist. Es gibt keine einzige Flagge, die besagt: „Das System läuft“.
- Latenz ist nicht kostenlos. Ein Aufruf, der innerhalb eines Prozesses Mikrosekunden dauerte, benötigt nun Millisekunden – und in Ketten multipliziert sich dieser Effekt.
- Die Antwort kann verloren gehen. Ein Request kann erfolgreich sein, aber die Bestätigung kommt nie an. Aus Sicht des Aufrufers ist er fehlgeschlagen; die Arbeit wurde dennoch erledigt.
- Die Zeit ist nicht synchronisiert. Uhren auf verschiedenen Maschinen driften auseinander, weshalb die Sortierung von Ereignissen allein anhand von Zeitstempeln unsicher ist.
Die gestalterische Antwort darauf ist ein kleiner, bekannter Werkzeugkasten: Timeouts für jeden Aufruf, begrenzte Retries mit Backoff und Jitter, Circuit Breaker, Bulkheads, die den Ausfall einer einzelnen Abhängigkeit isolieren, und Idempotency Keys, damit wiederholte Requests sicher sind. Nichts davon ist optional. Ein Microservices-System ohne diese Maßnahmen ist ein System, das funktioniert – bis zum ersten schlechten Tag.
Dateneigentum und die Falle der gemeinsam genutzten Datenbank
Jeder Service besitzt seine eigenen Daten. Das bedeutet, dass ein Service der einzige ist, der in sein Schema schreibt, und kein anderer Service eine Verbindung zu dieser Datenbank herstellt. Dieses Eigentum ist die Voraussetzung für ein unabhängiges Deployment, da eine Schemaänderung nur den besitzenden Service und dessen Contract betrifft.
Eine gemeinsam genutzte Datenbank wirkt zunächst bequem, zerstört jedoch schleichend die Architektur. Zwei Services, die dieselben Tabellen lesen, sind über das Schema gekoppelt: Benennen Sie eine Spalte um, und beide müssen gleichzeitig deployed werden. Schlimmer noch: Das gemeinsame Schema wird zu einem De-facto-Shared-Model, wodurch die Bounded Contexts wieder zu einer einzigen verschmelzen.
Cross-Service-Queries sind meist der Grund, warum Teams zu einer gemeinsam genutzten Datenbank greifen. Die Lösungen hierfür sind Read-Models und APIs:
- Fragen Sie den besitzenden Service über dessen API nach einer kleinen, zweckgebundenen Antwort.
- Abonnieren Sie dessen Events und pflegen Sie eine lokale Projection, die auf Ihre Queries zugeschnitten ist.
- Akzeptieren Sie, dass einige Abfragen einen dedizierten Reporting-Store benötigen und kein Join über Service-Datenbanken hinweg möglich ist.
Eine Projection ist denormalisiert und eventually consistent – genau deshalb ist sie schnell und weshalb Sie mit einer kurzen Verzögerung leben müssen. Dies ist die Read-Seite von CQRS und der Standardweg, um Fragen über Servicegrenzen hinweg zu beantworten, ohne das Dateneigentum zu verletzen.
Sagas statt verteilter Transaktionen
Es gibt keine ACID-Transaktionen, die sich über mehrere Services erstrecken. Ein Two-Phase-Commit erfordert, dass alle Teilnehmer Locks halten und verfügbar sind – genau die Bedingung, die man über ein Netzwerk hinweg nicht garantieren kann, und die von den meisten modernen Datastores nicht unterstützt wird. Der Ersatz hierfür ist die Saga.
Eine Saga ist eine Sequenz lokaler Transaktionen (eine pro Service), bei der jeder Schritt ein Event veröffentlicht oder den nächsten Schritt aufruft. Wenn ein späterer Schritt fehlschlägt, führt die Saga kompensierende Aktionen aus, um die bereits abgeschlossene Arbeit auf geschäftlicher Ebene rückgängig zu machen. Betrachten wir das Beispiel einer Bestellung:
- Orders erstellt die Bestellung als
pending. - Inventory reserviert den Lagerbestand.
- Payments belastet den Kunden.
- Fulfilment plant den Versand ein und Orders markiert die Bestellung als
confirmed.
Wenn die Zahlung fehlschlägt, gibt die Kompensation die Reservierung frei und storniert die Bestellung. Beachten Sie, dass ein „Undo“ kein Rollback ist; es ist ein neuer geschäftlicher Fakt. Man kann eine Bestätigungs-E-Mail nicht „un-senden“, daher besteht die Kompensation aus einer zweiten E-Mail, die die Stornierung erklärt.
Da jeder Schritt wiederholt werden kann, muss jeder Schritt idempotent sein. Dieselbe Bestellung darf nicht zweimal berechnet werden. Leiten Sie einen Deduplizierungs-Key aus der Geschäftsoperation ab – der Order-ID –, nicht aus einer zufälligen Request-ID, und nutzen Sie einen Unique Constraint oder einen SET NX, um die Prüfung atomar zu gestalten. Sagas sind der anspruchsvollste Teil von Microservices. Wer den Kompensationspfad vernachlässigt, riskiert Systeme mit hängengebliebenen Reservierungen und Doppelabbuchungen.
Contracts, Versionierung und Kompatibilität
Der Contract eines Services ist seine API, von der die Aufrufer abhängen. Behandeln Sie diesen als veröffentlichtes Interface mit einem eigenen Lebenszyklus:
- Erweitern, nicht brechen. Neue optionale Felder sind unbedenklich; das Umbenennen, Entfernen oder Ändern der Bedeutung eines Feldes hingegen nicht.
- Bewusste Versionierung. Eine Versionierung über die URL oder Header macht Breaking Changes explizit. Die Guides zu REST und API-Versionierung erläutern die jeweiligen Vor- und Nachteile.
- Konsumenten Zeit geben. Kündigen Sie Deprecations an, erfassen Sie Nutzungsmetriken pro Client und entfernen Sie einen Endpoint erst, wenn er von niemandem mehr aufgerufen wird.
- Den Contract von beiden Seiten testen. Consumer-driven Contract Tests decken Fälle ab, in denen eine vermeintlich „kompatible“ Änderung des Producers für einen spezifischen Consumer nicht kompatibel ist.
Die gleiche Disziplin gilt für Event-Schemas. Ein Consumer, der ein Topic liest, wird Nachrichten sehen, die von einer älteren Version des Producers geschrieben wurden. Daher müssen Events additiv und selbsterklärend sein. Fügen Sie eine Version oder eine Schema-ID in den Envelope ein und erlauben Sie den Consumern, unbekannte Felder zu tolerieren.
Discovery, Gateways und Load Balancing
Services verändern ihre Position. Instanzen werden neu geplant, skaliert und ersetzt, weshalb Aufrufer keine Adressen hartcodieren können. Service Discovery löst dieses Problem: Entweder fragt der Client ein Registry nach gesunden Instanzen ab (client-seitig), oder eine stabile virtuelle Adresse vor den Instanzen übernimmt dies (server-seitig). Kubernetes bietet Ihnen Letzteres kostenlos über Services und DNS an.
Ein API Gateway sitzt an der Edge und übernimmt Aufgaben, die sonst jeder Service einzeln implementieren müsste: Authentifizierung, Rate Limiting, TLS Termination, Request Routing und Response Aggregation. Es ist äußerst nützlich. Gleichzeitig wird es jedoch zu einem Single Point of Failure und – falls es zu viel Business-Logik ansammelt – zu einem Miniatur-Distributed-Monolith. Halten Sie das Gateway schlank und verschieben Sie funktionsspezifische Regeln zurück in den jeweiligen Service.
Load Balancing ist mehr als nur Round-Robin. Langlebige Verbindungen, Sticky Sessions und langsame Consumer beeinflussen alle, wie gleichmäßig sich der Traffic verteilt. Lassen Sie den Load Balancer der Plattform seine Arbeit machen und gestalten Sie Services zustandslos (stateless), sodass jede Instanz jede Anfrage bedienen kann.
Observability über Services hinweg
In einem Monolithen erklärt ein Stack Trace normalerweise den Vorfall. Über mehrere Services hinweg hat die Anfrage Ihren Prozess bereits vor mehreren Hops verlassen und die Spur ist verloren gegangen. Drei Instrumente stellen diese wieder her.
- Correlation IDs. Generieren Sie eine ID am Edge, propagieren Sie diese in Headern und Event-Envelopes und fügen Sie sie in jede Log-Zeile ein. Sie ist der rote Faden, der eine Benutzeraktion mit jedem Service verknüpft, den sie berührt hat.
- Distributed Tracing. OpenTelemetry Spans zeichnen die Zeit auf, die in jedem Service und jedem Aufruf verbracht wurde. Ein Trace zeigt auf einen Blick, welcher Hop langsam ist – eine Frage, die Logs allein nicht beantworten können.
- Metrics pro Service. Tracken Sie Request-Rate, Error-Rate und Duration für jeden Endpoint sowie Saturation-Signale wie Queue-Tiefe und Pool-Auslastung. Alarmieren Sie bei Symptomen, die Ihre Nutzer spüren, nicht bei jedem kleinen Ausschlag.
Logs sollten strukturiertes JSON mit einem Service-Namen und der Correlation ID sein und an einem zentralen Ort gesammelt werden. Ein Log, das Sie nicht über Services hinweg durchsuchen können, ist ein Log, das Sie um 3 Uhr morgens nicht nutzen werden. Der Guide zu Cloud Deployment behandelt die operative Seite der Erfassung dieser Signale.
Deadlines und Request-Budgets
Die Latenz in einer Call-Chain ist additiv, und jeder Service in der Kette stochert im Dunkeln, sofern Sie keine Deadline propagieren. Ein benutzerseitiger Request, der innerhalb von 800ms beantwortet werden muss, kann es sich nicht leisten, vier Downstream-Calls zu tätigen, von denen jeder bereit ist, zwei Sekunden zu warten.
Weisen Sie jedem Request am Edge ein Budget zu und geben Sie die verbleibende Zeit mit dem Call weiter. Jeder Service zieht die Zeit für seine eigene Arbeit ab und leitet eine kürzere Deadline weiter, sodass die Kette schnell fehlschlägt (“fail fast”), anstatt Timeouts zu stapeln.
// The gateway sets the budget once.
const deadline = Date.now() + 800;
await orders.place(input, { deadline }); // forwards the remaining time
Wenn ein Service feststellt, dass die Deadline überschritten wurde, sollte er die Verarbeitung stoppen und einen ehrlichen Fehler zurückgeben, anstatt weitere Arbeit zu beginnen, die er nicht mehr abschließen kann. Kombinieren Sie dies mit einem Fallback für nicht essenzielle Calls: Wenn die Empfehlungen nicht innerhalb von 100ms antworten können, liefern Sie die Seite ohne diese aus. Ein Request-Budget verwandelt ein “manchmal hängt es” in einen vorhersagbaren p99 und ist einer der kostengünstigsten Gewinne an Zuverlässigkeit, die über verschiedene Services hinweg erzielt werden können.
Deployment und Infrastruktur
Ein unabhängiges Deployment ist eine Eigenschaft Ihrer Pipeline, nicht nur Ihrer Topologie. Jeder Service benötigt einen eigenen Build-, Test-, Image- und Rollout-Prozess sowie eine Möglichkeit, Datenbank-Migrationen ohne Downtime durchzuführen und einen Rollback, der nicht vom Redeployment aller anderen Komponenten abhängt.
In der Regel bedeutet das: Container und ein Orchestrator. Container machen die Runtime des Services portabel und seine Abhängigkeiten explizit; Kubernetes oder ein managed Äquivalent übernimmt das Scheduling, Discovery, Health Checks, Autoscaling und Rolling Updates. Zudem benötigen Sie Konfigurationen und Secrets, die pro Umgebung bereitgestellt werden, sowie eine Strategie für Schema-Migrationen, die mit der vorherigen Code-Version kompatibel bleibt, da während eines Rollouts alte und neue Instanzen nebeneinander laufen.
Das ist die sogenannte „Platform Tax“. Sie ist real, sie ist dauerhaft und sie ist der Grund, warum kleine Teams genau überlegen sollten, bevor sie diesen Architekturstil übernehmen.
Der verteilte Monolith
Der verteilte Monolith ist das schlimmste Ergebnis: viele Services, die dennoch gemeinsam deployed werden müssen. Die Symptome sind eindeutig.
- Services teilen sich eine Datenbank, sodass Schema-Änderungen Teamgrenzen überschreiten.
- Ein Release erfordert die gleichzeitige Koordination mehrerer Services.
- Eine synchrone Aufrufkette erstreckt sich über vier Services für eine einzige Benutzeraktion.
- Zwei Services werden immer im selben Pull Request geändert.
Wenn Sie diese Anzeichen sehen, sind die Grenzen falsch gesetzt. Die üblichen Ursachen sind eine Aufteilung nach technischen Layern, eine Aufteilung, bevor die Domain vollständig verstanden wurde, oder die Beibehaltung einer gemeinsamen Datenbank nach dem Split. Die Lösung besteht oft darin, die betroffenen Services wieder zusammenzuführen und eine bessere Trennlinie zu finden – ein schweres Eingeständnis, aber günstiger als ein Jahrzehnt koordinierter Releases.
Wann man keine Microservices einsetzen sollte
Teilen Sie Ihre Anwendung nicht auf, wenn einer der folgenden Punkte zutrifft:
- Das Team ist klein genug, sodass jeder die gesamte Anwendung sicher deployen kann.
- Die Domain wird gerade erst erschlossen, sodass Grenzziehungen lediglich Vermutungen wären.
- Das Produkt befindet sich in einer frühen Phase und die Anforderungen ändern sich wöchentlich.
- Sie verfügen noch nicht über die CI/CD, Observability und On-Call-Kapazitäten, um viele Services zu betreiben.
In all diesen Fällen bietet Ihnen ein modularer Monolith die gleiche interne Disziplin, jedoch mit nur einem Deployment, In-Process-Transaktionen und Refactorings, die keinen Migrationsplan erfordern. Da die Module über explizite Interfaces kommunizieren, kann ein Modul später in einen eigenen Service ausgegliedert werden, sobald ein konkreter Grund dafür besteht: ein Team, das seinen eigenen Rhythmus benötigt, eine Komponente mit einem grundlegend anderen Skalierungsprofil oder eine Compliance-Grenze. Das Ausgliedern eines sauberen Moduls ist weitaus günstiger, als eine fehlerhafte Aufteilung wieder zusammenzuführen.
Migration mit dem Strangler Fig Pattern
Wenn Sie ein bestehendes System aufteilen, tun Sie dies inkrementell. Das Strangler Fig Pattern leitet den Traffic über eine Fassade und verschiebt eine Funktionalität nach der anderen, bis der alte Code nicht mehr genutzt wird und gelöscht werden kann.
- Eine Nahtstelle finden. Wählen Sie eine Funktionalität aus, die bereits weitgehend eigenständig ist und häufig geändert wird. Eine saubere Modulgrenze ist hierfür der beste Kandidat.
- Eine Fassade vorschalten. Leiten Sie den relevanten Traffic über einen Proxy oder ein Gateway, sodass Sie diesen verschieben können, ohne die Clients zu beeinflussen.
- Das Modul in einen Service extrahieren. Geben Sie dem Modul ein eigenes Deployment und eine eigene Pipeline und verschieben Sie die zugehörigen Tabellen in eine eigene Datenbank.
- Daten sorgfältig synchronisieren. Nutzen Sie Dual-Writes oder veröffentlichen Sie Events und führen Sie Backfills durch, bis der neue Store die maßgebliche Quelle (Authoritative Source) ist. Beenden Sie erst dann die Schreibvorgänge in den alten Tabellen.
- Umschalten und Entfernen. Verschieben Sie den Traffic, beobachten Sie die Metriken und löschen Sie den alten Codepfad, sobald dieser nicht mehr genutzt wird.
Beginnen Sie niemals mit einem Big-Bang-Rewrite. Der Wert des Strangler-Ansatzes liegt darin, dass jeder Schritt umkehrbar ist und das System die gesamte Zeit über in Produktion bleibt.
Zuerst den Contract definieren
Ein interner HTTP-Contract neigt leicht zum „Drift“, da Producer und Consumer oft von verschiedenen Teams geschrieben werden und dies erst durch Integrationstests auffällt. Wenn man den Contract vor der Implementierung beider Seiten als Schema definiert, stellt dies die Konsistenz sicher und ermöglicht es, Clients, Server und Dokumentationen aus einer einzigen Quelle zu generieren.
// inventory.proto
service Inventory {
rpc GetStock(GetStockRequest) returns (StockLevel);
rpc Reserve(ReserveRequest) returns (Reservation);
}
message GetStockRequest { string sku = 1; }
message StockLevel { string sku = 1; int32 available = 2; }
Für HTTP übernimmt ein OpenAPI-Dokument dieselbe Rolle. In jedem Fall ist das Schema das Artefakt, das überprüft wird, sodass ein Breaking Change in einem Diff sichtbar wird und nicht erst in der Produktion. Consumer-driven Contract Tests schließen den Kreis, indem sie kodieren, was jeder Consumer tatsächlich nutzt. So weiß ein Producer bereits vor dem Release, ob seine Änderungen für die existierenden Caller sicher sind.
Den Transport an die Interaktion anpassen
Der Fehler besteht darin, einen einzigen Transport für alles zu verwenden. Ein reines REST-System serialisiert jede Reaktion hinter einer Aufrufkette; ein reines Event-System macht eine einfache Abfrage absurd indirekt. Wählen Sie pro Interaktion, nicht pro System.
| Interaktion | Transport | Warum |
|---|---|---|
| Browser zu Backend | REST/JSON | Allgegenwärtig, cachebar, einfach zu debuggen |
| Service zu Service, geringe Latenz | gRPC | Typisiert, kompakt, generierte Clients |
| Fire-and-forget Reaktion | Events | Keine zeitliche Kopplung, gepuffert |
| Langlaufender Workflow | Events plus eine Saga | Dauerhaft, wiederholbar, kompensierbar |
| High-Volume Read | Cache oder Read Model | Vermeidet den Aufruf komplett |
Ein nützlicher Standardansatz ist es, Schreibvorgänge über einen synchronen Aufruf autoritativ zu gestalten, wenn der Aufrufer die Antwort benötigt, und für jeden Effekt, auf den der Aufrufer nicht warten muss, ein Event zu veröffentlichen.
Idempotency Keys in der Praxis
Sagas, Retries und At-Least-Once-Events führen alle dazu, dass dieselbe Operation doppelt ankommen kann. Die einzige Verteidigung, die gegen Concurrency beständig ist, ist ein Deduplizierungs-Check, der atomar mit dem Side Effect erfolgt.
export async function capturePayment(cmd: CapturePayment) {
const key = `payment:captured:${cmd.orderId}`;
const inserted = await redis.set(key, "1", "NX", "EX", 86_400);
if (inserted === null) return { skipped: true };
return gateway.charge(
{ orderId: cmd.orderId, amountCents: cmd.amountCents },
{ idempotencyKey: cmd.orderId },
);
}
Dabei sind drei Details entscheidend. Der Key wird aus der Business-Operation abgeleitet und nicht aus einer zufälligen Request-ID, sodass ein Producer, der dieselbe logische Arbeit zweimal in die Queue schreibt, immer noch eine Kollision auslöst. Der Check und das Schreiben des Markers erfolgen in einer einzigen atomaren Operation, sodass zwei konkurrierende Worker nicht beide gewinnen können. Und dem Provider wird sein eigener Idempotency Key übergeben, da Ihr Marker verloren gehen kann, während der Datensatz des Providers erhalten bleibt.
Outbox, Inbox und Exactly-Once-Effekte
Ein Service, der in seine Datenbank schreibt und anschließend ein Event veröffentlicht, hat eine Schwachstelle: Der Prozess kann zwischen diesen beiden Schritten abstürzen, wodurch der Zustand zwar geändert, das Event jedoch nicht gesendet wurde. Das Outbox-Pattern schließt diese Lücke, indem das Event in eine outbox-Tabelle innerhalb derselben lokalen Transaktion wie die Zustandsänderung geschrieben wird; anschließend veröffentlicht ein Relay die entsprechenden Zeilen.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
id: randomUUID(),
topic: "orders.created",
key: order.id,
payload: order.toEvent(),
});
});
// A separate relay polls outbox and publishes, at least once.
Die Consumer-Seite ist das Spiegelbild davon. Da die Zustellung nach dem At-Least-Once-Prinzip erfolgt, kann ein Consumer dasselbe Event zweimal erhalten. Führen Sie eine Inbox-Tabelle mit den IDs verarbeiteter Events und überspringen Sie Duplikate – ebenfalls innerhalb derselben Transaktion wie der Effekt. Outbox und Inbox zusammen ermöglichen eine effektiv Exactly-Once-Verarbeitung, ohne vorzugeben, dass das Netzwerk zuverlässig sei.
Bulkheads und Graceful Degradation
Ein Circuit Breaker stoppt die Aufrufe einer fehlerhaften Dependency. Ein Bulkhead geht einen Schritt weiter und isoliert Ressourcen, sodass eine einzelne Dependency nicht alle verfügbaren Kapazitäten verbrauchen kann. Wenn der Recommendations-Service seinen eigenen Connection Pool besitzt, kann eine Verzögerung dort nicht den Pool erschöpfen, von dem der Checkout-Prozess abhängt.
const pools = {
checkout: new Pool({ max: 20 }),
recommendations: new Pool({ max: 5, timeoutMs: 500 }),
};
Degradation ist die benutzerseitige Hälfte desselben Konzepts. Wenn eine nicht essenzielle Dependency langsam reagiert, sollte eine reduzierte Antwort anstelle einer Fehlermeldung zurückgegeben werden. Eine Produktseite ohne personalisierte Empfehlungen ist immer noch eine gute Seite; eine Produktseite, die in einen Timeout läuft, weil die Empfehlungen zu lange gebraucht haben, ist ein Ausfall. Entscheiden Sie pro Aufruf, ob dieser zwingend erforderlich oder lediglich „Best-Effort“ ist, und hinterlegen Sie diese Entscheidung direkt dort, wo der Aufruf erfolgt.
Der Anti-Corruption Layer
Wenn ein Service das Modell eines anderen Services direkt konsumiert, übernimmt er die Annahmen dieses Modells. Jede Änderung an der Order-Struktur des Produzenten wirkt sich somit auf jeden Consumer aus. Ein Anti-Corruption Layer ist eine dünne Übersetzungsschicht, die den externen Contract in die eigene Domain-Sprache des Consumers überführt.
// Shipping has its own idea of a shipment. It translates the event.
function toShipment(event: OrderCreated): ShipmentRequest {
return {
reference: event.id,
destination: event.shippingAddress,
lines: event.lines.map((l) => ({ sku: l.sku, quantity: l.qty })),
};
}
Diese Schicht erfordert zwar etwas zusätzlichen Code, bringt aber echte Unabhängigkeit. Der Consumer kann seine eigenen Konzepte frei benennen, unbekannte Felder tolerieren und Breaking Changes von Drittanbietern, die er nicht selbst kontrolliert, abfangen.
Erkennen, ob die Aufteilung funktioniert
Microservices sind ein Mittel zum Zweck, kein Ziel an sich – messen Sie daher das, was Sie eigentlich erreichen wollten. Die Signale, die darauf hindeuten, dass sich die Aufteilung auszahlt, sind organisatorischer Natur: die Anzahl der Teams, die unabhängig voneinander deployen können, die Lead Time vom Commit bis zur Produktion für einen einzelnen Service und wie oft eine einzige Änderung mehrere Services betrifft. Wenn sich diese Zahlen nicht verbessern, rechtfertigt die Topologie nicht ihren Aufwand.
Die Signale dafür, dass es schlecht läuft, sind technischer Natur und leicht zu erkennen:
- Deploys erfolgen über die Services hinweg immer noch in einer festgelegten Reihenfolge.
- Eine einzige Benutzeraktion löst eine lange synchrone Aufrufkette aus.
- Eine Schema-Änderung in einem Service erfordert den Release eines anderen Teams.
- Die On-Call-Rotation kann nicht feststellen, welcher Service einen Incident verursacht hat.
Jedes dieser Anzeichen bedeutet, dass Sie einen verteilten Monolithen haben. Die Lösung besteht in der Regel darin, eine Grenze aufzuheben und Services zusammenzuführen, anstatt einen weiteren Service hinzuzufügen.
Eine Checkliste vor der Aufteilung
Bevor Sie ein Modul aus einem Monolithen auslagern, stellen Sie sicher, dass all die folgenden Punkte zutreffen:
- Das Modul besitzt bereits seine eigenen Daten und niemand sonst schreibt in seine Tabellen.
- Andere Module hängen von seinem Interface ab, nicht von seinen Internals.
- Sie haben einen Grund, der über ein bloßes „es fühlt sich zu groß an“ hinausgeht: zum Beispiel die Taktung eines Teams, ein spezifisches Scaling-Profil oder eine Compliance-Grenze.
- Sie verfügen über CI/CD, Tracing und On-Call-Kapazitäten für eine weitere deploybare Einheit.
- Sie haben einen Plan für die Datenmigration und eine Möglichkeit für einen Rollback.
- Sie haben entschieden, wie das Modul mit dem Rest kommuniziert – synchron oder asynchron – und den Contract bereits definiert.
Wenn eine dieser Fragen mit „Nein“ beantwortet wird, wird die Extraktion teurer sein, als es zunächst aussieht. Die Boundary zuerst innerhalb des Monolithen zu korrigieren, ist fast immer günstiger, als sie über ein Netzwerk hinweg zu beheben.
Best Practices
- Splitten Sie für unabhängiges Deployment und Team-Autonomie, nicht für die Skalierung.
- Richten Sie jeden Service an einem Bounded Context aus und weisen Sie ihm einen klaren Owner zu.
- Geben Sie jedem Service seine eigene Datenbank und verbieten Sie Cross-Service-Queries.
- Setzen Sie Timeouts, begrenzte Retries mit Jitter und Circuit Breaker für jeden Aufruf ein.
- Machen Sie Schreiboperationen idempotent, bevor Sie Retries zulassen.
- Bevorzugen Sie Events für Reaktionen, die keine sofortige Antwort benötigen.
- Nutzen Sie Sagas mit Kompensationen anstelle von verteilten Transaktionen.
- Versionieren Sie Contracts und gestalten Sie jede Änderung additiv.
- Propagieren Sie eine Correlation ID und implementieren Sie Tracing ab dem ersten Service.
- Halten Sie das Gateway schlank und verschieben Sie die Business-Logik in die Services.
- Extrahieren Sie Services mithilfe des Strangler Fig Patterns, eine Capability nach der anderen.
Häufige Fehler
- Aufteilung nach technischen Layern, was dazu führt, dass für jedes Feature drei Services benötigt werden.
- Nutzung einer gemeinsamen Datenbank und das anschließende Rätsel, warum Releases immer noch koordiniert werden müssen.
- Services gemeinsam deployen und das Ergebnis als Microservices bezeichnen.
- Implementierung von Retries ohne Idempotenz, was zu Doppelbuchungen bei Kunden führt.
- Timeouts nicht setzen, sodass eine einzige langsame Dependency die gesamte Aufrufkette blockiert.
- Verwendung synchroner Ketten, wo ein Event die Kopplung aufheben würde.
- Aufbau einer Saga ohne Kompensationspfad für Fehlerfälle.
- Verzicht auf Distributed Tracing und Debugging durch bloßes Raten anhand von Logs.
- Zu frühes Splitten, wodurch falsche Grenzen in einer teuren Infrastruktur zementiert werden.
- Kubernetes und ein Service Mesh als Ziel anstatt als Kostenfaktor für das eigentliche Ziel zu betrachten.
Wie geht es weiter?
Wenn die hier beschriebenen Kompromisse für dein Team zu gewichtig erscheinen, lies den Guide zum Modular Monolith — dies ist der richtige Standardansatz und die beste Vorbereitung für eine zukünftige Aufteilung. Um die Kommunikation zwischen Services zu ermöglichen, ohne dass diese sich gegenseitig aufrufen müssen, ist der Guide zur Event-Driven Architecture der nächste Schritt, mit Kafka als zugrunde liegendem Log. Sobald du viele Services betreibst, behandelt der Guide zum Cloud Deployment die Plattform- und Observability-Themen, die für einen stabilen Betrieb notwendig sind.