Event-Driven Architecture: Muster und Fallstricke
Ereignisgesteuerte Systeme entkoppeln Producer und Consumer asynchron — doch Konsistenz, Ordnungsgarantien und Idempotenz kosten echte Komplexität.

In einer Request-Response-Architektur ruft Service A Service B auf und wartet auf eine Antwort. Das führt zu enger Kopplung — A muss Bs Adresse, API-Vertrag und Verfügbarkeit kennen. Ereignisgesteuerte Architektur kehrt das um: A veröffentlicht ein Ereignis („order created“) an einen Broker, und beliebig viele Consumer reagieren unabhängig darauf. Der Producer weiß nicht und kümmert sich nicht darum, wer zuhört.
Events vs. Commands
Events beschreiben etwas, das passiert ist. Commands fordern etwas an, das passieren soll. Der Unterschied ist wichtig für das Systemdesign.
// Events: past tense, factual, immutable
interface OrderCreatedEvent {
type: "order.created";
timestamp: string;
data: {
orderId: string;
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
totalAmount: number;
};
}
// Commands: imperative, directed at a specific handler
interface SendEmailCommand {
type: "send.email";
data: {
to: string;
template: string;
variables: Record<string, string>;
};
}
// ❌ Mixing events and commands
// "OrderCreated" event that also tells the email service what to do
interface BadEvent {
type: "order.created";
data: {
orderId: string;
emailTo: string; // Why does the order event know about emails?
emailTemplate: "receipt"; // This couples the producer to the consumer
};
}
// ✅ Clean separation — event carries facts, consumers decide what to do
// Order service publishes: OrderCreatedEvent
// Email service listens, looks up user email, sends receipt
// Inventory service listens, decrements stock
// Analytics service listens, records conversionEinfache Event-Bus-Implementierung
Ein einfacher In-Process-Event-Bus veranschaulicht das Muster, bevor ein Message Broker eingeführt wird.
type EventHandler<T = unknown> = (event: T) => Promise<void>;
class EventBus {
private handlers = new Map<string, EventHandler[]>();
on<T>(eventType: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler as EventHandler);
this.handlers.set(eventType, existing);
}
async emit<T extends { type: string }>(event: T): Promise<void> {
const handlers = this.handlers.get(event.type) ?? [];
// Execute all handlers concurrently
const results = await Promise.allSettled(
handlers.map(handler => handler(event))
);
// Log failures without blocking the producer
results.forEach((result, index) => {
if (result.status === "rejected") {
console.error(`Handler ${index} for ${event.type} failed:`, result.reason);
}
});
}
}
// Usage
const bus = new EventBus();
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await sendReceiptEmail(event.data.userId, event.data.orderId);
});
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await decrementInventory(event.data.items);
});
bus.on<OrderCreatedEvent>("order.created", async (event) => {
await recordAnalytics("conversion", event.data);
});Umgang mit eventual consistency
In ereignisgesteuerten Systemen sind Daten schließlich konsistent — der E-Mail-Service verarbeitet das Ereignis Sekunden nach der Erstellung der Bestellung. Das erfordert sorgfältiges UI- und API-Design.
// ❌ Assuming immediate consistency
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
await eventBus.emit({ type: "order.created", data: order });
// Problem: client immediately queries order status
// but the inventory service hasn't processed the event yet
res.json({ orderId: order.id, status: "confirmed" });
});
// ✅ Acknowledging async processing
app.post("/orders", async (req, res) => {
const order = await createOrder(req.body);
await eventBus.emit({ type: "order.created", data: order });
// Return 202 Accepted — processing is asynchronous
res.status(202).json({
orderId: order.id,
status: "processing",
statusUrl: `/orders/${order.id}/status`, // Client can poll for updates
});
});
// Status endpoint reflects the actual processed state
app.get("/orders/:id/status", async (req, res) => {
const order = await getOrder(req.params.id);
res.json({
orderId: order.id,
status: order.status, // "processing" | "confirmed" | "failed"
inventoryReserved: order.inventoryReserved,
paymentCaptured: order.paymentCaptured,
updatedAt: order.updatedAt,
});
});Idempotente Event Handler
Events können mehrfach zugestellt werden (Broker-Retries, Netzwerkprobleme). Handler müssen idempotent sein — das zweimalige Verarbeiten desselben Events sollte denselben Effekt haben wie das einmalige.
// ❌ Non-idempotent handler — double-charges the customer
async function handlePaymentEvent(event: OrderCreatedEvent) {
await chargeCustomer(event.data.userId, event.data.totalAmount);
// If this event is delivered twice, the customer is charged twice
}
// ✅ Idempotent handler — uses event ID for deduplication
async function handlePaymentEvent(event: OrderCreatedEvent & { id: string }) {
// Check if we've already processed this event
const processed = await db.query(
"SELECT 1 FROM processed_events WHERE event_id = $1",
[event.id]
);
if (processed.rows.length > 0) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process within a transaction
await db.transaction(async (tx) => {
await tx.query(
"INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())",
[event.id]
);
await tx.query(
"INSERT INTO payments (order_id, amount, status) VALUES ($1, $2, 'captured')",
[event.data.orderId, event.data.totalAmount]
);
});
}Dead Letter Queues
Wenn ein Event Handler wiederholt fehlschlägt, sollte das Ereignis die Queue nicht ewig blockieren. Dead Letter Queues fangen fehlgeschlagene Events zur Untersuchung ein.
async function processWithRetry(
event: unknown,
handler: EventHandler,
maxRetries: number = 3
): Promise<void> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await handler(event);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(`Attempt ${attempt}/${maxRetries} failed:`, lastError.message);
if (attempt < maxRetries) {
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
}
// All retries exhausted — send to dead letter queue
await deadLetterQueue.push({
originalEvent: event,
error: lastError?.message,
failedAt: new Date().toISOString(),
attempts: maxRetries,
});
}Wichtige Erkenntnisse
- Events beschreiben Fakten, Commands fordern Aktionen — halte Events als reine Daten darüber, was passiert ist
- Producer kennen Consumer nicht — diese Entkopplung ermöglicht unabhängiges Skalieren und Deployen
- Design für eventual consistency — gib 202 Accepted zurück und stelle Status-Endpunkte für asynchrone Operationen bereit
- Jeder Handler muss idempotent sein — dedupliziere per Event-ID, denn At-Least-Once-Delivery ist der Normalfall
- Nutze Dead Letter Queues — fehlgeschlagene Events brauchen Untersuchung, keine unendlichen Retry-Schleifen
- Event-Driven erhöht die Komplexität — adoptiere es nicht für einfache synchrone Workflows, bei denen Request-Response ausreicht


