Vai al contenuto
Event design

Event design: contratti, naming e schema evolution 🧩

Questa è la guida che vorresti avere prima di pubblicare il tuo primo evento “UserUpdated” e scoprire che hai appena creato un contratto eterno.

Eventi come contratti (sì, come le API) 📜

    flowchart LR
  subgraph Producer
    S[Servizio]
  end

  S --> E["Evento (contratto)"]
  E --> B[(Broker / Stream)]
  B --> C1[Consumer A]
  B --> C2[Consumer B]
  B --> C3[Consumer C]
  

In EDA, l’evento è un contratto tra sistemi.

  • Se cambi il payload senza pensare alla compatibilità, non “rompi una feature”: rompi un ecosistema.
  • Se non documenti l’evento, stai regalando al futuro un bel rebus (spoiler: lo risolverai tu, di notte).

Naming: scegli il dominio, non il database 🧠

Il nome di un evento è il suo contratto più visibile. Prima ancora del payload, il nome dice ai consumer cosa è successo nel dominio. Sbagliarlo costa caro: una volta che un event type è in produzione con consumer reali, rinominarlo è un breaking change.

Preferisci nomi che rappresentano business facts, al passato, in inglese:

  • OrderCreated, EmailChanged, PaymentRejected, InventoryReserved
  • OrderRowInserted, UpdateUserTable, SyncCRM, DoPayment

Alcune convenzioni utili:

  • Past tense: l’evento racconta qualcosa che è già successo, non un comando. OrderCreated è un fatto; CreateOrder è un’intenzione (e appartiene a un command, non a un event).
  • Dominio.Entità.Fatto (opzionale): in sistemi grandi, un prefisso di dominio aiuta il routing e la leggibilità nei cataloghi — es. payments.PaymentRejected, inventory.StockDepleted. Scegli uno schema e applicalo ovunque.
  • Evita nomi generici: UserUpdated non dice nulla di utile. UserEmailVerified, UserAddressChanged, UserAccountSuspended raccontano fatti precisi con semantica chiara per i consumer.
  • Evita nomi tecnici: DBSyncEvent, CacheInvalidated, RowDeleted sono dettagli implementativi travestiti da eventi di dominio. Se finiscono pubblici, accoppi tutti i consumer al tuo storage.

Regola pratica: se il nome rivela l’implementazione o non si capisce senza leggere il codice, va cambiato prima di andare in produzione.

Internal vs external events 🔐

    flowchart LR
  INT["Internal event<br/>(ottimizzato per il dominio)"] --> ACL["Anti-corruption layer<br/>(traduzione/normalizzazione)"]
  ACL --> EXT["External/Public event<br/>(stabile, documentato)"]
  

Non tutti gli eventi sono uguali in termini di audience e stabilità attesa:

  • Internal events: ottimizzati per il bounded context; possono cambiare più spesso, riflettono il modello interno, non hanno bisogno di essere comprensibili fuori dal team. Un payments.InternalLedgerEntryCreated può evolvere liberamente finché resta dentro il dominio pagamenti.
  • External/public events: pensati per consumatori fuori dal bounded context o fuori dall’organizzazione; vanno progettati come API pubbliche — con lo stesso rigore, la stessa attenzione alla retro-compatibilità e la stessa documentazione che daresti a un endpoint REST pubblico.

L’Anti-Corruption Layer (ACL) è il livello di traduzione che separa i due mondi: prende un internal event, lo normalizza e pubblica un external event con una forma stabile e curata. Questo ti permette di evolvere il modello interno senza rompere i consumer esterni.

Consiglio pragmatico: anche se oggi sei l’unico consumer, tratta il canale pubblico come se ci fossero già dieci team in ascolto. Aggiungere un ACL dopo il fatto, quando i consumer sono già accoppiati al formato interno, è costoso e doloroso. Meglio separare i livelli fin dall’inizio, anche con un mapping banale.

Tipi di eventi (quelli che compaiono davvero) 🧩

Non esistono “solo eventi”: esistono eventi con intenzioni e trade-off diversi. Dare un nome a questi tipi non è accademia: è un modo per evitare discussioni infinite su payload, stabilità e coupling.

  • Domain/Internal events: parlano il linguaggio del bounded context e possono evolvere più rapidamente. Esempi: PaymentLedgerUpdated, InternalRiskScoreComputed. Nessuno fuori dal contesto deve dipendere da questi.
  • Integration/Public events: pensati per integrazioni tra bounded context o consumer esterni; stabilità e documentazione prima di tutto. Esempi: PaymentCompleted, OrderShipped. Cambiarli richiede un processo di deprecazione esplicito.
  • Delta events: portano “cosa è cambiato” (diff) invece di ripetere tutto lo stato. Utili in scenari di change data capture o quando il payload completo è troppo grande o troppo sensibile. Sfida principale: il consumer deve ricostruire lo stato applicando i delta in ordine, il che complica replay e recovery.
  • CDC events: derivati direttamente da cambiamenti nel database (binlog, WAL), spesso tramite tool come Debezium. Hanno il vantaggio di essere generati automaticamente e atomici rispetto alla transazione DB, ma sono strettamente accoppiati allo schema di storage. Se escono dal confine del bounded context senza traduzione, accoppi i consumer al tuo modello dati interno — quasi sempre un errore.

Notification vs ECST: payload minimo o ricco? 📦

