Event-Schemas weiterentwickeln, ohne Consumer zu brechen
Wie man in asynchronen Systemen Felder hinzufügt, Eigenschaften umbenennt und Events umstrukturiert, ohne Deployments abzustimmen oder Consumer zu brechen.

Event-getriebene Systeme entkoppeln Dienste wunderbar — bis du das Schema eines Events ändern musst. Plötzlich arbeitet die Entkopplung gegen dich. Producer und Consumer werden unabhängig deployed, Nachrichten verweilen stunden- oder tagelang in einer Queue, und es gibt kein HTTP 422, das dir sagt, dass etwas kaputt ist. Falsch gemachte Schema-Änderungen korrumpieren stillschweigend den Zustand oder lassen Worker um 2 Uhr nachts abstürzen.
Dies ist kein Tooling-Problem, das du durch die Wahl des richtigen Message Brokers löst. Es ist ein Disziplin-Problem. Hier ist das Framework, das ich anwende, bevor ich in Produktion ein Event-Schema anfasse.
Warum Schema-Evolution schwieriger ist als API-Evolution
Bei REST-APIs sind Breaking Changes sichtbar. Der Client erhält einen 400, Retries schlagen fehl, Dashboards werden rot. Der Feedback-Loop ist kurz.
Bei event-getriebenen Systemen ist der Feedback-Loop unterbrochen:
- Consumer hinken hinterher: Ein Consumer könnte Nachrichten verarbeiten, die vor Tagen produziert wurden, wenn du eine Schema-Änderung deployst. Alte Nachrichten, die bereits in der Queue sind, schlagen jetzt bei der Validierung fehl.
- Mehrere Consumer: Eine Schema-Änderung kann drei verschiedene Services kaputt machen — von denen dein Team nur einen besitzt.
- Kein synchroner Handshake: Es gibt keinen Moment, in dem Producer und Consumer einen Vertrag aushandeln. Der Vertrag lebt in der Dokumentation, oder schlimmer, im Tribal Knowledge.
Die Lösung ist nicht, Schema-Änderungen zu vermeiden. Es ist, nur kompatible Änderungen zu machen und ein bewusstes Muster für die inkompatiblen zu haben.
Die vier Kompatibilitätsmodi
Bevor du ein Schema anfasst, klassifiziere die Änderung:
| Änderungstyp | Beispiel | Kompatibilität |
|---|---|---|
| Optionales Feld hinzufügen | + correlationId?: string | Rückwärts ✅ Vorwärts ✅ |
| Optionales Feld entfernen | - deprecatedField? | Nur vorwärts ⚠️ |
| Erforderliches Feld hinzufügen | + requiredAt: string | Keine ❌ |
| Umbenennen oder Typ ändern | status: boolean → string | Keine ❌ |
Rückwärtskompatibel bedeutet, dass alte Consumer neue Nachrichten lesen können. Vorwärtskompatibel bedeutet, dass neue Consumer alte Nachrichten lesen können. Volle Kompatibilität in beide Richtungen ist der einzige sichere Modus für langlebige Event-Systeme, in denen du Deployments über Teamgrenzen hinweg nicht koordinieren kannst.
Das Expand-Contract-Muster
Für Breaking Changes veränderst du das Schema niemals in einem Schritt. Nutze drei Phasen:
- Expandieren: Füge das neue Feld neben dem alten hinzu. Produziere beide.
- Migrieren: Aktualisiere alle Consumer, damit sie das neue Feld lesen. Markiere das alte als deprecated.
- Kontrahieren: Entferne das alte Feld, sobald kein Consumer mehr darauf zugreift.
// Phase 1 — Expand: produce both old and new field simultaneously
interface OrderPlacedExpanded {
orderId: string;
customerId: string; // kept for old consumers still reading this field
accountId: string; // new canonical field for new consumers
totalCents: number;
}
function produceOrderPlaced(order: Order): OrderPlacedExpanded {
return {
orderId: order.id,
customerId: order.account.id, // backward compat
accountId: order.account.id, // forward compat
totalCents: order.totalCents,
};
}// Phase 2 — Consumers prefer the new field with a fallback
function handleOrderPlaced(event: OrderPlacedExpanded): void {
// ✅ graceful fallback during the transition window
const accountId = event.accountId ?? event.customerId;
processOrder(accountId, event.orderId, event.totalCents);
}Erst wenn jeder Consumer mit dem Fallback deployed ist, gehst du zu Phase 3 über und entfernst customerId. Überspringe Phase 2, und Consumer werden mit Nachrichten abstürzen, die bereits in der Queue liegen — Nachrichten, die vor deinem Deployment produziert wurden.
Das Envelope-Muster für inkompatible Änderungen
Wenn ein wirklich inkompatibler Wechsel unvermeidlich ist — ein Event neu strukturieren, die Semantik eines Feldes ändern — führe eine neue Schema-Version ein. Das Envelope-Muster macht die Versions-Routing explizit, anstatt auf Form-Raten zu setzen:
interface EventEnvelope<T = unknown> {
eventId: string;
eventType: string;
schemaVersion: number;
occurredAt: string; // ISO 8601
payload: T;
}
interface OrderPlacedV1Payload {
orderId: string;
customerId: string;
totalCents: number;
}
interface OrderPlacedV2Payload {
orderId: string;
account: { id: string; email: string };
total: { cents: number; currency: string };
}
type OrderPlacedEvent =
| (EventEnvelope<OrderPlacedV1Payload> & { schemaVersion: 1 })
| (EventEnvelope<OrderPlacedV2Payload> & { schemaVersion: 2 });Der Consumer dispatcht auf die Version, nicht auf Heuristiken zur Feldpräsenz:
function handleOrderPlaced(envelope: OrderPlacedEvent): void {
if (envelope.schemaVersion === 1) {
const { orderId, customerId, totalCents } = envelope.payload;
processOrderV1(orderId, customerId, totalCents);
return;
}
if (envelope.schemaVersion === 2) {
const { orderId, account, total } = envelope.payload;
processOrderV2(orderId, account.id, total.cents, total.currency);
return;
}
// exhaustive check — compiler warns if a new version is added without handling it
const _exhaustive: never = envelope;
}Vermeide es, die Version in den Event-Typ-Namen einzubetten (order.placed.v2). Das koppelt das Routing an Schema-Belange und lässt die Topic-Namen explodieren. Bevorzuge ein schemaVersion-Feld im Envelope mit einem einzigen stabilen Topic pro logischem Event-Typ.
Laufzeitvalidierung an der Consumer-Grenze
TypeScript-Typen verschwinden zur Laufzeit. Wenn eine Nachricht vom Broker ankommt, kennst du ihre Form nicht, bis du sie validierst. Koppel deine Interfaces mit einem Laufzeit-Schema:
import { z } from "zod";
const OrderPlacedV2Schema = z.object({
eventId: z.string().uuid(),
eventType: z.literal("order.placed"),
schemaVersion: z.literal(2),
occurredAt: z.string().datetime(),
payload: z.object({
orderId: z.string(),
account: z.object({ id: z.string(), email: z.string().email() }),
total: z.object({
cents: z.number().int().positive(),
currency: z.string().length(3),
}),
}),
});
// ❌ Trusting the message shape — the compiler is lying to you
async function consumeRaw(message: Buffer): Promise<void> {
const event = JSON.parse(message.toString()) as OrderPlacedV2; // cast, not safety
processOrder(event.payload.orderId); // crashes if shape is wrong
}
// ✅ Validate at the boundary before touching the payload
async function consume(message: Buffer): Promise<void> {
const parsed = JSON.parse(message.toString());
const result = OrderPlacedV2Schema.safeParse(parsed);
if (!result.success) {
logger.error("Schema validation failed", {
errors: result.error.flatten(),
rawEvent: parsed,
});
await deadLetterQueue.send(message); // park it, don't drop it
return;
}
processOrder(result.data.payload.orderId);
}Fehlgeschlagene Validierungen gehören in eine Dead-Letter-Queue, nicht in einen Crash oder ins stille Verwerfen. Beides ist schlimmer, als die Nachricht mit vollem Kontext zur manuellen Inspektion zu parken.
Wissen, wann der Übergang abgeschlossen ist
Der schwierigste Teil der Schema-Evolution ist nicht der Code — es ist zu wissen, wann Phase 3 sicher abgeschlossen werden kann. Einige Signale, die tatsächlich funktionieren:
Producer-seitige Deprecation-Metriken: Erhöhe einen Zähler jedes Mal, wenn das alte Feld geschrieben wird. Wenn der Zähler nach der Migration flach wird, kann das Feld sicher entfernt werden.
Consumer-seitige Fallback-Zähler: Verfolge, wie oft event.accountId ?? event.customerId den Fallback-Zweig trifft. Ein Zähler, der null erreicht, ist ein verlässliches Migrations-Signal — kein Deployment-Zeitstempel.
Schema-Registry-Enforcement: Tools wie Confluent Schema Registry oder AWS Glue Schema Registry können inkompatible Schema-Veröffentlichungen zur CI-Zeit ablehnen, bevor etwas Produktion erreicht.
# Validate schema compatibility before publishing — runs in CI
curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"schema":"{...}"}' \
"http://registry:8081/compatibility/subjects/order-placed-value/versions/latest"
# Returns 200 with {"is_compatible":true} or fail the pipelineDie Verwendung einer Registry verschiebt die Kompatibilitätsprüfung nach links — du fängst den Breaking Change im Pull Request ab, nicht in Produktion und nicht um 2 Uhr nachts.
Wichtigste Erkenntnisse
- Klassifiziere, bevor du änderst: Jede Schema-Modifikation ist rückwärtskompatibel, vorwärtskompatibel oder keine von beidem. Wisse, welche es ist, bevor du eine Zeile schreibst.
- Expandiere, bevor du kontrahierst: Breaking Changes erfordern drei Deployment-Phasen — Expandieren, Migrieren, Kontrahieren. Die Migrationsphase zu überspringen korrumpiert Nachrichten, die bereits in der Queue sind.
- Umschließe jedes Event: Ein
schemaVersion-Feld in einem Standard-Envelope verwandelt implizites Form-Raten in explizites, vom Compiler geprüftes Version-Dispatching. - Validiere an der Consumer-Grenze: Laufzeit-Schema-Validierung fängt die Diskrepanz ab. Schicke Fehler in die Dead-Letter-Queue — verwirf sie niemals stillschweigend.
- Verfolge den Übergang mit Metriken: Deprecation-Zähler und Fallback-Zweig-Treffer zeigen dir, wann die Migration abgeschlossen ist. Verlasse dich nicht auf Deployment-Zeitstempel.


