Das nimmst du mit
- Event und Command trennen
- Keys sinnvoll wählen
- Reihenfolge einordnen
- Schemas evolvieren
Kapitelkompass
Messaging entkoppelt Zeit und Verfügbarkeit, verlangt dafür klare Event-Semantik, Partitionierung und Schema-Evolution.
OrderCreated muss pro Bestellung geordnet, aber über Bestellungen parallel verarbeitet werden.
Partitioniere nach der fachlichen Einheit, für die Reihenfolge tatsächlich garantiert sein muss.
Ein Event mit Datenbanktabellen-Semantik verteilt internes Design als dauerhaften Vertrag.
Messaging Deep Dive: Kafka/JMS, Event Design, Commands vs Events, Topics, Partitionen, Consumer Groups und Schema-Versionierung.
Messaging wird in Enterprise-Projekten oft zu spät ernst genommen.
Ein Team beginnt mit sendEvent(order) und merkt erst im
Betrieb, dass ein Event nicht nur eine technische Nachricht ist. Ein
Event ist ein Vertrag zwischen fachlichem Modell, Laufzeitverhalten und
Betrieb. Sobald ein Order-Service einen Billing-Service, ein
Reporting-System, ein Audit-System oder eine Legacy-Schnittstelle
asynchron beliefert, entstehen neue Designfragen.
Die wichtigste Erkenntnis ist: Messaging entfernt Kopplung nicht, sondern verschiebt sie. Ein synchroner REST-Call koppelt den Aufrufer an Antwortzeit und Verfügbarkeit. Ein asynchrones Event koppelt Publisher und Consumer an Event-Schema, fachliche Bedeutung, Reihenfolge, Wiederholbarkeit und Reprocessing-Strategie.
Schlechte Enterprise-Lösung:
public void placeOrder(PlaceOrderCommand command) {
Order order = repository.save(command.toOrder());
kafka.send("order-events", order.toJson());
// keine Version, keine Idempotenz, kein Trace
}
Diese Lösung sieht kurz aus, aber sie verschweigt fast alles, was im
Betrieb wichtig wird: Was passiert, wenn der Broker down ist? Ist
toJson() ein stabiler Vertrag? Welcher Consumer darf welche
Felder verwenden? Kann das Event erneut verarbeitet werden? Wie wird
eine alte Event-Version gelesen? Ein Deep-Dive-Design muss diese Fragen
vor dem ersten produktiven Release beantworten.
Synchron bedeutet nicht automatisch schlecht und asynchron bedeutet nicht automatisch modern. Eine Bestellung anzulegen ist für den Benutzer oft ein synchroner Vorgang: Der Client will wissen, ob die Order angenommen wurde. Die spätere Rechnungserzeugung, das Reporting oder eine Benachrichtigung können dagegen asynchron laufen.
Eine robuste Faustregel lautet: Die unmittelbare fachliche Entscheidung bleibt im Application Service. Nebenwirkungen, Projektionen und nachgelagerte Reaktionen können über Events laufen. Wenn aber ein Consumer sofort antworten muss, ist Messaging meist das falsche Werkzeug oder braucht ein explizites Request-Reply-Protokoll.
ASCII-Sicht:
Client
|
| POST /orders
v
Order Application Service -- lokale Entscheidung --> Order accepted/rejected
|
| OrderPlaced Event
v
Broker/Log
|------------------> Billing Projection
|------------------> Reporting
|------------------> Audit
Produktionshinweis: Wenn ein Fachbereich sagt, „Billing muss garantiert danach passieren“, bedeutet das nicht automatisch synchrone Kopplung. Es bedeutet, dass Fehlerpfad, Retry, Monitoring und Eskalation sauber sein müssen.
Commands, Events und Queries sind keine Namenskonventionen, sondern unterschiedliche fachliche Aussagen.
Ein Command ist eine Aufforderung. Es hat einen intendierten Empfänger und kann abgelehnt werden. Ein Event ist eine Tatsache, die bereits passiert ist. Eine Query liest Daten und sollte keine fachliche Änderung auslösen.
Schlechtes Beispiel:
// klingt wie Tatsache, ist aber eigentlich eine Anweisung an Billing
public record InvoiceShouldBeGeneratedEvent(String orderId) {
}
Besser:
public record GenerateInvoiceCommand(String orderId) {
}
public record InvoiceGenerated(String invoiceId, String orderId) {
}
Warum das wichtig ist: Wenn ein Consumer
InvoiceShouldBeGeneratedEvent liest, weiß er nicht, ob eine
Rechnung schon existiert, ob er die Aktion ausführen soll oder ob es nur
eine Zustandsinformation ist. Unklare Nachrichtentypen erzeugen unklare
Verantwortlichkeiten.
Ein häufiges Anti-Pattern ist das Event OrderChanged. Es
klingt flexibel, ist aber für Consumer schlecht. Der Consumer muss
herausfinden, was sich geändert hat und ob diese Änderung für ihn
relevant ist. Dadurch wandert Fachlogik aus dem Publisher in viele
Consumer.
Besser sind konkrete fachliche Ereignisse:
public record OrderPlaced(String orderId, String customerId, long amountInCent, String currency) {
}
public record OrderCancelled(String orderId, String reason) {
}
public record PaymentAuthorized(String orderId, String authorizationId) {
}
public record InvoiceGenerated(String invoiceId, String orderId) {
}
Zu fein ist aber ebenfalls gefährlich.
OrderLineQuantityFieldChanged ist meistens zu nah an
interner Datenstruktur. Ein gutes Event beschreibt eine relevante
fachliche Tatsache, nicht eine UI- oder Tabellenänderung.
Checkfrage: Kann ein Fachbereich den Eventnamen verstehen? Falls nein, ist das Event vermutlich zu technisch. Falls ein Consumer aus dem Namen nicht erkennen kann, warum er reagieren sollte, ist es vermutlich zu grob.
JMS und Kafka werden oft als austauschbare Messaging-Technologien betrachtet. Das ist zu oberflächlich. JMS ist historisch stark auf Nachrichtenzustellung, Queues, Topics, Acknowledgement und Enterprise-Integration ausgerichtet. Kafka ist ein verteiltes append-only Log, in dem Consumer ihre Lesepositionen über Offsets verwalten und Eventströme erneut gelesen werden können.
JMS passt oft gut für Arbeitsaufträge, Punkt-zu-Punkt-Verarbeitung und klassische Enterprise-Messaging-Szenarien. Kafka passt gut für Event Streaming, Reprocessing, mehrere unabhängige Consumer Groups und hohe Durchsatz-Szenarien.
Eine Migration von JMS nach Kafka ist deshalb keine reine Dependency-Änderung. Das Team muss klären: Wer besitzt Offsets? Wie lange bleiben Events lesbar? Können Consumer neu aufbauen? Wie wird Reihenfolge garantiert? Welche Semantik hat ein Commit?
Topic Design ist Architekturdesign. Zu wenige Topics führen zu unklaren Eventströmen. Zu viele Topics erzeugen Betriebskomplexität. Ein Topic sollte fachlich und betrieblich zusammenpassen.
Mögliche Varianten:
| Variante | Beispiel | Vorteil | Risiko |
|---|---|---|---|
| Domänen-Topic | order-events |
fachlich verständlich | viele Eventtypen in einem Strom |
| Eventtyp-Topic | order-placed |
leicht gezielt zu abonnieren | viele Topics |
| Kontext-Topic | billing-events |
bounded context nah | Grenzfälle bei übergreifenden Flows |
Für eine Order/Billing-Plattform ist order-events ein
guter Start, wenn Partition Key, Schema-Strategie und Consumer-Vertrag
sauber beschrieben sind. Für hochvolumige oder sicherheitskritische
Events kann ein eigenes Topic sinnvoll sein.
Kafka-artige Systeme skalieren über Partitionen. Innerhalb einer Partition ist die Reihenfolge stabil. Zwischen Partitionen gibt es keine globale Reihenfolge. Consumer Groups erlauben parallele Verarbeitung: Jede Partition wird innerhalb einer Group typischerweise nur von einem Consumer gleichzeitig gelesen.
Das wichtigste Missverständnis: Ein Offset ist keine fachliche Verarbeitungsgarantie. Wenn ein Consumer den Offset zu früh committed, kann eine Nachricht technisch als gelesen gelten, obwohl die fachliche Wirkung nicht dauerhaft gespeichert wurde.
Gute Regel: Offset-Commit erst nach idempotenter und dauerhafter Verarbeitung. Bei Consumer-Projektionen heißt das: Projektion speichern, Idempotenz markieren, dann Offset committen.
Der Partition Key entscheidet, welche Events gemeinsam geordnet
werden. Wenn alle Order-Events mit orderId partitioniert
werden, sieht ein Consumer für dieselbe Order die Reihenfolge
OrderPlaced -> PaymentAuthorized -> InvoiceGenerated
innerhalb derselben Partition.
Schlecht:
String partitionKey = UUID.randomUUID().toString();
Besser:
String partitionKey = event.aggregateId();
// z.B. orderId
Trade-off: Ein sehr grober Key wie tenantId kann Hot
Partitions erzeugen. Ein zu zufälliger Key zerstört fachliche Ordnung.
Der richtige Key hängt vom Aggregat und vom wichtigsten
Reihenfolgeversprechen ab.
Ein Event ohne Envelope ist im Betrieb schwer zu verstehen. Der Envelope trennt fachliche Payload von technischen und organisatorischen Metadaten.
Wichtige Felder:
public record EventEnvelope(
String eventId,
String traceId,
String aggregateType,
String aggregateId,
String eventType,
int schemaVersion,
Instant occurredAt,
String partitionKey,
Map<String, String> headers,
Object payload
) {
}
eventId ermöglicht Idempotenz. traceId
verbindet Logs über Services. schemaVersion macht Evolution
explizit. partitionKey dokumentiert die
Ordnungsentscheidung. Ohne diese Felder wird jeder spätere Fehlerfall
manuelle Detektivarbeit.
Events sind langlebige Verträge. Ein Consumer kann heute ein Event lesen, das gestern, letzte Woche oder vor einer Migration geschrieben wurde. Deshalb darf ein Publisher ein Event-Schema nicht wie eine interne DTO-Klasse behandeln.
Kompatible Änderung:
// V1
public record OrderPlacedV1(String orderId, long amountInCent) {
}
// V2: neues Feld mit Default möglich
public record OrderPlacedV2(String orderId, long amountInCent, String currency) {
}
Brechende Änderung:
// amount wird plötzlich decimal string statt cent long
public record OrderPlacedV3(String orderId, String amount) {
}
Deep-Dive-Regel: Neue Consumer sollten mehrere Versionen lesen können. Neue Publisher sollten nicht sofort alte Consumer brechen. Ereignisse brauchen ein Evolutionsfenster.
Delivery Semantics werden häufig missverstanden.
At-most-once vermeidet Duplikate, kann aber Nachrichten
verlieren. At-least-once vermeidet Verlust, kann aber
Duplikate erzeugen. Exactly-once ist in der Praxis nur
innerhalb enger technischer Grenzen gültig und löst nicht automatisch
fachliche Idempotenz.
Für Enterprise-Consumer ist
at-least-once + idempotenter Consumer meist die ehrlichste
Annahme.
if (idempotencyStore.alreadyProcessed(event.eventId())) {
return ProcessingResult.duplicateIgnored(event.eventId());
}
applyBusinessEffect(event);
idempotencyStore.markProcessed(event.eventId());
Wichtig: Idempotenz muss dieselbe dauerhafte Grenze haben wie die fachliche Wirkung. Wenn Projektion und Idempotenz in getrennten Speichern ohne Transaktion liegen, bleibt ein Crash-Fenster.
Ein Consumer ist kein
forEach(event -> handle(event)). Er ist eine kleine
State Machine mit technischen und fachlichen Zuständen.
Typischer Ablauf:
Received
-> validate envelope
-> validate schema version
-> check idempotency
-> apply business effect
-> mark idempotent
-> commit offset
Fehler können an jeder Stelle auftreten. Deshalb muss der Code unterscheiden: kaputtes Event, temporärer DB-Fehler, unbekannte Version, Duplikat, fachlich nicht mehr relevante Nachricht.
Das Code-Lab enthält keinen echten Kafka-Client, sondern ein bewusst kleines Modell, das die Konzepte sichtbar macht: Topics, Partitionen, Offsets und Consumer Groups. Dadurch kann man die Logik mit reinem JDK 21 kompilieren und testen.
Ausschnitt:
public List<ConsumerRecord> poll(String topicName, String groupId, int maxRecords) {
Topic topic = topics.get(topicName);
ConsumerGroupState group = groups.computeIfAbsent(groupId, ConsumerGroupState::new);
List<ConsumerRecord> records = new ArrayList<>();
for (Partition partition : topic.partitions()) {
long nextOffset = group.nextOffset(topicName, partition.id());
records.addAll(partition.readFrom(nextOffset, maxRecords - records.size()));
}
return records;
}
Der Code zeigt bewusst: Consumer Groups besitzen eigene Offsets. Zwei Gruppen können dieselben Events unabhängig lesen.
Die Registry im Lab ist klein, aber fachlich wichtig. Sie verhindert, dass ein Consumer ungeprüft unbekannte Versionen verarbeitet.
public CompatibilityCheck validate(EventEnvelope event) {
Set<Integer> versions = acceptedVersions.get(event.eventType());
if (versions == null) return CompatibilityCheck.unknownType(event.eventType());
if (!versions.contains(event.schemaVersion())) {
return CompatibilityCheck.rejectedVersion(event.eventType(), event.schemaVersion());
}
return CompatibilityCheck.accepted(event.eventType(), event.schemaVersion());
}
In echten Projekten kann dahinter Avro, JSON Schema, Protobuf oder ein Schema Registry Service stehen. Das Mentalmodell bleibt gleich: Consumer entscheiden bewusst, welche Verträge sie akzeptieren.
Der BillingProjectionConsumer baut aus
OrderPlaced-Events eine eigene Sicht. Er verarbeitet
Duplikate nicht erneut, lehnt unbekannte Versionen ab und schreibt
kaputte Events in eine Dead Letter Queue.
public ProcessingResult handle(EventEnvelope event) {
if (idempotencyStore.alreadyProcessed(event.eventId())) {
return ProcessingResult.duplicateIgnored(event.eventId());
}
CompatibilityCheck check = schemaRegistry.validate(event);
if (!check.accepted()) {
deadLetterQueue.add(event, check.reason());
return ProcessingResult.rejected(event.eventId(), check.reason());
}
projection.apply(event);
idempotencyStore.markProcessed(event.eventId());
return ProcessingResult.processed(event.eventId());
}
Das ist kein vollständiges Produktions-Framework, aber es zeigt die richtige Denkweise: Validierung vor Wirkung, Idempotenz vor erneuter Wirkung, Dead Letter statt stiller Datenkorruption.
Typische Fehler in Enterprise-Projekten:
OrderChanged als einziges Event nutzenSchlechtes Beispiel:
public record DbOrderRowChanged(Map<String, Object> row) {
}
Besser:
public record OrderPlaced(String orderId, String customerId, long amountInCent, String currency) {
}
Der Unterschied ist nicht syntaktisch, sondern fachlich: Das gute Event beschreibt eine Tatsache im Geschäftsprozess.
Messaging muss mit Szenarien getestet werden, nicht nur mit einzelnen Methoden. Das Lab enthält Tests für Consumer-Groups, Idempotenz und Schema-Abweisung.
Wichtige Testfälle:
1. Billing und Reporting lesen denselben Eventstrom unabhängig.
2. Duplikat mit gleicher eventId erzeugt keine zweite Projektion.
3. Unbekannte Schema-Version landet in DLQ.
4. Partition Key hält Events einer Order zusammen.
5. Consumer committen erst nach Verarbeitung.
Diese Tests sind Architekturtests im Kleinen. Sie prüfen nicht nur Java-Methoden, sondern die Annahmen des Integrationsdesigns.
Vor einem produktiven Messaging-Design sollte diese Liste beantwortet sein:
Wenn diese Fragen offen sind, ist das System nicht tief verstanden, auch wenn der Code bereits Nachrichten sendet.
Der Order-to-Invoice-Flow ist ein gutes Beispiel, weil mehrere fachliche Verantwortungen beteiligt sind. Der Order-Service entscheidet, ob eine Bestellung angenommen wird. Inventory reserviert Bestand. Payment autorisiert Zahlung. Billing erzeugt eine Rechnung. Reporting und Audit hören mit, ohne den Kernfluss zu blockieren.
Ein schlechter Fluss wäre ein einziger synchroner Ablauf:
orderRepository.save(order);
inventoryClient.reserve(order);
paymentClient.authorize(order);
billingClient.generateInvoice(order);
reportingClient.update(order);
Dieser Code ist einfach zu lesen, aber operativ gefährlich. Jeder Downstream-Ausfall blockiert die Bestellung. Timeouts werden zu fachlichen Abbrüchen. Wiederholungen können Payment oder Billing doppelt ausführen. Außerdem ist nicht klar, welche Teilschritte schon dauerhaft passiert sind.
Eine bessere Choreografie trennt lokale Entscheidung und nachgelagerte Reaktionen:
OrderApplicationService
-> Order speichern
-> Outbox(OrderPlaced) schreiben
-> Commit
OutboxPublisher
-> order-events publizieren
InventoryConsumer
-> Bestand reservieren
-> InventoryReserved publizieren
BillingConsumer
-> nach PaymentAuthorized Rechnung erzeugen
Deep-Dive-Punkt: Choreografie bedeutet nicht, dass niemand verantwortlich ist. Der fachliche Prozess braucht weiterhin Monitoring, Prozesszustände und Eskalationsregeln.
Eine Enterprise-Plattform kann Topics nach Domäne, Eventtyp, Sicherheitszone oder Betriebsprofil schneiden. Das ist keine reine Geschmacksfrage.
| Entscheidung | Vorteil | Risiko | Geeignet wenn |
|---|---|---|---|
order-events |
einfache fachliche Landkarte | viele Eventtypen | Order-Kontext hat moderate Eventmenge |
payment-events |
Sicherheit/Compliance getrennt | mehr Topic-Verwaltung | Payment braucht eigene Retention/ACL |
order-placed |
Consumer können gezielt abonnieren | Topic-Wildwuchs | sehr hohe Last pro Eventtyp |
audit-events |
Audit getrennt betreibbar | Doppelpublikation möglich | Audit ist eigener Bounded Context |
Ein guter Architekt dokumentiert nicht nur den gewählten Topic-Namen, sondern auch die verworfenen Alternativen. Später versteht man sonst nicht, warum das System so geschnitten wurde.
Ein Event-Schema muss über ein Migrationsfenster betrachtet werden. Publisher und Consumer werden selten exakt gleichzeitig deployt. Deshalb braucht man Phasen:
Phase 1: Consumer kann V1 lesen
Phase 2: Consumer kann V1 und V2 lesen
Phase 3: Publisher sendet V2
Phase 4: alte V1-Events laufen aus Retention/Archiv
Phase 5: Consumer-Unterstützung für V1 wird entfernt
Schlecht:
// Publisher wechselt sofort von V1 zu V2, Consumer versteht nur V1.
broker.publish(new OrderPlacedV2(orderId, amount, currency, taxRate));
Besser:
// Consumer akzeptiert vor Publisher-Umstellung beide Versionen.
registry.register("OrderPlaced", 1, 2);
Produktionshinweis: Die Retention des Brokers ist Teil der Schema-Strategie. Wenn Events 14 Tage im Topic bleiben, muss ein Consumer in dieser Zeit alte Versionen lesen können.
Consumer Groups skalieren nicht beliebig. Die maximale parallele Verarbeitung innerhalb einer Group ist durch die Anzahl der Partitionen begrenzt. Zehn Consumer auf drei Partitionen bedeuten, dass sieben Consumer keine Partition aktiv bearbeiten.
Rebalancing ist ein Betriebsereignis. Wenn Consumer starten, sterben oder langsam werden, werden Partitionen neu verteilt. Währenddessen können Pausen entstehen. Consumer-Code muss deshalb damit umgehen, dass Verarbeitung unterbrochen und später fortgesetzt wird.
Wichtige Regel:
Mehr Consumer erhöhen nur dann Durchsatz,
wenn genügend Partitionen existieren und die Downstream-Systeme mithalten.
Ein Billing-Consumer kann Kafka schnell lesen, aber die Datenbank oder der Payment-Adapter kann trotzdem der Engpass sein. Dann hilft mehr Kafka-Parallelität nicht, sondern erzeugt Druck auf abhängige Systeme.
Offset-Commit ist einer der wichtigsten Punkte im Consumer-Design.
Schlecht:
record = poll();
commitOffset(record);
// zu früh
projection.apply(record);
// kann danach fehlschlagen
Wenn projection.apply fehlschlägt, ist das Event
technisch als gelesen markiert, aber fachlich nicht verarbeitet. Das ist
Datenverlust.
Besser:
record = poll();
projection.apply(record);
idempotency.markProcessed(record.eventId());
commitOffset(record);
Jetzt kann ein Crash vor dem Commit zu erneuter Lieferung führen. Deshalb muss die Projektion idempotent sein. Das ist der ehrliche Preis von at-least-once.
Bei einer JMS-Queue wird ein Arbeitsauftrag typischerweise von einem
Consumer abgeholt und nach Acknowledgement aus Sicht der Queue erledigt.
Das passt gut zu Aufgaben wie GenerateInvoiceCommand.
Bei Kafka bleibt das Event im Log, und jede Consumer Group verwaltet
ihre eigene Position. Das passt gut zu Tatsachen wie
OrderPlaced, weil Billing, Reporting und Audit unabhängig
lesen können.
Nicht gut:
OrderPlaced als JMS-Arbeitsauftrag behandeln,
obwohl mehrere unabhängige Systeme dieselbe Tatsache brauchen.
Nicht gut:
GenerateInvoiceCommand als Kafka-Domänen-Event publizieren,
obwohl es einen klaren Empfänger und eine Ausführungsabsicht hat.
Gute Eventnamen für die Order/Billing/Payment-Domäne:
OrderPlaced
OrderAccepted
OrderRejected
InventoryReserved
InventoryReservationFailed
PaymentAuthorized
PaymentDeclined
InvoiceGenerated
InvoiceGenerationFailed
OrderCancelled
RefundRequested
RefundCompleted
Vorsichtige oder schlechte Namen:
OrderChanged
OrderUpdated
ProcessOrderEvent
DoBillingEvent
PaymentStatusChanged
DataSynced
Warum? Die schlechten Namen beschreiben entweder zu wenig Fachlichkeit oder eine technische Aktivität statt einer fachlichen Tatsache.
Ein Event sollte genug enthalten, damit Consumer ihre Aufgabe erfüllen können, aber nicht das komplette interne Aggregat dumpen.
Zu wenig:
public record OrderPlaced(String orderId) {
}
Jetzt müssen Consumer eventuell den Order-Service synchron nachladen. Dadurch entsteht neue Kopplung.
Zu viel:
public record OrderPlaced(OrderEntityJpaDump dump) {
}
Jetzt wird die interne Datenstruktur zum öffentlichen Vertrag.
Besser:
public record OrderPlaced(
String orderId,
String customerId,
long grossAmountInCent,
String currency,
List<OrderLineSnapshot> lines
) {
}
Snapshot bedeutet: fachlich relevante Daten zum Zeitpunkt des Events, nicht Live-Entity.
Ein echter Messaging-Testkatalog enthält mehr als einen Happy Path.
- Event wird einmal publiziert und einmal verarbeitet.
- Dasselbe Event wird zweimal geliefert und nur einmal angewendet.
- Unbekannte Version wird abgelehnt.
- Alter Consumer liest alte Version weiter.
- Neue optionale Felder brechen alte Consumer nicht.
- Partition Key hält Order-Reihenfolge.
- Zwei Consumer Groups lesen unabhängig.
- Consumer-Lag wird erkennbar.
- DLQ-Eintrag enthält Ursache und Trace-ID.
Diese Tests sind nicht nur technische Tests. Sie sichern Architekturentscheidungen.
Vor Produktion müssen mindestens diese Punkte geklärt sein:
Event-Katalog dokumentiert
Topic-Katalog dokumentiert
Partition-Key-Strategie dokumentiert
Schema-Versionierungsregel dokumentiert
Consumer-Groups bekannt
Offset-Commit-Regel festgelegt
Idempotenzspeicher definiert
DLQ-Prozess definiert
Trace-ID und Correlation-ID definiert
Retention und Reprocessing geklärt
ACL/Security für Topics geklärt
Monitoring und Alerts geplant
Wenn ein Team nur den Producer-Code zeigen kann, aber diese Liste nicht beantworten kann, ist das Messaging-Design noch nicht deep-dive-reif.
Der vollständige Code liegt unter
code/messaging-Kapitel-rebuild. Das PDF hält Code bewusst
kurz; die HTML- und ZIP-Fassung enthalten den vollständigen Code.