Qui la scelta non è tra “bene” e “male”: è tra contratto più semplice ma più callback e contratto più ricco ma più responsabilità. L’importante è rendere esplicito quale coupling stai accettando e perché.

  • Notification: l’evento porta solo un riferimento (es. { "orderId": "abc-123" }). Il consumer che ha bisogno di dettagli fa una chiamata sincrona al producer. Vantaggio: il contratto dell’evento è piccolo e stabile; svantaggio: reintroduce dipendenza sincrona e può generare un “thundering herd” di callback ad alto volume.
  • Event-Carried State Transfer (ECST): l’evento porta tutto lo stato rilevante nel payload ({ "orderId": "abc-123", "customerId": 7, "items": [...], "total": 89.90 }). Il consumer è autonomo a runtime; svantaggio: ogni campo diventa parte del contratto pubblico, la schema evolution è più complessa e il payload può diventare grande.

Non esiste il “giusto” in assoluto: esiste il compromesso tra coupling sincrono e complessità del contratto. La scelta va documentata esplicitamente, non lasciata al caso o all’intuizione del momento.

Eventi espliciti vs impliciti 🧭

Un evento è esplicito quando racconta chiaramente il fatto di business (PaymentRejected, UserEmailVerified); è implicito quando lascia il consumer a indovinare (UserUpdated, OrderChanged).

Gli eventi impliciti sembrano comodi da produrre — basta ascoltare qualsiasi modifica e pubblicare — ma costringono i consumer a inferenze fragili: perché è stato aggiornato l’utente? cosa è cambiato nell’ordine? Ogni consumer risponde diversamente, e la semantica dell’evento diverge silenziosamente nel tempo.

Regola pratica: se due team possono interpretare lo stesso evento in due modi diversi, il nome non è abbastanza esplicito. Meglio più eventi con nomi precisi che un unico evento generico che prova a coprire tutto.

Schema evolution: come cambiare senza fare danni 🧬

Obiettivo: compatibilità backward e/o forward.

La maggior parte dei disastri nasce da cambi “piccoli” fatti in fretta: rinomini, cambi tipo, semantica che slitta piano piano. Queste linee guida sono volutamente conservative: puntano a mantenere compatibilità e a ridurre sorprese nei consumer.

Linee guida conservative:

  • aggiungi campi opzionali (non cambiare significato di quelli esistenti);
  • non cambiare tipo di un campo (se serve, aggiungi un campo nuovo);
  • evita rinomini “creativi” (sono breaking change mascherati);
  • pianifica una strategia di deprecazione (periodo di overlap).

Versioning: eventi “v1, v2…”? 🏷️

Versionare è utile, ma abusarne è un modo elegante per avere 17 varianti in produzione.

Approccio spesso sostenibile:

  • prova prima con compatibilità (campi opzionali);
  • versione esplicita solo per breaking change inevitabili;
  • documenta chiaramente la finestra di supporto.

Metadata minimo (che ti salva nel debugging) 🧵

    flowchart TB
  ENV[Event envelope] --> META[Metadata<br />- event_id<br />- event_type<br />- occurred_at<br />- producer<br />- correlation_id<br />- causation_id]
  ENV --> PAY["Payload<br />(dati di dominio)"]
  

Suggerimento pratico: definisci uno “standard interno” di metadata.

Campi tipici:

Non serve inventarsi 40 header “perché sì”: servono pochi campi coerenti che ti permettono di tracciare il flusso e fare debugging.

  • event_id (univoco)
  • event_type
  • occurred_at
  • producer
  • correlation_id
  • causation_id

Se vuoi uno standard interop, valuta CloudEvents come envelope comune. Non risolve i problemi di dominio, ma evita il “ognuno inventa la ruota”.

Attenzione a non esporre troppo 🕵️

Eventi troppo ricchi possono esporre dettagli interni, PII o vincolare i consumer al tuo modello dati corrente. Meglio progettare un “public event” pulito (con ACL/traduzione quando serve) che pubblicare direttamente la tua tabella travestita da JSON.

Checklist prima di pubblicare un nuovo evento ✅

Questa checklist è la versione “prima di schiacciare deploy” di tutto il capitolo. Se la salti, funziona comunque… finché non arriva il primo consumer vero (o il primo audit privacy).

  • Nome: deve essere un business fact chiaro anche a chi non ha letto il codice; se serve, chiedi a un product owner una frase di spiegazione.
  • Internal vs external: identifica i consumer target e decidi se serve un livello di traduzione/anti-corruption prima di esporre l’evento.
  • Payload (Notification vs ECST): documenta la scelta e i motivi (latenza vs autonomia consumer) per evitare ripensamenti “a sentimento”.
  • Contratto e versioning: assicurati che lo schema sia definito in un posto tracciabile e che la strategia di compatibilità sia chiara (opzionale vs breaking).
  • Metadata: conferma quali header sono obbligatori (es. correlation_id) e come vengono generati/propagati.
  • Privacy: controlla PII, minimizza i dati nel payload e valuta mascheramento o redaction per i public events.
  • Documentazione: indica dove si trova lo schema, chi lo mantiene e come aprire una richiesta di modifica.

Prossimi passi 🚀

Se hai progettato bene il contratto, il prossimo punto è farlo sopravvivere alla realtà: duplicati, retry, DLQ e persone che fanno replay quando non guardi.

Ultimo aggiornamento il