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:
UserUpdatednon dice nulla di utile.UserEmailVerified,UserAddressChanged,UserAccountSuspendedraccontano fatti precisi con semantica chiara per i consumer. - Evita nomi tecnici:
DBSyncEvent,CacheInvalidated,RowDeletedsono 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.InternalLedgerEntryCreatedpuò 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_typeoccurred_atproducercorrelation_idcausation_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.
- Per idempotenza, retry, DLQ: vedi la guida operativa.
- Per governance e catalogo eventi: vedi la guida di governance.