1. Szenariendienst: Dirigent und Kombine
Wenn man ContextFlow aus architektonischer Sicht betrachtet, dann sind unsere OrderPlacementService und OrderCancellationService keine „allgemeinen Dienste“, sondern Szenariendienste. Sie dirigieren den Prozess: sammeln Daten, ändern Zustände, rufen benötigte Abhängigkeiten auf. Und das ist in Ordnung. Das Problem beginnt, wenn der Dirigent plötzlich beschließt, selbst alle Instrumente des Orchesters zu spielen – und noch dazu am Eingang Tickets zu verkaufen.
In einer realen Anwendung startet der Szenariendienst fast immer „sauber“: Er erledigt ein oder zwei Dinge, sieht ordentlich aus und weckt sogar Optimismus. Aber dann kommt die Prüfung (und sie ist „unbedingt nötig“), dann Benachrichtigungen („lassen Sie uns gleich SMS und E-Mail“), dann Statistiken („wir brauchen einen Bericht für das Dashboard“), dann ein weiterer Nebeneffekt („das geht schnell, rufen Sie einfach die Methode auf“). Und schon weiß der Szenariendienst zu viel und ist von der Hälfte der Anwendung abhängig.
Schauen wir uns das typische Bild „vor Ereignissen“ an – wenn wir alles ehrlich direkt aufrufen.
Stellen wir uns vor, das Szenario zur Bestellerstellung sieht so aus (vereinfacht, ohne Domänendetails):
public void placeOrder(CreateOrderCommand cmd) {
// 1) Erstelle das Domänenmodell der Bestellung aus dem eingegebenen Befehl
Order order = orderFactory.create(cmd);
// 2) Speichere die Bestellung – dies ist Teil des Szenarienkerns (ohne diesen gibt es die Tatsache "Bestellung erstellt" nicht)
orderStore.save(order);
// 3) Anschließend folgen Reaktionen auf die bereits eingetretene Tatsache (nicht Kern des Szenarios)
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
}
Aus Sicht des Ergebnisses des Business-Szenarios haben wir „alles richtig“ gemacht: Die Bestellung gespeichert, die Prüfung protokolliert, die Benachrichtigung gesendet. Aber architektonisch ist hier bereits ein Problem versteckt: OrderPlacementService weiß, dass nach der Bestellerstellung unbedingt die Prüfung und Benachrichtigungen aufgerufen werden müssen, was bedeutet, dass er direkt mit bestimmten Diensten und deren Verträgen verbunden ist. Jedes neue „nach der Bestellerstellung muss noch…“ wird dazu zwingen, genau diesen Dienst zu ändern.
2. Hauptszenario und Reaktionen
Um zu verstehen, wo ein Ereignis angebracht ist, müssen wir uns zunächst auf Begriffe einigen. Sonst werden wir im Stil diskutieren „Ist die Prüfung wichtig oder nebensächlich?“ und dann trotzdem wie in der Aufgabe machen, aber mit Schuldgefühl. In dieser Vorlesung interessiert uns eine einfache und sehr praktische Grenze: was ist der Kern des Szenarios und was ist eine Reaktion auf eine bereits eingetretene Tatsache.
Unter „Hauptszenario“ verstehen wir die Schritte, ohne die ein Geschäftsvorfall nicht als eingetreten gilt. Für die Bestellerstellung sieht das normalerweise so aus: Wir haben die Bestellung zusammengestellt, ihr eine ID zugewiesen, die Summe berechnet, gespeichert. Man kann über Reihenfolge und Details streiten, aber der Sinn ist derselbe: Wenn die Bestellung nicht gespeichert und nicht in den erwarteten Zustand überführt wurde, ist es zu früh, von „Bestellung erstellt“ zu sprechen.
Unter „Nebenwirkung“ verstehen wir eine Reaktion auf eine bereits eingetretene Tatsache. Zum Beispiel ist „Bestellung erstellt“ eine Tatsache, und „Protokolliere die Prüfung“, „Benachrichtige den Kunden“, „Erhöhe den Statistikzähler“ sind Reaktionen. Und hier ein wichtiger Punkt: Eine Nebenwirkung ist nicht unbedingt „unwichtig“. Die Prüfung kann geschäftskritisch sein, aber dennoch eine Reaktion auf die Tatsache der Bestellerstellung sein, nicht Teil der Mechanik „Bestellung als Objekt erstellen“.
Um dies leichter im Kopf zu behalten, ist es nützlich, einmal den Vergleich in einer Tabelle zu sehen.
| Frage | Hauptszenario | Nebenwirkung (Reaktion) |
|---|---|---|
| Worum geht es? | „Bewirken, dass die Tatsache eintritt“ | „Auf die Tatsache reagieren“ |
| Wer sollte davon wissen? | Szenariendienst (Dirigent) | Beliebige separate „Reaktoren“: Prüfung, Benachrichtigungen, Statistik |
| Was passiert, wenn dies nicht getan wird? | Die Tatsache ist nicht eingetreten | Die Tatsache ist eingetreten, aber das System hat nicht vollständig reagiert |
| Was passiert normalerweise bei der Erweiterung von Anforderungen? | Ändert sich selten, weil der Kern stabiler ist | Wächst ständig („eine weitere Benachrichtigung“, „ein weiterer Bericht“) |
In ContextFlow sind die natürlichen Tatsachen, um die sich alles dreht, „Bestellung erstellt“ und „Bestellung storniert“. Gerade solche Tatsachen sind die besten Kandidaten für Ereignisse. Nicht „wir haben begonnen, die Bestellung zu erstellen“, nicht „wir haben den Status NEW gesetzt“, nicht „wir haben die Summe berechnet“ (obwohl das in komplexen Domänen vorkommen kann), sondern genau die verständlichen Dinge, die wie abgeschlossene Geschäftsaussagen lesen.
3. Direkte Aufrufe erhöhen die Kopplung
Jetzt kehren wir zu unserem „Kombine“ zurück und formulieren, was genau darin zu schmerzen beginnt. Wichtig: Das ist keine „Ästhetik“ und keine „saubere Architektur um der Likes willen“. Der Schmerz ist praktisch: Solche Dienste sind schwer zu ändern, schwer zu testen und schwer zu erweitern, ohne versehentliche Nebenwirkungen. Und außerdem züchten sie Abhängigkeit über Abhängigkeit, wie ein Abhängigkeitsbaum neue Zweige wachsen lässt – und eines Tages werden Sie nicht mehr verfolgen können, wo dort der Stamm ist.
Schauen wir uns ein realistischeres Fragment an: Der Szenariendienst kennt den Bestellspeicher, die Preisgestaltung, die Prüfung, die Benachrichtigungen und (irgendwann) die Statistik.
public void placeOrder(CreateOrderCommand cmd) {
// Kern des Szenarios: Bestellung zusammenstellen, Summe berechnen, speichern
Order order = orderFactory.create(cmd);
Money total = pricingService.calculateTotal(order);
// Aktualisiere das Bestellmodell mit der berechneten Summe
order = order.withTotal(total);
// Speichern – Teil der Definition der Tatsache "Bestellung erstellt"
orderStore.save(order);
// Reaktionen auf die Tatsache: Sie ändern nicht das Ergebnis "Bestellung erstellt", sondern ergänzen das System
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
statisticsService.onOrderCreated(order.getId());
}
Auf der Ebene „funktioniert“ – ausgezeichnet. Aber auf der Ebene „Architektur“ treten drei typische Konsequenzen auf.
Die erste Konsequenz ist der Schneeballeffekt. Jede neue Reaktion („lassen Sie uns auch noch in eine Datei schreiben“, „lassen Sie uns auch noch in einen Bericht“, „lassen Sie uns auch noch in eine lokalisierte Nachricht“) führt zu einer neuen Abhängigkeit und einem neuen Aufruf in dieser Methode. Und irgendwann wird die Methode zu einer Serie: 12 Staffeln, 300 Episoden, und Sie erinnern sich nicht, wer wessen Bruder ist.
Die zweite Konsequenz ist die starke Kopplung: Der Szenariendienst hängt von bestimmten Reaktionen ab. Selbst wenn die Prüfung und Benachrichtigungen als schöne Dienste implementiert sind, ist die Tatsache, „wer reagiert“ im Szenario fest verdrahtet. Der Szenariendienst wird zu dem Ort, wo „alle Drähte zusammenlaufen“.
Die dritte Konsequenz ist die Testkomplexität. Um zu testen, dass „die Bestellerstellung die Bestellung speichert“, müssen Sie entweder eine Menge Abhängigkeiten hochfahren oder die Prüfung, Benachrichtigungen und Statistik mocken, obwohl der Test gar nicht darüber handelt. Infolgedessen werden die Tests brüchig: Wenn Sie eine Reaktion ändern, brechen die Tests zur Bestellerstellung.
4. Direkter Aufruf: ehrlich und einfach
In diesem Moment entsteht bei Anfängern oft die Versuchung: „Oh, dann machen wir doch alles über Ereignisse!“ Und das ist eine großartige Möglichkeit, ein Problem durch ein anderes zu ersetzen, nur ein moderneres. Der direkte Aufruf ist kein Übel, sondern ein normales Ingenieurswerkzeug. Er ist ehrlich, transparent und gut lesbar, wenn die Abhängigkeit tatsächlich am wesentlichen Ergebnis des Szenarios beteiligt ist.
Der direkte Aufruf ist besonders angebracht, wenn der Szenariendienst seine Arbeit nicht ohne das Ergebnis der Abhängigkeit abschließen kann. Zum Beispiel wird ohne OrderStore die Bestellung nicht gespeichert. Ohne ID-Generierung wird die Bestellung kein gültiges Objekt. Ohne Preisberechnung können Sie den Endbetrag nicht bilden. Das sind alles Elemente des Szenarienkerns – man möchte sie nicht hinter Ereignissen verstecken, weil dann das Szenario selbst „implizit“ wird.
In ContextFlow kann der Kern des Szenarios zur Bestellerstellung etwa so aussehen:
public Order placeOrder(CreateOrderCommand cmd) {
// Erstellung des Domänenobjekts – Teil des Szenarios
Order order = orderFactory.create(cmd);
// Speichern – Teil der Definition der Tatsache "Bestellung erstellt"
orderStore.save(order);
// Gebe das Ergebnis des Szenarios an den aufrufenden Code zurück
return order;
}
Hier kann man diskutieren, wo der Preis berechnet und wann die ID vergeben wird, aber der entscheidende Punkt ist einfach: orderStore.save(order) ist keine Reaktion, sondern das Wesen des Szenarios. Wenn Sie das „Speichern der Bestellung“ als Reaktion auf ein Ereignis auslagern, wird das Szenario zu „wir haben die Bestellung sozusagen erstellt, aber tatsächlich ist sie noch nicht erstellt, bis jemand dort etwas tut“. Und das ist bereits architektonischer Nebel, der nur in Präsentationen schön aussieht.
Unterm Strich klingt die nützliche Regel langweilig, aber sie funktioniert: Wenn ein Schritt Teil der Definition der Tatsache ist („Bestellung erstellt“), dann soll er ein direkter Aufruf bleiben. Wenn ein Schritt eine Reaktion auf die Tatsache ist („mache nach der Bestellerstellung noch dies“), dann ist er ein Kandidat für die Entkopplung.
5. Ereignis: eine Tatsache und Reaktionen
Jetzt kommen wir zu dem Punkt, wegen dem Ereignisse überhaupt initiiert werden. Sie sind nützlich, wo eine Tatsache mehrere Reaktionen auslösen soll und der Szenariendienst weder die Liste dieser Reaktionen noch ihre Reihenfolge noch wissen muss, wie viele es überhaupt gibt. Der Szenariendienst soll seine Aufgabe erledigen und verkünden: „Die Tatsache ist eingetreten“. Und dann soll das System reagieren.
Auf menschlicher Sprache ist das wie eine Ankündigung im Büro: „Kollegen, die Bestellung wurde erstellt.“ Jemand hört es und geht, um die Prüfung zu protokollieren. Jemand hört es und geht, um eine Benachrichtigung zu senden. Jemand hört es und aktualisiert die Statistik. Dabei rennt die Person, die die Bestellung erstellt hat, nicht durch die Büros und schreit: „Petja, protokolliere die Prüfung! Mascha, sende SMS! Wasja, zähle die Statistik!“ Er hat die Aktion ausgeführt und die Tatsache mitgeteilt.
Wenn wir in der Welt der direkten Aufrufe bleiben, sieht die „Ankündigung“ normalerweise wie eine Aufrufkette aus:
// Kern des Szenarios
orderStore.save(order);
// Reaktionen (Nebenwirkungen) – ihre Liste und Reihenfolge sind direkt im Szenario verdrahtet
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
statisticsService.onOrderCreated(order.getId());
Wir gehen noch nicht ins Spring API und halten nur die Idee selbst fest:
// Kern des Szenarios: Die Bestellung muss hier direkt und explizit gespeichert werden
orderStore.save(order);
// Verkünde die Tatsache "Bestellung erstellt" einmal (danach beginnen die Reaktionen)
orderEvents.orderCreated(order.getId());
Und dann ist orderEvents kein „Dienst, der alles macht“, sondern genau der Mechanismus, der die Tatsache an diejenigen liefert, die abonniert sind, um zu reagieren.
Minibrücke: Nutzen von Ereignissen ohne Spring API
Um zu sehen, was wir genau erreichen wollen, können wir uns für einen Moment eine „halbeingeschränkte“ Variante vorstellen, die keine Magie erfordert: Wir injizieren einfach eine Liste von Reaktionen (das können wir seit dem Thema Collection Injection).
// Spring kann eine Liste aller Beans injizieren, die OrderCreatedReaction implementieren
private final List<OrderCreatedReaction> reactions;
public void placeOrder(CreateOrderCommand cmd) {
// Kern des Szenarios
Order order = orderFactory.create(cmd);
orderStore.save(order);
// Reaktionen: Der Szenariendienst weiß nicht, wie viele es gibt und welche Klassen es sind
for (OrderCreatedReaction r : reactions) {
// Gebe nur die "Tatsache" nach außen (minimale Daten, die für die Reaktion benötigt werden)
r.react(order.getId());
}
}
Das sind noch keine Spring Events, aber es ist schon sichtbar, worauf wir abzielen: OrderPlacementService weiß nicht mehr, welche Reaktionen genau existieren. Er weiß nur, dass nach der Bestellerstellung die Tatsache nach außen gegeben werden muss, und dann kann der Standardmechanismus für Container-Ereignisse diese Entkopplung übernehmen.
Grenzen von Application Events
Es ist sehr wichtig, jetzt gleich die Grenzen zu halten, sonst ruft das Wort „events“ sofort das Bild von Brokern, Warteschlangen, Microservices und ewiger Konsistenz hervor. In unserem Kurs geht es um Anwendungsereignisse innerhalb eines einzelnen ApplicationContext, also innerhalb eines Prozesses, innerhalb einer Anwendung. Das ist kein verteiltes System. Es ist eine Möglichkeit, die Kommunikation zwischen Beans so zu organisieren, dass das Szenario nicht von den Details der Nebenreaktionen abhängt.
Daher sollte man von Anwendungsereignissen nicht erwarten, was sie nicht versprechen. Sie machen die Anwendung nicht automatisch asynchron. Sie geben keine Liefergarantien „wie in einer Warteschlange“. Sie machen kein „Event Sourcing“ (und das ist auch nicht nötig, wir müssen erst einmal eine Bestellung erstellen). Sie erlauben einfach zu sagen: „Innerhalb der Anwendung ist eine Tatsache eingetreten“ und anderen Teilen der Anwendung zu erlauben, darauf zu reagieren, ohne diese Reaktionen in das Szenario einzubauen.
In der Praxis verändert das das Gefühl für den Code. Der Szenariendienst wird kürzer, hat weniger Abhängigkeiten, und das Hinzufügen einer neuen Reaktion wird zu „einen neuen Handler hinzufügen“ statt „in das Szenario einsteigen und vorsichtig einen weiteren Aufruf einfügen, um nichts zu zerbrechen“. Und das ist ein echter technischer Vorteil, nicht „wir haben die Architektur modisch gemacht“.
Wie man keine Ereignissuppe macht
Ereignisse haben eine Kehrseite: Es ist sehr leicht, sie zu sehr zu lieben. Es ist wie bei Gewürzen: Eine Prise verbessert das Gericht, eine halbe Packung verwandelt alles in ein scharfes Missverständnis. Die Hauptgefahr des Ereignismodells innerhalb einer Anwendung ist der Verlust der Transparenz: Die Aktion findet nicht „hier und jetzt“ statt, sondern „irgendwo dort“, und ein Neuling öffnet das Projekt, sieht ein placeOrder() und versteht nicht, warum bei der Bestellerstellung noch eine Berichtsdatei erstellt und Nachrichten auf der Konsole ausgegeben werden.
Um dies zu vermeiden, ist es nützlich, eine einfache Disziplin einzuhalten. Ein Ereignis ist eine bedeutende Tatsache, nicht „jedes Niesen innerhalb einer Methode“. Wenn Sie anfangen, „OrderValidatedEvent“, „OrderPriceCalculatedEvent“, „OrderSavedEvent“, „OrderAuditPlannedEvent“ und „OrderNotificationPreparedEvent“ zu veröffentlichen, erhalten Sie ein System, in dem die grundlegende Geschäftslogik auf ein Dutzend Handler verteilt ist und es unmöglich ist, die Kette im Kopf zu verfolgen, ohne Debugger und starken Kaffee.
Ein weiterer häufiger Fehler ist es, ein Ereignis zu einem Befehl zu machen. Wenn ein Ereignis wie ein Auftrag heißt („SendEmailNowEvent“), ist das fast immer ein Zeichen, dass Sie „Tatsache“ und „Aktion“ vermischt haben. Ein gutes Ereignis steht in der Vergangenheitsform: „Bestellung erstellt“, „Bestellung storniert“. Ein schlechtes Ereignis steht im Imperativ: „Sende“, „Protokolliere“, „Zähle“.
Und schließlich: Ereignisse sind keine Ausrede, um wichtige Schritte des Szenarios zu verstecken. Wenn ein Schritt das Ergebnis entscheidend bestimmt („die Bestellung muss gespeichert werden“), dann lagern Sie ihn nicht in ein Ereignis aus, nur weil es „moderner“ ist. Lassen Sie das Szenario ehrlich und lesbar bleiben, und Ereignisse bedienen die Reaktionen.
6. Typische Fehler: Aufruf vs Ereignis
Fehler Nr. 1: Versuchen, jeden Schritt der Methode zu einem Ereignis zu machen.
Einem Anfänger scheint, dass „Ereignisse = architektonische Schönheit“ sind, und er beginnt, Ereignisse für jeden Mikrobefehl zu veröffentlichen: „Objekt erstellt“, „Preis berechnet“, „Gespeichert“. Infolgedessen hört das Hauptszenario auf, ein Szenario zu sein, und verwandelt sich in eine Sammlung von Signalen. Für den ersten Kurs halten wir uns an die einfache Idee: Ereignisse nur auf der Ebene verständlicher Geschäftstatsachen veröffentlichen, z. B. „Bestellung erstellt“ und „Bestellung storniert“.
Fehler Nr. 2: In ein Ereignis verschieben, ohne das das Szenario nicht abgeschlossen ist.
Manchmal versucht man, den OrderPlacementService zu „entlasten“ und geht zu weit: Die Bestellung wird „irgendwo im Ereignishandler“ gespeichert. Dann garantiert der Aufruf placeOrder() nicht mehr, dass die Bestellung tatsächlich erstellt wurde. Das Ergebnis ist ein architektonischer Sumpf: Die Methode ist beendet, aber die Tatsache ist noch nicht eingetreten. Wenn der Schritt die Tatsache selbst definiert, soll er ein direkter Aufruf bleiben.
Fehler Nr. 3: Ein Ereignis als externe Nachrichtenwarteschlange wahrnehmen.
Nach dem Wort „events“ schaltet bei vielen der „Kafka-Denkmodus“ ein: Als ob das Veröffentlichen eines Ereignisses bedeutet „in den Hintergrund gesendet“. In unserem Kurs ist dies ein interner Mechanismus einer einzelnen Anwendung, kein verteilter Transport. Wir werden noch über das Runtime-Verhalten sprechen, aber schon jetzt ist es wichtig, keine Erwartungen zu wecken, die nicht zu Application Events gehören.
Fehler Nr. 4: Ein Ereignis wird wie ein Befehl benannt, nicht wie eine Tatsache.
Namen wie SendEmailEvent klingen wie „führe eine Aktion aus“ und nicht „eine Tatsache ist eingetreten“. Infolgedessen beginnen die Handler, von dem Befehl abzuhängen, und der Publisher steuert sie tatsächlich über das Ereignis. Richtig ist, Ereignisse „in der Vergangenheitsform“ zu nennen: OrderCreated..., OrderCancelled.... Dann beschreibt das Ereignis die Realität und erteilt keinen Befehl.
Fehler Nr. 5: Der Szenariendienst beginnt, die Listener zu „kennen“.
Wenn Sie sich bereits entschieden haben, Ereignisse zu verwenden, aber trotzdem direkte Aufrufe „für alle Fälle“ im OrderPlacementService belassen haben, funktioniert die Entkopplung nicht. Das Ergebnis ist ein Hybrid: Ein Teil der Reaktionen ist in Listenern versteckt, ein Teil im Dienst, und ein neuer Entwickler wird sie im gesamten Projekt suchen. Entweder ist die Reaktion Teil des Kerns (dann direkter Aufruf) oder die Reaktion ist ein Abonnent (dann Ereignis). Mischen kann man nur bewusst und selten.
GO TO FULL VERSION