// referenzimplementierung

Event-driven Bestellsystem

Was hier gezeigt wird, ist eine Referenzimplementierung: ein Bestellsystem, das ich gebaut habe, um eine einzelne Architekturentscheidung überprüfbar zu machen — nämlich, dass ein Dienst seine Empfänger nicht kennen muss.

Die Oberfläche trägt den Produktnamen VERTEILER. Diesen Namen gibt es nur hier: kein Produkt, keine Kunden, keine Nutzer, alle Bestellungen synthetisch. Er existiert, weil sich eine Architektur an einem Arbeitsgerät besser zeigen lässt als an einer Übungsaufgabe.

Die vier Entscheidungen unten sind die, die ich in einem echten Projekt genauso treffen würde. Zu jeder steht, was sie kostet.

.NET / C#RabbitMQReactTypeScriptDocker Compose
Komponenten des BestellsystemsDas Frontend spricht per POST /orders und PATCH /orders/id/status nur mit dem Order-Service. Dieser veröffentlicht die Ereignisse OrderCreated, OrderPaid und OrderShipped auf einem Fanout-Exchange in RabbitMQ. Der Exchange kopiert jede Nachricht in zwei getrennte Queues: Queue A beliefert den Notification-Consumer, Queue B den Stats-Consumer. Das Frontend holt sich die Auswertung per GET /stats direkt beim Stats-Consumer ab. Zwischen Order-Service und den beiden Consumern gibt es keine Verbindung.Frontend React + TypeScriptOrder-Service.NET/C#RabbitMQFanout ExchangeNotification-Consumer.NET/C#Stats-Consumer.NET/C#POST /ordersPATCH/orders/id/statuspublish EventsOrderCreated,OrderPaid,OrderShippedQueue AQueue BGET /stats
Das Frontend spricht per POST /orders und PATCH /orders/id/status nur mit dem Order-Service. Dieser veröffentlicht die Ereignisse OrderCreated, OrderPaid und OrderShipped auf einem Fanout-Exchange in RabbitMQ. Der Exchange kopiert jede Nachricht in zwei getrennte Queues: Queue A beliefert den Notification-Consumer, Queue B den Stats-Consumer. Das Frontend holt sich die Auswertung per GET /stats direkt beim Stats-Consumer ab. Zwischen Order-Service und den beiden Consumern gibt es keine Verbindung.

// entscheidungen (4)

Wie erfahren andere Teile des Systems davon, dass sich eine Bestellung geändert hat?

ENTSCHEIDUNG

Der Order-Service veröffentlicht Domain-Events auf einem Fanout-Exchange und ist damit fertig. Er kennt seine Empfänger nicht und ruft niemanden auf.

VERWORFEN

Der direkte Aufruf: nach der Statusänderung den Notification-Service und den Stats-Service anfragen. Das ist naheliegend, in einer Zeile zu lesen und liefert sofort Rückmeldung, ob die Zustellung geklappt hat. Es trägt genau so lange, bis der dritte Interessent dazukommt — ab dann bedeutet jeder neue Empfänger eine Änderung, einen Test und ein neues Deployment am Order-Service, obwohl sich an Bestellungen nichts geändert hat. Im Projekt ist die Abwesenheit dieser Kopplung nachprüfbar: Eine Suche nach den Namen der Consumer oder nach ausgehenden HTTP-Aufrufen im Order-Service liefert nichts.

PREIS

Eventual Consistency. Nach dem 200 OK ist die Statistik noch nicht aktualisiert — wer unmittelbar danach liest, sieht einen alten Stand. Und ein Fehler im Consumer meldet sich nicht mehr beim Aufrufer: Er landet in einer Queue, die niemand ansieht, solange nichts sie überwacht. Wer so baut, kauft Entkopplung mit Beobachtbarkeit, und muss diese Beobachtbarkeit von Anfang an mit einplanen.

