Salta al contenuto principale

01 - Fondamenti EDA: Domain Events, Commands e Message Bus

Un evento racconta un fatto passato e va a molti, un comando chiede un'azione a uno solo. Con una prova in memoria, la busta CloudEvents e il versionamento.

Cosa ne pensi di questo articolo?

01 - Fondamenti EDA: Domain Events, Commands e Message Bus
Event-Driven ArchitectureFoto:RDNE Stock projectPexels

In Breve

Un evento racconta un fatto passato e va a molti, un comando chiede un'azione a uno solo. Con una prova in memoria, la busta CloudEvents e il versionamento.

Perché un ordine non dovrebbe chiamare cinque servizi

Un cliente conferma un ordine e devono succedere cinque cose: scalare il magazzino, mandare la mail di conferma, avvisare la spedizione, accreditare i punti fedeltà, aggiornare le statistiche. Se il servizio Ordini le chiama una dopo l'altra in modo sincrono, basta che la mail sia lenta perché il cliente aspetti, e basta che il servizio fedeltà sia giù perché l'ordine fallisca per una ragione che al cliente non interessa. Ogni funzione nuova è anche una modifica al servizio Ordini.

In un'architettura a eventi il servizio Ordini fa una cosa sola: registra l'ordine e pubblica un fatto, OrderPlaced. Chi ha bisogno di reagire si iscrive e lo riceve per conto suo, alla propria velocità. Il guadagno è il disaccoppiamento: chi pubblica non conosce i consumatori e non aspetta la loro risposta. Il costo è che la risposta arriva dopo, in modo asincrono, e che il sistema diventa coerente «col tempo» invece che subito.

Questo articolo apre la serie e fissa il vocabolario: che cosa è un evento, in che cosa differisce da un comando, come si consegna a più destinatari, come si descrive e come si cambia senza rompere nessuno. Le prove sono programmi piccoli e in memoria, perché qui non c'è un broker: dove un broker vero si comporta in modo diverso, lo dico.

Evento, comando, query: tre messaggi diversi

La differenza si vede dal verbo e dal destinatario. Un evento racconta qualcosa che è già successo (OrderPlaced) e non può essere rifiutato, perché il fatto è accaduto. Un comando chiede a qualcuno di fare qualcosa (SendEmail) e può essere respinto o fallire. Una query chiede un dato e aspetta una risposta; in un sistema asincrono richiede una coda di risposta, ed è il caso in cui l'asincronia costa di più.

TipoChe cosa diceDestinatariPuò fallire?Esempio
EventoUn fatto passatoDa zero a N, sconosciuti al mittenteNon si discute il fatto, può fallire chi lo elaboraOrderPlaced
ComandoUna richiesta di azioneUno solo, noto al mittenteSì, e il mittente se ne curaSendEmail
QueryUna richiesta di datiUno, con rispostaSìGetOrderStatus

Il nome è il test più rapido. Se riesci a scrivere il messaggio al passato, è un evento; se è un imperativo, è un comando. Sbagliare categoria ha conseguenze concrete: un SendEmail pubblicato come se fosse un evento finisce a chiunque sia iscritto, e nessuno è responsabile di spedire davvero la mail. Un evento mandato come comando lega il mittente a un destinatario che non gli serve conoscere.

