EDA Fundamentals: domeingebeurtenissen, opdrachten en berichtenbus
Stel je een e-commercesysteem voor: wanneer een klant een bestelling voltooit, moet dit ook gebeuren veel dingen parallel: de voorraad moet worden verlaagd, er moet een e-mailmelding worden verzonden, het fulfilmentteam moet op de hoogte worden gebracht, het loyaliteitssysteem moet worden bijgewerkt. Ja Coördineren zij deze diensten? Een klassieke aanpak is dat de dienst Bestellingen belt alle andere diensten direct: nauwe koppeling, kwetsbaarheid, onmogelijkheid zelfstandig kunnen klimmen.
L'Gebeurtenisgestuurde architectuur (EDA) gooit dit paradigma omver: service
Orders publiceert een evenement OrderPlaced op een berichtenbus en alle services
geïnteresseerde partijen consumeren het zelfstandig. De dienst Bestellingen weet niet wie naar hen luistert, niet
wacht op antwoorden, het is niet afhankelijk van hun beschikbaarheid. Deze ontkoppeling en de
fundamentele principe waarop schaalbare en veerkrachtige gedistribueerde systemen zijn gebouwd.
Wat je gaat leren
- Het verschil tussen domeingebeurtenissen, opdrachten en query's in EDA
- Publiceer-Abonneer Patroon: ontkoppelde producenten en consumenten
- Message Bus, Event Bus en Message Queue: wanneer wat te gebruiken
- Gebeurtenisschema: structuur, versiebeheer en standaard CloudEvents
- Voordelen en afwegingen van EDA versus synchrone REST
- Implementatie van een eenvoudig EDA-systeem in TypeScript
- Hoe u kunt beslissen wanneer u EDA wel en wanneer niet gebruikt
De drie soorten berichten in EDA
Niet alle berichten in een gebeurtenisgestuurd systeem zijn hetzelfde. Begrijp het onderscheid tussen gebeurtenissen, commando's en vragen en de eerste stap om correcte EDA-systemen te ontwerpen:
| Type | Beschrijving | Richting | Antwoord | Voorbeeld |
|---|---|---|---|---|
| Domein evenement | Er is iets gebeurd in het domein | 1 → N (uitzending) | No | OrderPlaced, PaymentReceived |
| Commando | Een verzoek om een actie uit te voeren | 1 → 1 (punt-tot-punt) | Optioneel (asynchrone ACK) | PlaceOrder, SendEmail |
| Vragen | Eén verzoek om gegevens (asynchrone EDA) | 1 → 1 met antwoord | Ja (antwoordwachtrij) | GetOrderStatus via antwoordwachtrij |
Domeinevenementen: het hart van EDA
Un Domein evenement beschrijft iets dat al is gebeurd in het zakelijke domein. De belangrijkste eigenschappen:
- Onveranderlijk: beschrijft het verleden, het verandert niet na publicatie
- Named nel passato:
OrderPlaced, nonPlaceOrder - Self-contained: contiene tutti i dati necessari per i consumatori
- Typed: ogni tipo di evento ha uno schema definito
// TypeScript: struttura di un Domain Event
interface DomainEvent {
eventId: string; // ID unico dell'evento (UUID)
eventType: string; // nome del tipo evento
occurredAt: string; // timestamp ISO 8601 (immutabile)
aggregateId: string; // ID dell'aggregato che ha generato l'evento
aggregateType: string; // tipo dell'aggregato (es. "Order")
version: number; // versione dello schema evento (per evoluzione)
payload: unknown; // dati specifici dell'evento
metadata?: {
correlationId?: string; // ID per tracciare la catena di eventi
causationId?: string; // ID del messaggio che ha causato questo evento
userId?: string; // utente che ha innescato l'azione
};
}
// Evento concreto: OrderPlaced
interface OrderPlacedEvent extends DomainEvent {
eventType: 'OrderPlaced';
aggregateType: 'Order';
payload: {
orderId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
unitPrice: number;
}>;
totalAmount: number;
currency: string;
shippingAddress: {
street: string;
city: string;
country: string;
};
};
}
// Creare un OrderPlaced event
function createOrderPlacedEvent(order: Order): OrderPlacedEvent {
return {
eventId: crypto.randomUUID(),
eventType: 'OrderPlaced',
occurredAt: new Date().toISOString(),
aggregateId: order.id,
aggregateType: 'Order',
version: 1,
payload: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
totalAmount: order.totalAmount,
currency: order.currency,
shippingAddress: order.shippingAddress,
},
metadata: {
correlationId: crypto.randomUUID(),
},
};
}
Pattern Publish-Subscribe
Il pattern Publish-Subscribe e il fondamento dell'EDA: il publisher (produttore) invia eventi al message bus senza sapere chi li riceve; i subscriber (consumatori) si registrano per ricevere certi tipi di eventi senza sapere chi li pubblica.
// Implementazione semplice di un Event Bus in memoria (per test/sviluppo)
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;
class InMemoryEventBus {
private handlers = new Map<string, EventHandler<DomainEvent>[]>();
subscribe<T extends DomainEvent>(eventType: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
this.handlers.set(eventType, [...existing, handler as EventHandler<DomainEvent>]);
}
async publish(event: DomainEvent): Promise<void> {
const eventHandlers = this.handlers.get(event.eventType) ?? [];
// Pubblica in parallelo a tutti i subscriber
await Promise.allSettled(
eventHandlers.map((handler) => handler(event))
);
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
// Utilizzo:
const eventBus = new InMemoryEventBus();
// Inventory Service si registra per OrderPlaced
eventBus.subscribe<OrderPlacedEvent>('OrderPlaced', async (event) => {
console.log(`Decrementing inventory for order ${event.payload.orderId}`);
for (const item of event.payload.items) {
await inventoryService.decrement(item.productId, item.quantity);
}
});
// Email Service si registra per OrderPlaced
eventBus.subscribe<OrderPlacedEvent>('OrderPlaced', async (event) => {
await emailService.sendOrderConfirmation(
event.payload.customerId,
event.payload.orderId
);
});
// Order Service pubblica l'evento (non conosce i subscriber)
await eventBus.publish(createOrderPlacedEvent(placedOrder));
Message Bus, Event Bus e Message Queue: Le Differenze
I termini vengono spesso usati in modo intercambiabile ma hanno significati precisi:
- Message Queue: Coda point-to-point. Un messaggio viene consegnato a un solo consumatore. Esempio: SQS Standard Queue
- Event Bus: Broadcast a tutti i subscriber. Ogni subscriber riceve una copia dell'evento. Esempio: AWS EventBridge, SNS Topic
- Message Bus: Termine generale che include sia queue che topic. In pratica: un broker che gestisce il routing dei messaggi (RabbitMQ, Kafka)
// Esempio: stessa logica su AWS SQS + SNS (architettura fan-out comune)
// Pattern fan-out: SNS Topic + SQS Queue per ogni consumer
// 1. Pubblica su SNS Topic
// 2. SNS consegna a tutte le SQS Queue sottoscritte
// 3. Ogni servizio legge dalla propria SQS Queue indipendentemente
// Terraform per il fan-out pattern:
resource "aws_sns_topic" "order_events" {
name = "order-events"
}
resource "aws_sqs_queue" "inventory_queue" {
name = "inventory-order-events"
}
resource "aws_sqs_queue" "email_queue" {
name = "email-order-events"
}
resource "aws_sns_topic_subscription" "inventory" {
topic_arn = aws_sns_topic.order_events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.inventory_queue.arn
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.order_events.arn
protocol = "sqs"
endpoint = aws_sqs_queue.email_queue.arn
}
CloudEvents: Lo Standard per gli Schemi degli Eventi
CloudEvents e una specifica CNCF che standardizza la struttura degli eventi tra sistemi diversi. Adottarla facilita l'interoperabilita e semplifica i tool di monitoring e debugging:
// CloudEvents v1.0 - struttura standard
{
"specversion": "1.0",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "com.company.order.placed", // Reverse DNS + evento
"source": "/orders-service/v1", // URI del servizio sorgente
"subject": "order-789", // identificativo della risorsa
"time": "2026-03-20T10:30:00Z", // timestamp ISO 8601
"datacontenttype": "application/json",
"dataschema": "https://schemas.company.com/order/placed/v1.json",
"data": {
"orderId": "order-789",
"customerId": "cust-123",
"totalAmount": 150.00,
"currency": "EUR"
}
}
// TypeScript: creare un CloudEvent con la SDK ufficiale
import { CloudEvent } from "cloudevents";
const event = new CloudEvent({
specversion: "1.0",
type: "com.company.order.placed",
source: "/orders-service/v1",
subject: `order-${orderId}`,
datacontenttype: "application/json",
dataschema: "https://schemas.company.com/order/placed/v1.json",
data: {
orderId: order.id,
customerId: order.customerId,
totalAmount: order.totalAmount,
currency: order.currency,
},
});
// Valida il CloudEvent prima di pubblicarlo
if (!event.source || !event.type) {
throw new Error("CloudEvent validation failed: missing required fields");
}
Versioning degli Eventi
Gli eventi vengono consumati da piu servizi in modo indipendente. Cambiare lo schema di un evento senza una strategia di versioning rompe i consumer. I pattern principali:
// Pattern 1: Versioning nel tipo evento
// Vecchi consumer continuano a ricevere v1, nuovi consumer si registrano per v2
eventBus.subscribe('OrderPlaced.v1', handleOrderPlacedV1);
eventBus.subscribe('OrderPlaced.v2', handleOrderPlacedV2);
// Pattern 2: Backward-compatible changes (aggiunta di campi opzionali)
// SAFE: aggiungere nuovi campi opzionali (consumer ignorano i campi sconosciuti)
interface OrderPlacedEventV1 {
orderId: string;
customerId: string;
totalAmount: number;
}
interface OrderPlacedEventV2 extends OrderPlacedEventV1 {
// Aggiunto in V2: opzionale, backward-compatible
estimatedDeliveryDate?: string;
loyaltyPointsEarned?: number;
}
// Pattern 3: Parallel publishing (per breaking changes)
// Pubblica sia v1 che v2 per un periodo di transizione
async function publishOrderPlaced(order: Order): Promise<void> {
const v1Event = createOrderPlacedV1(order);
const v2Event = createOrderPlacedV2(order);
await Promise.all([
eventBus.publish(v1Event), // per consumer legacy
eventBus.publish(v2Event), // per consumer aggiornati
]);
}
// NEVER: rimuovere campi, cambiare tipi, rinominare campi obbligatori
// -> breaking change: migra prima tutti i consumer poi rimuovi v1
Benefici e Trade-Off dell'EDA
Quando Usare l'EDA
- Disaccoppiamento necessario: Quando vuoi aggiungere nuovi consumer senza modificare i publisher
- Scalabilita indipendente: Consumer diversi con carichi diversi che scalano separatamente
- Audit trail: Gli eventi immutabili sono un log naturale di tutto cio che e accaduto nel sistema
- Resilienza alle failure: Se un consumer e giu, il message bus trattiene i messaggi fino a quando non torna up
- Integrazione cross-system: Sistemi eterogenei che comunicano tramite eventi standard
Quando NON Usare l'EDA
- Risposta immediata richiesta: Se l'utente deve aspettare il risultato sincrono, l'EDA aggiunge latenza e complessita inutile
- Sistemi semplici: Un monolite con poche funzionalita non beneficia dell'overhead di un message broker
- Transazioni distribuite semplici: Per operazioni che devono essere atomiche su piu servizi, l'EDA richiede il pattern Saga (complessita alta)
- Team piccolo senza esperienza EDA: La curva di apprendimento e significativa. Inizia con REST e aggiungi EDA dove necessario
Il Flusso Completo: Esempio e-Commerce
// Flusso completo EDA per un ordine e-commerce
// 1. Order Service: riceve HTTP POST /orders
// 2. Valida, persiste, pubblica evento
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus
) {}
async placeOrder(dto: PlaceOrderDto): Promise<Order> {
// Logica business: crea l'ordine
const order = Order.create(dto);
// Persisti nel database
await this.orderRepo.save(order);
// Pubblica gli eventi generati dall'aggregato
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
order.clearEvents();
return order;
}
}
// 3. Inventory Service: ascolta OrderPlaced
// - Scala indipendentemente con 5 consumer paralleli
// - Se giu, i messaggi si accumulano nella queue
// 4. Email Service: ascolta OrderPlaced
// - Invia email di conferma
// - Se fallisce, il messaggio va in DLQ per retry
// 5. Loyalty Service: ascolta OrderPlaced
// - Calcola e aggiunge punti fedeltà
// - Pubblica LoyaltyPointsEarned
// 6. Analytics Service: ascolta OrderPlaced + LoyaltyPointsEarned
// - Aggiorna le metriche in tempo reale
// Il servizio Order non sa niente di tutto questo!
// Aggiungere un nuovo consumer = zero modifiche al publisher
Conclusioni e Prossimi Passi
EDA is een paradigmaverschuiving: van een systeem waar diensten elkaar naartoe 'bellen' een systeem waarbij diensten "communiceren via gebeurtenissen". De ontkoppelingswinst, schaalbaarheid en veerkracht zijn reëel, maar vereisen het aangaan van nieuwe uitdagingen: management Het aantal fouten wordt asynchroon, foutopsporing vereist correlatie-ID en gedistribueerde tracering, consistentie moet ‘uiteindelijk’ worden.
De volgende artikelen in deze serie gaan in op de geavanceerde patronen waaruit EDA bestaat levensvatbaar in productie: Event Sourcing voor onveranderlijke staat, CQRS voor scheiding lezen/schrijven, Saga voor gedistribueerde transacties en AWS-tools (EventBridge, SQS, SNS) om ze in cloudomgevingen te implementeren.
Aankomende artikelen in de Event-Driven Architecture-serie
- Event Sourcing: Staat als een onveranderlijke opeenvolging van gebeurtenissen
- CQRS: afzonderlijk lezen en schrijven voor onafhankelijke schaling
- Saga-patroon: gedistribueerde transacties met choreografie en orkestratie
- AWS EventBridge: serverloze gebeurtenisbus en op inhoud gebaseerde routering
- Dead Letter-wachtrij en veerkracht in asynchrone systemen
Gerelateerde serie
- Apache Kafka en streamverwerking — Kafka als ruggengraat voor EDA-systemen met grote volumes
- Kubernetes op schaal — EDA-microservices op Kubernetes orkestreren
- Praktische softwarearchitectuur – wanneer EDA versus REST, monoliet versus microservices