Ablauf einer Statusänderung von der Anfrage bis zur AuswertungDas Frontend schickt PATCH /orders/{id}/status mit {"status":"Paid"} an den Order-Service. Der prüft den Statusübergang (Created → Paid erlaubt) und veröffentlicht die Nachricht mit basic.publish auf dem Fanout-Exchange orders.events. Damit endet seine Zuständigkeit: Er wartet auf keinen Consumer und antwortet dem Frontend sofort mit 200 OK — zu diesem Zeitpunkt hat noch keiner der beiden Consumer irgendetwas getan. Erst danach, und parallel zueinander, kopiert der Exchange die Nachricht in beide gebundenen Queues: notifications.orders stellt sie dem Notification-Consumer zu, stats.orders dem Stats-Consumer, jeweils mit deliver (autoAck=false) und einem eigenen basic.ack nach getaner Arbeit. Den aktuellen Stand der Auswertung holt sich das Frontend unabhängig davon per GET /stats, das alle 2,5 Sekunden pollt.FrontendFrontendorder-serviceorder-serviceorders.events(fanout)orders.events(fanout)notifications.ordersnotifications.ordersstats.ordersstats.ordersnotification-consumernotification-consumerstats-consumerstats-consumerHier endet die Zuständigkeit desOrder-Service. Er wartet aufkeinen Consumer.PATCH /orders/{id}/status{"status":"Paid"}Statusübergangprüfen(Created →Paid erlaubt)basic.publish(routingKey="",type="OrderPaid")200 OKKopie der Nachrichtdeliver (autoAck=false)"E-Mail würde jetztverschickt…"basic.ackKopie der Nachrichtdeliver (autoAck=false)Zähler + Projektionaktualisierenbasic.ackGET /stats (Polling alle 2,5 s)aktueller Snapshot[Fanout kopiert an jede gebundene Queue]par
Das Frontend schickt PATCH /orders/{id}/status mit {"status":"Paid"} an den Order-Service. Der prüft den Statusübergang (Created → Paid erlaubt) und veröffentlicht die Nachricht mit basic.publish auf dem Fanout-Exchange orders.events. Damit endet seine Zuständigkeit: Er wartet auf keinen Consumer und antwortet dem Frontend sofort mit 200 OK — zu diesem Zeitpunkt hat noch keiner der beiden Consumer irgendetwas getan. Erst danach, und parallel zueinander, kopiert der Exchange die Nachricht in beide gebundenen Queues: notifications.orders stellt sie dem Notification-Consumer zu, stats.orders dem Stats-Consumer, jeweils mit deliver (autoAck=false) und einem eigenen basic.ack nach getaner Arbeit. Den aktuellen Stand der Auswertung holt sich das Frontend unabhängig davon per GET /stats, das alle 2,5 Sekunden pollt.

Drei Dienste lesen dasselbe Nachrichtenformat. Gehört dieses Format in eine gemeinsame Bibliothek?

ENTSCHEIDUNG

Nein. Jeder Dienst hält seine eigene Kopie des Message-DTO, obwohl die drei Kopien heute nahezu identisch sind.

VERWORFEN

Das gemeinsame Contracts-Paket, auf das alle drei Projekte verweisen. Es ist der Reflex jedes erfahrenen Entwicklers, es beseitigt sichtbare Duplikation und ein Compiler wacht darüber. Nur stellt es genau die Kopplung wieder her, die der Fanout-Exchange gerade aufgelöst hat: Ab dann hängen alle drei Dienste an einer gemeinsamen Version, und eine Änderung am Paket zwingt sie zu einem abgestimmten Deployment. Die Entkopplung wäre dann nur noch im Diagramm vorhanden, nicht mehr im Betrieb.

PREIS

Ein neues Feld muss an drei Stellen nachgezogen werden, und nichts erzwingt das — kein Compilerfehler, kein Test, der von allein rot wird. Tragfähig ist das nur, weil die Consumer unbekannte Felder ignorieren und ein Consumer, der ein Feld nicht kennt, weiterläuft statt abzustürzen. Ohne diese Toleranz wäre die Entscheidung falsch.

In welcher Form wird der Bestellstatus im Event übertragen?

ENTSCHEIDUNG

Als lesbarer String — "Paid", "Shipped" — nicht als Ordinalzahl des Enums.

VERWORFEN

Die Zahl. Sie ist kompakter, schneller zu verarbeiten und die Vorgabe vieler Serialisierer. Sie koppelt aber jeden Consumer stillschweigend an die Reihenfolge des Enums im Order-Service. Wird dort später eine Zwischenstufe in den Lebenszyklus eingefügt, verschieben sich alle nachfolgenden Werte, und die Consumer werten ab diesem Moment falsch aus — ohne Ausnahme, ohne Logeintrag, ohne dass irgendwo etwas rot wird. Ein Fehler, der nicht auffällt, ist teurer als einer, der sofort auffällt.

PREIS

Der Payload wird geringfügig größer, und ein Tippfehler im Statusnamen fällt erst zur Laufzeit auf statt beim Kompilieren. Das ist der Tausch: ein sichtbarer Fehler an einer klar benannten Stelle gegen einen unsichtbaren an jeder.

Zwei Consumer wollen denselben Ereignisstrom vollständig sehen. Eine Queue oder zwei?

ENTSCHEIDUNG

Zwei. Jeder Consumer bekommt seine eigene Queue, beide sind an denselben Exchange gebunden.

VERWORFEN

Die gemeinsame Queue, an der beide Consumer hängen. Sie wirkt sparsamer und sieht im Diagramm identisch aus. RabbitMQ verteilt die Nachrichten dann aber unter den Consumern, statt sie zu kopieren — das Competing-Consumers-Muster. Jeder sähe ungefähr die Hälfte des Stroms: Ein Teil der Bestellungen bekäme keine Mail, ein Teil fehlte in der Statistik, und beides ohne einen einzigen Fehler im Log. Dasselbe Bild, ein völlig anderes Verhalten.

PREIS

Jede Queue puffert für sich. Ein Consumer, der über Nacht ausfällt, lässt seine Queue volllaufen, während die andere unauffällig weiterläuft — der Speicherverbrauch des Brokers wächst also an einer Stelle, die niemand ansieht. Zwei Queues heißt zwei Dinge, die überwacht werden müssen.

Solche Entscheidungen treffe ich auch in Ihrem System — und dokumentiere sie so, dass sie in sechs Monaten noch nachvollziehbar sind.

Verfügbarkeit anfragen