Martin Fowler distingue anche due stili di evento: la notifica, che porta il minimo (l'identificativo) e costringe il consumatore a richiedere il resto, e il trasferimento di stato nell'evento, che porta i dati necessari. Il secondo evita le richieste di ritorno ma copia dati ovunque e li fa invecchiare; la scelta è un compromesso, non una regola.

Che cosa rende buono un evento di dominio

  • Immutabile: descrive il passato e non si modifica dopo la pubblicazione.
  • Nominato al passato: OrderPlaced, non PlaceOrder.
  • Autosufficiente quanto basta: porta i dati che i consumatori tipici richiedono.
  • Tipizzato: per ogni tipo esiste uno schema noto e versionato.

Le quattro proprietà non pesano uguale. L'immutabilità è la più importante, perché è ciò che permette di riprodurre la storia e di correggere un errore con un evento nuovo invece di riscrivere quello vecchio; il nome al passato è la sua conseguenza linguistica. L'autosufficienza è la più discutibile, come visto sopra: va decisa consumatore per consumatore, guardando che cosa accadrebbe se il servizio proprietario del dato fosse irraggiungibile al momento della lettura.

Pubblicare a molti, inviare a uno: una prova in memoria

Il meccanismo che distingue evento e comando si riduce a una domanda sulle code. Quando pubblichi un evento, il broker ne mette una copia nella coda di ogni iscritto; quando invii un comando, c'è una coda sola e i worker se la contendono, così che ogni messaggio arrivi a uno solo. Il programma sotto implementa entrambe le cose in una classe da trenta righe. Non è un sostituto di un broker: non scrive su disco, non ha conferme né ritentativi. Serve a rendere visibili le due semantiche e un effetto che conta in produzione, la coda che si accumula quando un consumatore è fermo.

interface DomainEvent {
  eventId: string;
  eventType: string;
  occurredAt: string;
  aggregateId: string;
  version: number;
  payload: Record<string, unknown>;
}

let seq = 0;
function orderPlaced(orderId: string, total: number): DomainEvent {
  return {
    eventId: `evt-${++seq}`,
    eventType: 'OrderPlaced',
    occurredAt: '2026-11-12T10:00:00Z',
    aggregateId: orderId,
    version: 1,
    payload: { orderId, total, currency: 'EUR' },
  };
}

class Broker {
  private queues = new Map<string, DomainEvent[]>();
  private subscribers = new Map<string, string[]>();

  // Un evento ha N destinatari: ognuno si iscrive con il proprio nome e ha una coda sua.
  subscribe(eventType: string, consumer: string): void {
    this.queues.set(consumer, this.queues.get(consumer) ?? []);
    this.subscribers.set(eventType, [...(this.subscribers.get(eventType) ?? []), consumer]);
  }

  // Il publisher non conosce nessuno: il broker copia l'evento in ogni coda iscritta.
  publish(event: DomainEvent): void {
    for (const consumer of this.subscribers.get(event.eventType) ?? []) {
      this.queues.get(consumer)!.push(event);
    }
  }

  // Un comando ha un solo destinatario: una coda condivisa da tutti i worker.
  send(queue: string, message: DomainEvent): void {
    this.queues.set(queue, [...(this.queues.get(queue) ?? []), message]);
  }

  poll(queue: string): DomainEvent | undefined {
    return this.queues.get(queue)?.shift();
  }

  backlog(queue: string): number {
    return this.queues.get(queue)?.length ?? 0;
  }
}

const broker = new Broker();
for (const c of ['inventario', 'email', 'fedelta']) broker.subscribe('OrderPlaced', c);

broker.publish(orderPlaced('ord-1', 150));
broker.publish(orderPlaced('ord-2', 80));

// Il servizio email e' fermo: gli altri due consumano i propri messaggi.
for (const c of ['inventario', 'fedelta']) {
  for (let e = broker.poll(c); e; e = broker.poll(c)) console.log(`${c} elabora ${e.eventId} (${e.aggregateId})`);
}
console.log('coda email in attesa:', broker.backlog('email'));

// Il servizio email riparte e recupera tutto cio' che aveva perso.
for (let e = broker.poll('email'); e; e = broker.poll('email')) console.log(`email elabora ${e.eventId} (${e.aggregateId})`);

// Comando: tre invii, due worker sulla stessa coda. Ogni messaggio va a uno solo.
for (let i = 1; i <= 3; i++) broker.send('cmd:SendEmail', orderPlaced(`ord-${i}`, 10));
const workers = ['worker-A', 'worker-B', 'worker-A'];
for (const w of workers) console.log(`${w} prende`, broker.poll('cmd:SendEmail')?.aggregateId);

// Output:
// inventario elabora evt-1 (ord-1)
// inventario elabora evt-2 (ord-2)
// fedelta elabora evt-1 (ord-1)
// fedelta elabora evt-2 (ord-2)
// coda email in attesa: 2
// email elabora evt-1 (ord-1)
// email elabora evt-2 (ord-2)
// worker-A prende ord-1
// worker-B prende ord-2
// worker-A prende ord-3

Nella prima parte subscribe crea una coda per ciascun consumatore e publish copia l'evento in tutte: inventario e fedelta lavorano subito, mentre email, che non esegue il poll, accumula due messaggi senza che il publisher se ne accorga o rallenti. Quando il servizio riparte li trova tutti, nell'ordine in cui erano stati pubblicati.

Nella seconda parte send mette tre comandi nella stessa coda e due worker li prendono a turno: ord-1 e ord-3 vanno ad A, ord-2 a B, nessun messaggio a entrambi. Per brevità il comando riusa la forma dell'evento. Su un broker vero questo comportamento è la semantica documentata di una coda con più consumatori, ma ci sono differenze: l'assegnazione ai worker non è un turno rigido, e un messaggio non confermato torna in coda e può essere consegnato di nuovo (se ne parla nell'articolo sull'idempotenza).

Coda, bus e broker: tre parole per cose diverse

Nel linguaggio quotidiano message queue, event bus e message bus si scambiano di continuo. Conviene tenerle distinte. Una coda consegna ogni messaggio a un consumatore tra quelli collegati, ed è lo strumento dei comandi e del lavoro da ripartire. Un bus (o topic) consegna lo stesso messaggio a tutti gli iscritti, ed è lo strumento degli eventi.

Un broker è il software che ospita entrambe le cose: RabbitMQ le chiama exchange e queue, Kafka usa topic e consumer group. Il modello che ho appena simulato, un topic con una coda per iscritto, è lo schema di fan-out che molti servizi gestiti offrono e che l'articolo dedicato a SQS, SNS ed EventBridge confronta.

CloudEvents: una busta comune per gli eventi

Se ogni team inventa la propria busta, ogni consumatore scrive un parser diverso per ogni produttore. CloudEvents è una specifica che fissa gli attributi comuni della busta e lascia libero il contenuto. Nella versione 1.0.2 gli attributi obbligatori sono quattro: id, source, specversion e type; tutti gli altri (subject, time, datacontenttype, dataschema) sono facoltativi. Il progetto è cresciuto fino allo stato di «Graduated» nella CNCF, annunciato il 25 gennaio 2024 sul sito del progetto.

{
  "specversion": "1.0",
  "id": "ord-789-placed-1",
  "type": "com.example.order.placed",
  "source": "/orders-service",
  "subject": "order-789",
  "time": "2026-11-12T10:30:00Z",
  "datacontenttype": "application/json",
  "dataschema": "https://schemas.example.com/order/placed/v1.json",
  "data": { "orderId": "order-789", "customerId": "cust-123", "totalAmount": 150.00, "currency": "EUR" }
}

I campi che contano per il tuo sistema sono id e source. La specifica dice che la coppia source + id deve essere unica per ogni evento distinto e che un consumatore può considerare duplicati due eventi con la stessa coppia: è la base su cui si costruisce la deduplicazione. Il type dovrebbe avere un prefisso in notazione DNS inversa (com.example.order.placed), così che il dominio indichi chi definisce il significato dell'evento. Il JSON qui sopra non è eseguibile (è un messaggio, non un programma) ed è stato scritto seguendo la specifica; l'indirizzo schemas.example.com è un segnaposto di dominio riservato agli esempi.

Cambiare uno schema senza rompere i consumatori

Un evento ha molti lettori che non decidi tu e che non rilasci insieme a te. Un cambio di schema che sembra banale in un servizio rompe un altro che nessuno ricordava. La regola pratica è distinguere i cambi additivi (nuovi campi facoltativi) da quelli che cambiano il significato di un campo esistente (rinominarlo, cambiarne il tipo, spostarlo). I primi sono compatibili con un lettore tollerante, che ignora ciò che non conosce; i secondi no. Il programma sotto mostra i due casi con un consumatore scritto per la versione 1.

type Payload = Record<string, unknown>;

// Consumer scritto per la v1: legge solo orderId, customerId, totalAmount.
function consumerV1(p: Payload): string {
  const { orderId, customerId, totalAmount } = p as { orderId: string; customerId: string; totalAmount: number };
  if (typeof totalAmount !== 'number') throw new Error(`totalAmount mancante o non numerico in ${orderId}`);
  return `${orderId} di ${customerId}: ${totalAmount.toFixed(2)}`;
}

const v1 = { orderId: 'ord-9', customerId: 'cli-3', totalAmount: 150 };

// Cambio compatibile: la v2 AGGIUNGE un campo opzionale. Il consumer v1 non se ne accorge.
const v2Additivo = { ...v1, estimatedDeliveryDate: '2026-11-20' };
console.log('additivo  :', consumerV1(v2Additivo));

// Cambio incompatibile: la v2 RINOMINA totalAmount in total e lo sposta in un oggetto.
const v2Rotto = { orderId: 'ord-9', customerId: 'cli-3', total: { amount: 150, currency: 'EUR' } };
try {
  console.log('rinominato:', consumerV1(v2Rotto));
} catch (e) {
  console.log('rinominato: ERRORE ->', (e as Error).message);
}

// Output:
// additivo  : ord-9 di cli-3: 150.00
// rinominato: ERRORE -> totalAmount mancante o non numerico in ord-9

Il consumatore estrae solo tre campi e ignora il resto: per questo la versione additiva, con estimatedDeliveryDate, passa senza modifiche. Nella versione rotta il campo totalAmount non esiste più; il controllo typeof totalAmount !== 'number' lo trasforma in un errore chiaro, mentre senza di esso il consumatore avrebbe stampato NaN senza fermarsi. Questa è la ragione per cui un cambio incompatibile si pubblica come nuova versione del tipo (OrderPlaced.v2) e si tengono vive entrambe finché tutti i lettori non sono migrati: la specifica CloudEvents stessa porta come esempio il tipo com.example.object.deleted.v2.

Quando conviene e quando no

L'architettura a eventi rende conveniente aggiungere consumatori, perché il publisher non cambia, e isola i guasti, perché un consumatore fermo non blocca gli altri, come si è visto con la coda email. Ti dà anche una traccia di ciò che è successo. Costa in altri modi, e vale la pena elencarli prima di adottarla:

  • l'utente non ottiene il risultato nella stessa risposta: serve un modo per mostrare uno stato intermedio;
  • un'operazione che deve essere atomica su più servizi richiede una saga, non un evento;
  • il debug passa da identificativi di correlazione e tracce distribuite, non da uno stack trace;
  • un monolite con poche funzioni non ripaga un broker da gestire.

Sono quattro costi diversi: i primi due sono di prodotto e di progetto, il terzo di operatività, il quarto di proporzione. Il segnale più affidabile per adottare gli eventi non è la moda, ma la domanda «quante volte ho dovuto toccare il servizio Ordini solo per far reagire un altro servizio?». Se la risposta è mai, un'invocazione sincrona basta e avanza.

Che cosa sai fare adesso

Sai riconoscere se un messaggio è un evento, un comando o una query dal suo verbo e dal suo destinatario, e sai perché un evento va a molte code e un comando a una. Sai descrivere un evento con gli attributi obbligatori di CloudEvents e distinguere un cambio di schema innocuo da uno che rompe i consumatori. I prossimi passi: l'Event Sourcing usa gli eventi come fonte della verità, il CQRS separa lettura e scrittura, la saga coordina operazioni su più servizi. Se ti serve il broker che sta sotto, la serie su Apache Kafka ne descrive uno concreto. Quando gli eventi diventano la fonte della verità si passa all'unione tra event sourcing e CQRS; per un bus gestito con regole per contenuto c'è EventBridge; per pubblicare un evento senza perderlo né inventarlo c'è l'outbox; per ciò che l'utente vede nel frattempo, la consistenza eventuale.

Fonti

I due programmi dell'articolo sono stati eseguiti con Node.js v24.11.0 (che esegue file .ts con la sola rimozione dei tipi): gli output nei commenti sono quelli reali. Sono simulazioni in memoria, non un broker.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti