1. Raus aus dem Event-Chaos
Willkommen zu Teil 3 unserer Serie über das SAGA Pattern. In Teil 2 haben wir eine Choreografie-basierte Saga implementiert. Die Dienste kommunizierten lose gekoppelt über Events, was elegant und dezentral wirkte.
Doch Hand aufs Herz: Bei der Implementierung des Fehlerfalls haben wir schnell gemerkt, wie unübersichtlich dieser Ansatz werden kann. Die Geschäftslogik war über alle Services verteilt. Um einen Fehler im Payment Service zu kompensieren, musste der Order Service plötzlich auf ein payment.failed-Event lauschen. Was passiert, wenn 5 oder 6 Services beteiligt sind? Die Anzahl der Event-Verknüpfungen explodiert, und das Monitoring des Gesamtprozesses wird zum Albtraum.
Genau hier setzt die Orchestrierungs-Alternative an.
Statt einer Gruppe von Tänzern, die aufeinander reagieren (Choreografie), führen wir einen zentralen Dirigenten ein: den SAGA Orchestrator. Dieser Orchestrator ist ein eigener Service, der den gesamten Geschäftsprozess kennt. Er allein weiß, dass auf die Bestellung (Schritt 1) die Zahlung (Schritt 2) und danach die Inventar-Prüfung (Schritt 3) folgt.
Die einzelnen Microservices (Order, Payment, Inventory) werden “dumm”. Sie kennen die Saga nicht. Sie werden zu reinen Befehlsempfängern, die nur auf Anweisungen des Orchestrators warten, ihre atomare lokale Transaktion ausführen und anschließend “Erfolg” oder “Fehler” zurückmelden.
In diesem Artikel bauen wir genau das: Einen zentralen Spring Boot Orchestrator, der über RabbitMQ-Commands die einzelnen Services steuert und den Zustand der Saga in seiner eigenen Datenbank verwaltet.
2. Die Architektur – Ein neuer Chef im Ring
Der Kern der Orchestrierungs-Architektur ist der neue SagaOrchestrator-Service. Dieser Service hat zwei Hauptaufgaben:
- State Management: Er verwaltet den Zustand jeder laufenden Saga (z.B.
OrderID-123ist gerade beiSTEP_PAYMENT). - Command & Control: Er sendet Befehle (Commands) an die Services und wartet auf deren Antwort-Events (Replies).
Die anderen Services (Order, Payment, Inventory) werden angepasst: Sie lauschen nicht mehr auf Business-Events anderer Services (wie OrderCreated), sondern nur noch auf direkte Commands, die an sie gerichtet sind (z.B. CreateOrderCommand).
📡 Der Kommunikationsfluss (Command/Reply)
Wir nutzen RabbitMQ weiterhin, aber das Muster ändert sich fundamental. Statt eines “Publish/Subscribe”-Modells (Events) nutzen wir ein “Command/Reply”-Modell.
- Der Orchestrator sendet einen Command (z.B.
ProcessPaymentCommand) an eine dedizierte Queue (z.B.payment_command_queue). - Der
Payment Servicelauscht auf diese Queue. - Nach der Verarbeitung sendet der
Payment Serviceein Reply-Event (z.B.PaymentSuccessfuloderPaymentFailed) zurück an eine Queue, auf die nur der Orchestrator lauscht (z.B.orchestrator_reply_queue).
Der “Happy Path” sieht nun so aus:
- Client ->
SagaOrchestrator(REST API): Ein Kunde löst die Bestellung aus. Der Orchestrator erstellt einen neuen Saga-Eintrag in seiner DB (StatusSTARTED). Orchestrator-> (Command): SendetCreateOrderCommandan dieorder_command_queue.Order Service: Empfängt Command, legt Bestellung an (StatusPENDING), sendetOrderCreatedReply.Orchestrator: EmpfängtOrderCreatedReply, aktualisiert Saga-Status (z.B. aufAWAITING_PAYMENT), sendetProcessPaymentCommandanpayment_command_queue.Payment Service: Empfängt Command, verarbeitet Zahlung, sendetPaymentSuccessfulReply.Orchestrator: EmpfängtPaymentSuccessfulReply, aktualisiert Saga-Status, sendetUpdateInventoryCommandaninventory_command_queue.Inventory Service: Empfängt Command, bucht Ware, sendetInventorySuccessfulReply.Orchestrator: EmpfängtInventorySuccessfulReply, markiert Saga in seiner DB alsCOMPLETED.
Der entscheidende Unterschied: Kein Service spricht mit einem anderen Service. Alle kommunizieren ausschließlich mit dem Orchestrator. Der Payment Service weiß nichts von einem Order Service. Er weiß nur, wie er ein ProcessPaymentCommand verarbeiten muss.
3. Implementierung – State Management & Commands
Der Orchestrator muss zu jedem Zeitpunkt wissen, in welchem Zustand sich jede einzelne Saga befindet. Dafür ist eine eigene Datenbanktabelle unerlässlich. Gleichzeitig muss er die Command/Reply-Kommunikation über RabbitMQ steuern.
🗄️ Das State Management
Im SagaOrchestrator-Service definieren wir eine Entity, die den Zustand einer Saga abbildet. Wir verwenden hier JPA und eine beliebige relationale Datenbank (z.B. PostgreSQL).
// Im SagaOrchestrator Service
@Entity
public class SagaInstance {
@Id
private UUID sagaId; // Eindeutige ID für diesen Prozessfluss
private UUID orderId; // Die ID des zugehörigen Business-Objekts
@Enumerated(EnumType.STRING)
private SagaStatus status; // z.B. STARTED, AWAITING_PAYMENT, AWAITING_INVENTORY, COMPLETED, FAILED
private String currentStep; // z.B. "payment"
// ... Konstruktoren, Getter, Setter ...
}
public enum SagaStatus {
STARTED,
AWAITING_PAYMENT,
AWAITING_INVENTORY,
COMPENSATING_ORDER,
COMPENSATING_PAYMENT,
COMPLETED,
FAILED
}
// Dazu ein Spring Data JPA Repository
public interface SagaInstanceRepository extends JpaRepository<SagaInstance, UUID> {
}
Wenn der Client die Saga über die REST-API des Orchestrators startet, wird eine neue SagaInstance erstellt, in der DB gespeichert (Status STARTED) und der erste Command ausgelöst.
📬 Die Command/Reply-Kommunikation
Wir definieren klare Queues für jeden Service und eine zentrale Reply-Queue für den Orchestrator.
- Commands: Der Orchestrator sendet Commands an
order_command_queue,payment_command_queueusw. - Replies: Alle Services (
Order,Payment,Inventory) senden ihre Antworten (Erfolg oder Fehler) an dieselbeorchestrator_reply_queue.
Beispiel: Der Orchestrator startet die Saga
Der Orchestrator empfängt den initialen API-Call, speichert den State und sendet den ersten Command.
// Im SagaOrchestrator Service
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private SagaInstanceRepository sagaRepository;
public void startSaga(InitialOrderDto orderDto) {
// 1. Saga-Instanz erstellen und speichern
SagaInstance saga = new SagaInstance();
saga.setSagaId(UUID.randomUUID());
saga.setOrderId(orderDto.getOrderId()); // Annahme: OrderID wird hier generiert oder übergeben
saga.setStatus(SagaStatus.STARTED);
saga.setCurrentStep("order");
sagaRepository.save(saga);
// 2. Ersten Command senden
CreateOrderCommand command = new CreateOrderCommand(saga.getSagaId(), orderDto);
// Wir senden an die spezifische Command-Queue
rabbitTemplate.convertAndSend("order_command_queue", command);
}
Beispiel: Der Order Service reagiert
Der Order Service ist jetzt nur noch ein “dummer” Arbeiter. Er lauscht auf seine Command-Queue und sendet eine Antwort an die Reply-Queue des Orchestrators.
// Im Order Service
@Autowired
private RabbitTemplate rabbitTemplate;
@RabbitListener(queues = "order_command_queue")
public void handleCreateOrder(CreateOrderCommand command) {
try {
// ... Logik zum Speichern der Bestellung ...
// Bestellung wird lokal mit Status PENDING gespeichert
// 3. Erfolgs-Reply an den Orchestrator senden
OrderCreatedReply reply = new OrderCreatedReply(command.getSagaId(), /* ... */);
rabbitTemplate.convertAndSend("orchestrator_reply_queue", reply);
} catch (Exception e) {
// 4. Fehler-Reply an den Orchestrator senden
OrderFailedReply reply = new OrderFailedReply(command.getSagaId(), e.getMessage());
rabbitTemplate.convertAndSend("orchestrator_reply_queue", reply);
}
}
Beispiel: Der Orchestrator empfängt Replies
Der Orchestrator hat einen zentralen Listener für alle Antworten. Hier findet die eigentliche “Orchestrierung” statt – basierend auf der Antwort wird der nächste Schritt entschieden.
// Im SagaOrchestrator Service
@RabbitListener(queues = "orchestrator_reply_queue")
public void handleReplies(Object replyMessage) {
SagaInstance saga; // Wird aus der DB geladen
if (replyMessage instanceof OrderCreatedReply reply) {
saga = sagaRepository.findById(reply.getSagaId()).orElse(null);
if (saga == null || saga.getStatus() != SagaStatus.STARTED) return; // Idempotenz
// 5. State aktualisieren und nächsten Command senden
saga.setStatus(SagaStatus.AWAITING_PAYMENT);
saga.setCurrentStep("payment");
sagaRepository.save(saga);
ProcessPaymentCommand command = new ProcessPaymentCommand(saga.getSagaId(), /* ... */);
rabbitTemplate.convertAndSend("payment_command_queue", command);
} else if (replyMessage instanceof PaymentSuccessfulReply reply) {
saga = sagaRepository.findById(reply.getSagaId()).orElse(null);
if (saga == null || saga.getStatus() != SagaStatus.AWAITING_PAYMENT) return;
// 6. Nächster Schritt: Inventory
saga.setStatus(SagaStatus.AWAITING_INVENTORY);
saga.setCurrentStep("inventory");
sagaRepository.save(saga);
UpdateInventoryCommand command = new UpdateInventoryCommand(saga.getSagaId(), /* ... */);
rabbitTemplate.convertAndSend("inventory_command_queue", command);
} else if (replyMessage instanceof InventorySuccessfulReply reply) {
saga = sagaRepository.findById(reply.getSagaId()).orElse(null);
if (saga == null) return;
// 7. SAGA ERFOLGREICH BEENDET
saga.setStatus(SagaStatus.COMPLETED);
saga.setCurrentStep(null);
sagaRepository.save(saga);
System.out.println("Saga " + saga.getSagaId() + " COMPLETED.");
} else if (replyMessage instanceof PaymentFailedReply reply) {
// ... Fehlerbehandlung (kommt im nächsten Abschnitt) ...
}
// ... weitere Reply-Typen ...
}
Das ist das Grundgerüst. Wir haben eine klare Trennung: Der Orchestrator verwaltet den State und steuert den Fluss, die Services führen nur atomare Operationen aus.
4. Der “Unhappy Path” (zentral gesteuert)
In unserem Choreografie-Beispiel (Teil 2) war der Fehlerfall kompliziert: Der Order Service musste auf ein payment.failed-Event lauschen und “wissen”, dass er sich selbst kompensieren muss.
Im Orchestrierungs-Modell ist das drastisch einfacher. Die Verantwortung liegt einzig und allein beim Orchestrator.
Szenario: Die Zahlung schlägt fehl
- Der
Payment Serviceversucht, die Zahlung für dasProcessPaymentCommandauszuführen, aber sie schlägt fehl (z.B. “Insufficient funds”). - Der
Payment Servicemacht nichts weiter, als dem Orchestrator zu “melden”, dass er versagt hat. Er sendet einPaymentFailedReplyan dieorchestrator_reply_queue.
Die zentrale Kompensation im Orchestrator
Jetzt schauen wir uns den fehlenden else if-Block in unserem Orchestrator-Listener an:
// Im SagaOrchestrator Service
@RabbitListener(queues = "orchestrator_reply_queue")
public void handleReplies(Object replyMessage) {
SagaInstance saga;
// ... (All die "Happy Path" Blöcke von oben) ...
// HIER IST DER UNHAPPY PATH:
else if (replyMessage instanceof PaymentFailedReply reply) {
saga = sagaRepository.findById(reply.getSagaId()).orElse(null);
if (saga == null || saga.getStatus() != SagaStatus.AWAITING_PAYMENT) {
// Bereits kompensiert oder veraltete Nachricht, ignorieren (Idempotenz)
return;
}
// 1. FEHLER ERKANNT: State auf Kompensation setzen
System.out.println("Saga " + saga.getSagaId() + " failed at Payment. Initiating compensation...");
saga.setStatus(SagaStatus.COMPENSATING_ORDER);
saga.setCurrentStep("compensating_order");
sagaRepository.save(saga);
// 2. Expliziten Kompensations-Command senden
// Wir befehlen dem Order Service, seine Aktion rückgängig zu machen.
CancelOrderCommand command = new CancelOrderCommand(saga.getSagaId(), saga.getOrderId());
rabbitTemplate.convertAndSend("order_compensation_queue", command); // Eigene Queue für Kompensation
} else if (replyMessage instanceof OrderCancelledReply reply) { // Warten auf Bestätigung
saga = sagaRepository.findById(reply.getSagaId()).orElse(null);
if (saga == null || saga.getStatus() != SagaStatus.COMPENSATING_ORDER) return;
// 3. Kompensation war erfolgreich, Saga final als FAILED markieren
saga.setStatus(SagaStatus.FAILED);
saga.setCurrentStep(null);
sagaRepository.save(saga);
System.out.println("Saga " + saga.getSagaId() + " FAILED and fully compensated.");
}
}
Was der Order Service tun muss
Der Order Service muss nun auf diesen neuen Kompensations-Command lauschen. Er ist immer noch “dumm” – er tut nur, was ihm befohlen wird.
// Im Order Service
@RabbitListener(queues = "order_compensation_queue")
public void handleCancelOrder(CancelOrderCommand command) {
try {
Order order = orderRepository.findById(command.getOrderId()).orElse(null);
if (order != null && order.getStatus() == OrderStatus.PENDING) {
// Die eigentliche kompensierende Transaktion
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
}
// Erfolg der Kompensation zurückmelden
OrderCancelledReply reply = new OrderCancelledReply(command.getSagaId());
rabbitTemplate.convertAndSend("orchestrator_reply_queue", reply);
} catch (Exception e) {
// HILFE! Die Kompensation schlägt fehl.
// Das ist der "Worst Case". Hier braucht es robuste Retry-Mechanismen
// oder eine "Dead Letter Queue" für manuelle Eingriffe.
// Fürs Erste senden wir einfach eine Fehler-Reply.
OrderCompensationFailedReply reply = new OrderCompensationFailedReply(command.getSagaId(), e.getMessage());
rabbitTemplate.convertAndSend("orchestrator_reply_queue", reply);
}
}
Wir sehen den Unterschied sofort: Die gesamte Logik – “Wenn Zahlung fehlschlägt, DANN storniere Bestellung” – lebt ausschließlich im Orchestrator. Der Order Service weiß nicht warum er stornieren soll; er tut es einfach, weil er den Befehl (CancelOrderCommand) erhält.
5. Fazit – Orchestrierung vs. Choreografie
Wir haben nun beide SAGA-Implementierungen gesehen: die dezentrale Choreografie (Teil 2) und die zentrale Orchestrierung (Teil 3).
Mit dem Orchestrator haben wir die gesamte Geschäftslogik und das State Management in einem einzigen Service gebündelt. Die teilnehmenden Services (Order, Payment, Inventory) wurden zu “dummen”, aber hochspezialisierten Arbeitern degradiert, die nur noch Befehle ausführen und den Status ihrer atomaren Transaktion zurückmelden.
Dieser Ansatz behebt die größten Schwachstellen der Choreografie:
Vorteile der Orchestrierung
- ✅ Zentrale Geschäftslogik: Der gesamte Prozessfluss ist in einer Klasse im Orchestrator definiert. Neue Entwickler können den Code lesen und den gesamten Ablauf sofort verstehen.
- ✅ Überlegenes Monitoring: Um den Status einer beliebigen Bestellung (z.B.
SagaID-123) zu erfahren, müssen wir nur in eine Tabelle schauen: dieSagaInstance-Tabelle in der Datenbank des Orchestrators. - ✅ Explizite Fehlerbehandlung: Die Kompensation ist kein reaktiver Nebeneffekt mehr, sondern ein expliziter
else if (error)-Block im zentralen Code. Der Rollback-Prozess ist genauso klar definiert wie der “Happy Path”. - ✅ Geringere Service-Komplexität: Die Microservices selbst bleiben schlank. Der
Order Servicemuss nichts von Zahlungen wissen und umgekehrt.
Nachteile der Orchestrierung
- ❌ Single Point of Failure: Der Orchestrator ist ein kritischer Service. Fällt er aus, können keine neuen Sagas gestartet oder laufende Sagas fortgesetzt werden. Er muss hochverfügbar ausgelegt werden.
- ❌ Zentraler Flaschenhals: Der gesamte Command- und Reply-Verkehr läuft über diesen einen Service, was bei extrem hoher Last zu einem Skalierungsproblem werden kann.
- ❌ Kopplung an den Orchestrator: Die Services sind zwar voneinander entkoppelt, aber sie sind nun fest an den Orchestrator und die von ihm definierten
Commandsgebunden.
Das Urteil
Es gibt keine “beste” Lösung, nur die “passendste” für deinen Anwendungsfall.
- Nutze Choreografie für einfache, lineare Sagas mit wenigen Teilnehmern, bei denen eine maximale Entkopplung wichtiger ist als ein zentrales Monitoring.
- Nutze Orchestrierung für Sagas, die komplex sind, viele Schritte haben, bedingte Logik (if/else) erfordern oder bei denen Auditierbarkeit und ein klares Monitoring entscheidend für das Geschäft sind.
