Quando conviene davvero l'event sourcing in .NET?
Invece di riscrivere una riga si aggiunge un fatto, e lo stato si ricava rileggendo i fatti in ordine. Bastano una tabella su cui si fa solo INSERT con un vincolo di unicità su aggregato più numero progressivo, una funzione pura che applica un evento a uno stato, e una tabella di lettura ricostruibile. Non serve nessun database specializzato: SQL Server, PostgreSQL e SQLite vanno bene.
Il guadagno vero non è lo storico: è che confrontare due momenti qualunque diventa venti righe di codice, perché basta ricostruire due volte fermandosi in punti diversi. Il costo è la disciplina: una sola funzione per applicare gli eventi, mai un UPDATE, e i dati personali fuori dagli eventi o cifrati con una chiave che si può buttare.

«Chi ha cambiato questo prezzo, e quando?» è la domanda a cui, in un sistema costruito nel modo normale, non c'è risposta. Non perché il dato sia stato perso per sbaglio: perché è stato coperto apposta. L'istruzione UPDATE ha scritto centoventi dove c'era centotrenta, e centotrenta non esiste più da nessuna parte. Esiste un modo di scrivere i dati in cui quella domanda ha sempre risposta, e non è tenere un registro accanto: si chiama event sourcing.
In questo articolo non trovi la solita traduzione annacquata della pagina di Martin Fowler. Trovi lo schema del database, il codice C# che compila, il conto di quanto pesa un archivio di eventi e i tre casi in cui io questo pattern non lo userei. Soprattutto trovi un sistema vero: la Fabbrica dei contenuti dell'applicazione che sto usando in questo momento per scrivere questo articolo è costruita esattamente così, e il piano editoriale del blog che stai leggendo ci gira sopra da agosto.
Faccio software da ventisei anni, quasi sempre su sistemi dove i dati contano più del codice: gestionali, software industriale, piattaforme che qualcuno deve poter difendere davanti a un cliente o a un revisore. L'event sourcing l'ho visto risolvere problemi che nessun'altra scelta risolveva e l'ho visto complicare la vita a squadre che non ne avevano bisogno. Quindi qui trovi tutte e due le facce, e la seconda è la parte che nessuno scrive perché richiede di averlo messo in produzione.
Una precisazione subito, perché è il malinteso da cui parte metà delle discussioni: l'event sourcing non è un log di audit. Un log si aggiunge al sistema e può divergere da esso, perché è una scrittura in più che qualcuno può dimenticare. Qui gli eventi sono il sistema: non c'è nessun altro posto dove i dati vivono, e quindi non c'è nessun modo di essere disallineati.
Cos'è l'event sourcing: invece dello stato, salvi i fatti che l'hanno prodotto
La tua banca non salva il saldo. Salva i movimenti. Il saldo che vedi in home banking è la somma dei movimenti, calcolata nel momento in cui apri la pagina.
Sembra un dettaglio contabile ed è invece la definizione completa dell'event sourcing. Se salvi i fatti invece dello stato, lo stato lo ricavi sempre, quando ti serve. E siccome i fatti restano tutti, non ricavi soltanto lo stato di adesso: ricavi anche tutti quelli intermedi, senza aver fatto niente di particolare per conservarli.
Prova a ribaltare la domanda. Perché una banca non salva semplicemente un numero e lo aggiorna? Perché il numero da solo è indifendibile. Se il saldo di un cliente passa da mille a settecento e l'unica cosa che il sistema conosce è settecento, non c'è modo di dire se sono stati prelevati trecento euro, se qualcuno ha sbagliato a digitare, o se c'è stato un errore di calcolo. Con i movimenti, invece, ogni cifra ha una riga che la spiega e una firma che la attribuisce.
L'event sourcing prende quell'idea, che in contabilità ha cinquecento anni, e la applica a un pezzo qualunque di un sistema informatico. Al prezzo di un prodotto. Allo stato di una pratica. Alla configurazione di un impianto. Alla scheda di un articolo del blog.
Cosa cambia concretamente nel database
In un sistema normale una modifica è una riga riscritta. Il prezzo del prodotto vale centoventi, e quella è tutta la verità disponibile. Se qualcuno chiede quanto valeva a marzo, la risposta onesta è che non si sa.
In un sistema in event sourcing quella stessa modifica è una riga aggiunta. La tabella dei prodotti, nel senso in cui la conosci, non viene più scritta a mano da nessuno: c'è una tabella di eventi dove compaiono, in ordine, «creato con prezzo cento», «ribassato a centotrenta», «corretto a centoventi», ognuno con chi l'ha fatto, quando e, se glielo si chiede, perché. Il prezzo di adesso è quello che risulta dopo aver letto tutti e tre.
Da questa differenza minuscola discende tutto il resto: la storia completa senza tabelle di storico, il confronto fra due momenti qualunque senza copie, il controllo delle modifiche simultanee quasi gratis, e la possibilità di rispondere a domande che nessuno aveva previsto quando il sistema è stato scritto. Perché i fatti restano, e su fatti che restano puoi fare domande nuove.

Le tre parole che servono, e nessuna in più
Il vocabolario di questo pattern è pieno di termini che spaventano senza aggiungere niente. Ne servono tre.
L'evento è un fatto già successo, scritto al passato: «prezzo cambiato», non «cambia prezzo». La differenza non è stilistica. Un fatto accaduto non si discute e non si annulla: se hai sbagliato, aggiungi il fatto della correzione, e restano tutti e due. È esattamente quello che fa un commercialista con una nota di credito.
L'aggregato è la cosa di cui l'evento parla: quel prodotto, quella pratica, quella scheda. È l'unità dentro cui l'ordine degli eventi conta e fuori dalla quale non conta. Il termine arriva dal domain driven design, ed è l'unico prestito che serve da lì.
La proiezione è una tabella di lettura costruita rileggendo gli eventi. Serve per fare le query normali senza rileggere la storia ogni volta. È una comodità, non la verità: si può buttare e ricostruire, ed è la parte che di solito capisci davvero solo quando la butti la prima volta e ti accorgi che non è successo niente.
Event sourcing e CQRS: sono due cose diverse e si possono usare separate
Vengono nominati insieme talmente spesso che molti li considerano un pacchetto unico. Non lo sono, e confonderli è il modo più rapido per prendere una decisione sbagliata su entrambi.
CQRS dice una cosa sola: le operazioni che scrivono e quelle che leggono possono avere modelli diversi. Non dice niente su come i dati vengono salvati. Puoi fare CQRS con un normalissimo database relazionale aggiornato con UPDATE, e in effetti quasi tutti quelli che fanno CQRS lo fanno così. Se è la parte che ti interessa, l'ho trattata in il pattern CQRS e, dal lato pratico di come introdurlo in un sistema che gira già, in far evolvere un'architettura con CQRS.
L'event sourcing dice un'altra cosa sola: lo stato non si salva, si salvano i fatti. Non dice niente su come organizzi le letture. Puoi ricostruire lo stato e servirlo dallo stesso identico modello che usi per scrivere, senza nessuna separazione.
Detto questo, si trovano spesso insieme per una ragione pratica: se salvi solo eventi, prima o poi ti serve una tabella su cui fare le query normali, e quella tabella è di fatto un modello di lettura separato. Cioè arrivi a CQRS partendo dall'event sourcing, non il contrario. La direzione conta, perché spiega perché la maggior parte dei progetti che «fanno CQRS» non ha nessun bisogno di event sourcing, mentre quasi tutti quelli che fanno event sourcing finiscono per avere due modelli.
E non è nemmeno l'architettura a eventi, né il pattern outbox
Vale la pena chiudere anche le altre due sovrapposizioni, perché nelle riunioni si mescolano di continuo e portano a decisioni sbagliate in tutte e due le direzioni.
L'architettura a eventi riguarda il modo in cui parti diverse di un sistema si parlano: un componente annuncia che è successo qualcosa e altri reagiscono, senza chiamarsi direttamente. È una questione di comunicazione fra servizi. L'event sourcing è una questione di come un singolo componente conserva i suoi dati al proprio interno. Puoi avere l'uno senza l'altro in entrambe le direzioni: un monolite che salva tutto in eventi e non manda un messaggio a nessuno è event sourcing puro senza un grammo di architettura a eventi; una decina di servizi che si scambiano messaggi su una coda mentre ognuno fa i suoi UPDATE è il contrario esatto.
La confusione nasce dalla parola «evento», che nei due contesti indica cose diverse. Nell'event sourcing l'evento è un dato salvato per sempre, la cui forma è affar tuo e che nessuno fuori vede. Nell'architettura a eventi è un messaggio pubblicato verso l'esterno, la cui forma diventa un contratto con altre squadre e che quindi non puoi più cambiare a piacimento. Confonderli porta all'errore più costoso di tutti: pubblicare all'esterno gli eventi interni, e ritrovarsi con la struttura del proprio database congelata dal fatto che altri sistemi ci dipendono. Se il tuo sistema fa entrambe le cose, tieni due famiglie di eventi separate, anche a costo di scrivere un pezzo di codice che traduce.
Il pattern outbox risolve un problema ancora diverso: come mandare un messaggio fuori e salvare un dato dentro senza rischiare che una delle due cose avvenga e l'altra no. Si scrive il messaggio in una tabella nella stessa transazione del dato, e un processo separato lo spedisce. È un pattern di consegna affidabile, e se hai già l'event sourcing quella tabella spesso non serve, perché l'archivio degli eventi può fare da coda per il processo che spedisce. Ma i due si scelgono per ragioni diverse: l'outbox lo si prende quando il problema è la consegna, l'event sourcing quando il problema è la memoria.
Event sourcing in C#: l'archivio degli eventi, il riduttore e la proiezione
Le parti sono tre, e in un sistema fatto bene stanno in poche centinaia di righe. Quello che segue è codice C# che compila, ridotto ai campi essenziali per stare nell'articolo: nel sistema vero ci sono qualche colonna in più e la gestione degli errori, non concetti in più.
1. L'archivio dei fatti
Una tabella sola, su cui si fa solo INSERT. Niente UPDATE, niente DELETE: non per disciplina, ma perché non esiste nessun caso d'uso che li richieda.
CREATE TABLE Eventi (
Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
Aggregato VARCHAR(100) NOT NULL, -- di chi parla il fatto
Numero INT NOT NULL, -- 1, 2, 3... dentro l'aggregato
Tipo VARCHAR(100) NOT NULL, -- articolo.prezzo.cambiato
Contenuto NVARCHAR(MAX) NOT NULL, -- il fatto, in JSON
Autore NVARCHAR(200) NULL, -- chi
Motivo NVARCHAR(500) NULL, -- perché, se lo dichiara
QuandoUtc DATETIME2 NOT NULL,
CONSTRAINT UQ_Eventi_Aggregato_Numero UNIQUE (Aggregato, Numero)
);
CREATE INDEX IX_Eventi_Aggregato_Numero ON Eventi (Aggregato, Numero);Due righe di questo schema valgono più di tutte le altre, e sono le ultime due.
Il vincolo di unicità su aggregato più numero è quello che impedisce a due modifiche contemporanee di sovrascriversi. Ci torno fra poco, perché è il pezzo che nella maggior parte degli articoli su questo tema non compare proprio.
L'indice sulle stesse due colonne è la ragione per cui ricostruire uno stato costa millisecondi anche quando la tabella ha milioni di righe. La lettura che fai sempre è «dammi gli eventi di questo aggregato in ordine di numero», e con quell'indice il database non tocca niente che non ti serva.
Il campo Motivo sembra un di più e non lo è. È lo spazio dove chi modifica scrive perché lo sta facendo, e in un sistema dove le decisioni contano vale più di metà del resto: sei mesi dopo, la domanda vera non è quasi mai «cosa è cambiato», è «perché».
2. Il riduttore
Una funzione che prende uno stato e un evento e restituisce lo stato successivo. Nient'altro: niente accessi al database, niente chiamate a servizi, niente orologio. Deve essere pura, e la ragione è pratica: la stessa funzione va usata sia quando si scrive sia quando si ricostruisce la storia.
public sealed record Evento(
Guid Id, string Aggregato, int Numero, string Tipo,
string Contenuto, string? Autore, string? Motivo, DateTime QuandoUtc);
public sealed record StatoArticolo(
string Aggregato, string Titolo, decimal Prezzo,
string Stato, int Versione, DateTime AggiornatoUtc)
{
public static StatoArticolo Vuoto(string aggregato) =>
new(aggregato, "", 0m, "inesistente", 0, default);
}
public static class Riduttore
{
public static StatoArticolo Applica(StatoArticolo stato, Evento evento)
{
StatoArticolo dopo;
switch (evento.Tipo)
{
case "articolo.creato":
var creato = Leggi<Creato>(evento);
dopo = stato with { Titolo = creato.Titolo, Prezzo = creato.Prezzo, Stato = "bozza" };
break;
case "articolo.prezzo.cambiato":
dopo = stato with { Prezzo = Leggi<PrezzoCambiato>(evento).Dopo };
break;
case "articolo.pubblicato":
dopo = stato with { Stato = "pubblicato" };
break;
default:
dopo = stato;
break;
}
return dopo with { Versione = evento.Numero, AggiornatoUtc = evento.QuandoUtc };
}
private static T Leggi<T>(Evento evento) =>
JsonSerializer.Deserialize<T>(evento.Contenuto)
?? throw new InvalidOperationException($"Evento {evento.Numero} illeggibile.");
}Nota il default del switch: uno stato che non conosce un tipo di evento lo ignora invece di esplodere. Serve quando aggiungerai tipi nuovi e il codice vecchio dovrà comunque poter rileggere la storia.
Nota anche l'ultima riga: la versione dello stato è il numero dell'ultimo evento applicato. Non c'è nessun contatore separato da tenere allineato, e questo elimina in partenza un'intera categoria di bug.
L'errore più comune, in assoluto, è avere due funzioni: una che aggiorna la tabella quando si scrive e una che ricostruisce quando si legge la storia. Il giorno che divergono, e divergono, il sistema comincia a raccontare due storie diverse dello stesso dato, e trovare quale delle due sia sbagliata costa più che riscrivere il tutto. Una funzione sola, usata in tutti e due i posti: è la regola più importante di questo articolo.
3. La scrittura e la ricostruzione
public async Task<int> Emetti(
string aggregato, string tipo, object contenuto,
string? autore, string? motivo, int? versioneAttesa = null)
{
var corrente = await db.Eventi
.Where(e => e.Aggregato == aggregato)
.MaxAsync(e => (int?)e.Numero) ?? 0;
if (versioneAttesa is not null && versioneAttesa != corrente)
throw new ConflittoDiVersione(aggregato, corrente, versioneAttesa.Value);
var evento = new Evento(
Guid.NewGuid(), aggregato, corrente + 1, tipo,
JsonSerializer.Serialize(contenuto), autore, motivo, DateTime.UtcNow);
db.Eventi.Add(evento);
await db.SaveChangesAsync();
return evento.Numero;
}
public async Task<StatoArticolo?> Ricostruisci(string aggregato, int? finoA = null)
{
var eventi = await db.Eventi
.Where(e => e.Aggregato == aggregato && (finoA == null || e.Numero <= finoA))
.OrderBy(e => e.Numero)
.ToListAsync();
if (eventi.Count == 0) return null;
var stato = StatoArticolo.Vuoto(aggregato);
foreach (var evento in eventi) stato = Riduttore.Applica(stato, evento);
return stato;
}Il parametro finoA di Ricostruisci è tre caratteri di codice ed è il motivo per cui esiste la sezione successiva di questo articolo. Senza, ottieni lo stato di adesso. Con, ottieni lo stato che l'aggregato aveva a quella versione. Non c'è nient'altro da scrivere per avere il viaggio nel tempo: è una conseguenza della struttura, non una funzionalità aggiunta.
Il controllo delle modifiche contemporanee, in tre righe
Questo è il punto che vale da solo il cambio di approccio, e quasi nessuno lo racconta.
In un sistema normale, se Anna e Luca aprono la stessa scheda e la salvano a trenta secondi di distanza, il lavoro di Anna sparisce. Non c'è nessun errore, nessun avviso: l'UPDATE di Luca vince e nessuno saprà mai che c'era qualcos'altro. È il bug che nei gestionali viene segnalato come «ogni tanto le modifiche si perdono» e che non si riesce mai a riprodurre.
Qui, chi scrive dichiara da quale versione parte. Se nel frattempo ne è arrivata un'altra, Emetti si ferma e restituisce un errore comprensibile invece di cancellare il lavoro dell'altro. E se le due scritture arrivano nello stesso istante, così vicine che il controllo in memoria le lascia passare entrambe, il vincolo di unicità sul database fa da giudice finale: due righe con lo stesso aggregato e lo stesso numero non possono coesistere, quindi la seconda viene respinta dal motore.
Sono un vincolo di tabella e un parametro. In un sistema tradizionale la stessa garanzia richiede una colonna di versione su ogni tabella, la sua gestione in ogni UPDATE e la disciplina di non dimenticarla mai. Qui è strutturale: non puoi dimenticarla, perché senza numero progressivo il pattern non funziona proprio.

4. La proiezione
Dopo ogni scrittura si riscrive una riga in una tabella normale, con le colonne che servono per le query di tutti i giorni. È l'unica parte del sistema che assomiglia a quello che facevi prima, e infatti su di essa continui a fare SELECT, ordinamenti, filtri e pagine di elenco come hai sempre fatto.
La regola da tenere presente è una sola: la proiezione non è la verità. Se la perdi, la ricostruisci rileggendo gli eventi. Se cambi idea sulle colonne, la butti e la rifai con le colonne nuove, e i dati storici ci sono già tutti perché non li avevi mai buttati. Nel sistema di cui parlo fra un attimo c'è un comando che rifà tutte le proiezioni da zero, e serve a due cose: alle migrazioni di struttura, e a dimostrare che l'archivio dei fatti è davvero la fonte e non una copia di cortesia.
Il vantaggio che nessuno racconta: il confronto fra due versioni ti viene gratis
Chi presenta l'event sourcing parla di verificabilità, di storico, di conformità. Sono cose vere e sono la parte noiosa. Il guadagno che cambia davvero il modo di lavorare è un altro, e si vede solo dopo averlo messo in produzione.
Siccome ogni stato passato si può ricostruire, tutte le funzioni che confrontano due momenti non vanno scritte: esistono già.
Prendi una richiesta che in qualunque progetto arriva prima o poi: «vorrei vedere cosa è cambiato in questa scheda fra marzo e oggi». In un sistema normale è un progetto. Serve una tabella di versioni, serve decidere quando scattare una copia, serve gestire lo spazio che quelle copie occupano, serve scrivere il confronto campo per campo, e serve accorgersi sei mesi dopo che le copie sono state fatte solo su alcune modifiche e non su altre.
Qui la stessa richiesta è: ricostruisci due volte fermandoti in due punti diversi, e guarda dove le due fotografie non coincidono.
public async Task<IReadOnlyList<Differenza>> Confronta(string aggregato, int da, int a)
{
var prima = await Ricostruisci(aggregato, da);
var dopo = await Ricostruisci(aggregato, a)
?? throw new InvalidOperationException($"L'aggregato {aggregato} non esiste.");
var differenze = new List<Differenza>();
foreach (var campo in typeof(StatoArticolo).GetProperties())
{
var valorePrima = prima is null ? null : campo.GetValue(prima);
var valoreDopo = campo.GetValue(dopo);
if (Equals(valorePrima, valoreDopo)) continue;
differenze.Add(new Differenza(campo.Name, valorePrima, valoreDopo));
}
return differenze;
}Venti righe, e non c'è una sola riga di codice dedicata al versionamento in tutto il resto del sistema. Nessuna tabella di storico, nessuna copia, nessuna decisione su quando scattare la fotografia. Il confronto funziona fra due versioni qualunque, comprese quelle che nessuno aveva previsto di dover confrontare quando il codice è stato scritto.
Il caso reale: la Fabbrica dei contenuti di questo blog
Questo articolo esiste perché una scheda in un'applicazione diceva che andava scritto. Quell'applicazione la uso per decidere di cosa parlare sul blog: tiene le ricerche con il loro volume, il costo per click, la posizione attuale del sito, l'angolo da usare e il prompt di scrittura. Si chiama la Fabbrica dei contenuti e sta dentro il monitor SEO che ho costruito per il mio sito.
È fatta esattamente come ho descritto sopra. C'è una tabella forge_events con le stesse colonne dello schema di prima e lo stesso vincolo di unicità su aggregato più numero. Ci sono due proiezioni, una per la scheda e una per le lingue, entrambe ricostruibili. C'è un riduttore che è una funzione sola, usata sia quando si salva sia quando si ricostruisce. E c'è il confronto scritto sopra.
Il risultato pratico, dopo qualche mese di uso vero, è questo. La scheda di questo articolo è arrivata alla settima versione: è nata da una ricerca fatta sui dati di Search Console e del Keyword Planner, poi le sono stati cambiati l'angolo, i titoli delle sezioni, le prove da usare, le immagini da produrre e il prompt di scrittura. Ogni passaggio ha il suo autore, che a volte è una persona e a volte è un modello, ha la sua data e ha il motivo dichiarato. Posso chiedere all'applicazione cosa è cambiato fra la seconda e la settima versione e ottengo l'elenco dei campi con il prima e il dopo.
Non ho scritto una riga di codice per ottenere questo. È venuto dalla struttura. E qui c'è il punto che vale la pena portare a casa: il vantaggio dell'event sourcing non si misura sulle funzioni che avevi previsto, si misura su quelle che non avevi previsto. Il giorno che ho voluto sapere quali schede erano state cambiate da un modello e quali da me, la risposta era già nei dati, perché la colonna che dice con che cappello ha agito l'autore c'era dal primo giorno e nessuno l'aveva mai sovrascritta.
C'è anche un beneficio meno tecnico e più concreto, per chi come me lavora da solo o in squadre piccole: la storia rende reversibili le decisioni prese in fretta. Se cambio l'angolo di un articolo alle undici di sera e la mattina dopo mi accorgo che quello di prima era migliore, non devo ricordarmelo. È scritto, con il motivo che avevo dichiarato in quel momento, e recuperarlo costa una schermata.
Il livello successivo: rileggere la storia con una logica cambiata
C'è una conseguenza di tutto questo che si vede solo dopo un po', ed è la più potente. Siccome lo stato non è salvato ma calcolato, se cambi il riduttore cambi anche il passato.
Detta così suona come una minaccia, e va gestita con attenzione. Ma è la cosa che nei sistemi normali non si può proprio fare. Prendi una regola di calcolo: le provvigioni degli agenti, il punteggio di priorità di una pratica, la classificazione di un cliente in una fascia. In un sistema tradizionale quel numero è stato calcolato al momento e salvato, e se un anno dopo la direzione chiede «con la regola nuova, come sarebbero andati gli ultimi due anni?», la risposta è che bisognerebbe avere i dati grezzi di allora. Che non ci sono, perché è stato salvato il risultato.
Con i fatti conservati, quella domanda è un'esecuzione: rileggi la storia con il riduttore nuovo e ottieni i numeri che sarebbero usciti. Non è una simulazione approssimata su dati aggregati, sono i valori esatti calcolati sugli stessi input di allora. Ho visto discussioni su cambi di regole commerciali andare avanti per settimane, per la sola ragione che nessuno poteva dire quanto sarebbe costata la regola nuova.
La cautela da avere è una, e va scritta nel repository: lo stato ricostruito con il riduttore di oggi non è il numero che il sistema mostrò allora. Sono due cose diverse e in certi contesti la differenza è delicata. Quando serve conservare anche il valore mostrato in quel momento, per esempio su un documento fiscale che è stato emesso, quel valore va scritto dentro l'evento come un fatto a sé: «fattura emessa con imponibile X». Così hai tutte e due le cose, il numero che fu e il numero che sarebbe, e la distinzione è esplicita invece che nascosta.
Event sourcing e database: dove finiscono gli eventi e quanto pesano
Questa è la domanda che blocca più progetti sul nascere, e quasi sempre per una risposta sbagliata: «serve un database specializzato».
Non serve. SQL Server va benissimo. PostgreSQL va benissimo. SQLite va benissimo, e lo dico con cognizione perché la Fabbrica di cui sopra gira su Cloudflare D1, che è SQLite. Esistono database costruiti apposta per gli eventi e hanno il loro perché quando i volumi diventano seri o quando servono sottoscrizioni in tempo reale a valle, ma la loro assenza non è una scusa valida per non iniziare. La tabella di prima, con il suo indice, regge molto più di quanto la maggior parte dei progetti italiani vedrà mai.
I numeri, con le loro condizioni
Diciamolo con le cifre, perché è l'unica parte che poi devi difendere in una riunione.
Un evento tipico, con il contenuto in JSON, occupa fra i 200 byte e i 2 KB. La forbice dipende quasi tutta da quanto grande è il pezzo di dato che l'evento porta con sé: un cambio di stato sono poche decine di byte, un evento che porta dentro un testo lungo può arrivare a qualche KB. A titolo di ordine di grandezza, un milione di eventi da 1 KB sono un gigabyte, ed è una quantità che qualunque database su hardware normale gestisce senza accorgersene.
La ricostruzione di un aggregato con cinquanta eventi costa pochi millisecondi se l'indice c'è. È dominata dalla lettura sequenziale dell'indice, non dal calcolo: il riduttore è una funzione in memoria e su cinquanta iterazioni non si misura.
Il numero che conta per le decisioni è quello degli eventi per aggregato, non il totale della tabella. Un archivio con dieci milioni di eventi distribuiti su centomila aggregati si comporta benissimo, perché ogni lettura ne tocca cento. Un archivio con centomila eventi su dieci aggregati soli, invece, è un problema, perché ogni lettura ne tocca diecimila.

Le fotografie intermedie, e quando smettere di rimandarle
La fotografia intermedia è una riga che dice «alla versione 500, lo stato era questo», salvata di tanto in tanto, che permette di non ripartire sempre dal primo evento.
La domanda giusta non è se farle: è quando. E la risposta è più tardi di quanto tutti pensino. Sotto qualche centinaio di eventi per aggregato non servono, e implementarle prima significa aggiungere una parte da mantenere e un'altra cosa che può disallinearsi, per risolvere un problema che non hai. La soglia pratica è semplice: quando la ricostruzione di un singolo aggregato comincia a farsi sentire nei tempi di risposta, e non prima.
Quando le farai, l'unica cosa da tenere presente è che una fotografia è una copia e quindi può sbagliare. Va marcata con la versione a cui si riferisce e con la versione del riduttore che l'ha prodotta, così che un cambiamento della logica la invalidi invece di renderla una bugia silenziosa. Ed è per questo che il comando «ricostruisci tutto da zero» va scritto il primo giorno: è la rete di sicurezza che rende le fotografie una ottimizzazione e non un rischio.
Il cambiamento della forma degli eventi
Un giorno vorrai aggiungere un campo a un tipo di evento, e nell'archivio ci saranno diecimila eventi vecchi che quel campo non ce l'hanno. Gli eventi già scritti non si toccano: è la regola, e non ha eccezioni.
Le uscite sono due e sono entrambe semplici. La prima: il campo nuovo è opzionale e il riduttore, quando non lo trova, usa un valore ragionevole. Copre la stragrande maggioranza dei casi. La seconda: quando il cambiamento è così profondo che il vecchio evento non è più interpretabile, si introduce un tipo nuovo, per esempio articolo.prezzo.cambiato.v2, e il riduttore gestisce entrambi. Sono un paio di righe in più nel switch e restano lì per sempre, il che è il prezzo onesto da pagare per non aver riscritto la storia.
Se ti sembra sgradevole tenere codice che gestisce forme vecchie, considera l'alternativa: sistemi in cui i dati vecchi sono stati «migrati» e nessuno può più dire con certezza cosa dicessero prima della migrazione. Anche quelli hanno un costo, solo che si paga più tardi e non lo paga chi ha preso la decisione.
I tre casi in cui l'event sourcing è la scelta sbagliata
Questa è la parte che manca in quasi tutti gli articoli su questo tema, e manca per una ragione: si scrive solo dopo aver pagato il conto almeno una volta.
1. Anagrafiche che nessuno interrogherà mai sulla loro storia
La tabella delle province. L'elenco delle aliquote IVA. I dati di contatto di un fornitore. Un elenco di codici a cui si aggiunge una riga ogni tanto.
Su questi dati l'event sourcing è complessità pagata per niente. Nessuno chiederà mai chi ha cambiato il CAP di Verona e perché, e se un giorno lo chiedesse, la risposta si può ricavare da un banalissimo log di modifica. Ci vuole un UPDATE.
La prova del nove è una domanda sola, da farsi prima di scrivere qualunque riga: qualcuno, un giorno, avrà bisogno di sapere com'era prima? Se la risposta onesta è no, hai finito, e la risposta onesta è no molto più spesso di quanto un architetto entusiasta sia disposto ad ammettere. In un sistema sano gli aggregati in event sourcing sono pochi: due, tre, cinque. Se ne hai quaranta, quasi certamente hai applicato il pattern a tutto invece che a quello che lo meritava.
2. La squadra non lo ha mai visto e nessuno lo presidia
Questo è il motivo di fallimento più frequente, e non è un problema tecnico.
Un event sourcing fatto male è nettamente peggio di un CRUD fatto bene. Le cose che vanno storte sono sempre le stesse: il riduttore che diventa due funzioni diverse; qualcuno che aggiunge un UPDATE «solo per sistemare un dato sbagliato» e da quel momento l'archivio non è più la fonte; eventi scritti al futuro, cioè comandi travestiti da fatti; la proiezione trattata come verità e quindi mai più ricostruita, finché si scopre che diverge e nessuno sa da quando.
Nessuno di questi è un errore da principianti: sono tutti errori ragionevoli commessi da bravi sviluppatori che non hanno mai lavorato con questo pattern. Servono una persona che lo presidi e una regola scritta in tre righe nel repository, e se non ci sono, la risposta giusta è aspettare. Nel frattempo, un'architettura a strati fatta con criterio porta a casa il novanta per cento del valore con un decimo del rischio: se ti trovi in questa situazione, clean architecture in C# è il posto da cui partire.
3. C'è l'obbligo di cancellare i dati personali
Qui il conflitto è reale e va guardato in faccia, invece di essere liquidato con «tanto poi si risolve».
Il GDPR riconosce il diritto alla cancellazione dei dati personali. L'event sourcing si basa su un archivio a cui non si toglie niente. Se gli eventi contengono nome, email o altri dati riferibili a una persona, la richiesta di cancellazione non ha una risposta comoda: cancellare gli eventi rompe la ricostruzione di tutto quello che viene dopo, e modificarli rompe la premessa su cui il sistema si regge.
La tecnica che funziona si chiama chiave buttata. I dati personali dentro l'evento non si scrivono in chiaro: si cifrano con una chiave diversa per ogni persona, tenuta in una tabella a parte. Quando arriva la richiesta di cancellazione si elimina la chiave. Gli eventi restano tutti al loro posto, il numero progressivo non ha buchi, la ricostruzione continua a funzionare, e i campi personali diventano definitivamente illeggibili per chiunque, incluso chi ha scritto il sistema.
È una soluzione solida e va detto che aggiunge lavoro: la gestione delle chiavi, la cifratura e decifratura nel percorso di lettura, la classificazione di quali campi sono personali, e il fatto che uno stato ricostruito dopo la cancellazione avrà dei buchi che il codice a valle deve saper gestire. È un costo accettabile in un sistema dove l'event sourcing serve davvero. È un costo assurdo in un sistema dove ci si è finiti per moda.
La regola pratica che uso: i dati personali stanno fuori dagli eventi tutte le volte che è possibile. L'evento porta l'identificativo della persona, i suoi dati stanno in una tabella normale con le regole normali, e il problema non si presenta.
Da dove partire senza rifare tutto: un aggregato solo, quello che fa più male
Se sei arrivato fin qui probabilmente hai in testa un sistema che gira già, con anni di dati dentro e persone che ci lavorano tutti i giorni. La buona notizia è che non devi convertirlo. Anzi, non devi proprio provarci.
L'event sourcing convive senza problemi con il resto del sistema, perché è una scelta che si prende per aggregato e non per applicazione. Puoi avere una tabella di eventi per i prezzi e centoventi tabelle normali per tutto il resto, e le due cose non si accorgono l'una dell'altra. Non c'è nessuna migrazione grande da pianificare, nessun cambio di database, nessuna riscrittura.
Come si sceglie il primo
Il criterio è uno e non è tecnico: l'entità su cui vi chiedono più spesso «chi ha cambiato cosa, e quando».
Se lavori su un gestionale, quasi sempre è il prezzo o la condizione commerciale. Su una piattaforma di pratiche è lo stato della pratica. Su un configuratore è la configurazione. Su un sistema industriale è il parametro di ricetta o di impianto, dove peraltro la sequenza degli eventi è già il dato primario e il passaggio è più naturale che altrove. Se non riesci a nominare quell'entità in dieci secondi, prendilo come un segnale: forse questo pattern non ti serve, e va benissimo così.
Il secondo criterio, subordinato al primo, è che sia un'entità piccola. Pochi campi, poche transizioni. Non prendere l'ordine con le sue quaranta colonne e le sue righe: prendi lo stato dell'ordine. Il primo aggregato serve a imparare, e si impara meglio su qualcosa che si tiene tutto in una schermata.
Le prime tre cose da scrivere, in questo ordine
Uno. La tabella degli eventi con il suo vincolo di unicità e il suo indice, e la funzione che scrive un evento controllando la versione attesa. Sono le due pagine di codice della sezione precedente e non cambieranno più.
Due. Il riduttore, con i tre o quattro tipi di evento che ti servono davvero. Scrivilo come funzione pura e mettici sopra dei test che non toccano il database: dato uno stato e un elenco di eventi, ti aspetti questo stato. Sono i test più veloci e più stabili che avrai in tutto il progetto, perché non c'è niente da simulare.
Tre. La proiezione e il comando che la ricostruisce da zero. Il comando scrivilo subito, anche se non ti serve: è quello che ti permetterà di cambiare idea sulle colonne senza paura, ed è la dimostrazione, per te e per chi guarderà il sistema dopo, che l'archivio dei fatti è la fonte.
Quello che non va scritto il primo giorno: le fotografie intermedie, un bus di messaggi, le sottoscrizioni agli eventi, un database specializzato. Sono tutte cose che si aggiungono quando un numero le richiede, e prima di allora sono solo superficie da mantenere.
Se ti interessa la mappa completa di dove questo pattern si colloca rispetto agli altri, e quando conviene una scelta diversa, l'ho messa in gli altri pattern architetturali.
Il punto, in breve
L'event sourcing non è un modo migliore di fare le cose: è un modo diverso, che conviene su una parte piccola di un sistema e non conviene su tutto il resto. Salvi i fatti invece dello stato, e lo stato lo ricavi rileggendoli. Le parti sono tre, stanno in poche centinaia di righe, e girano su un database relazionale qualunque.
Quello che ottieni non è soprattutto la conformità o la tranquillità di non perdere i dati, anche se le ottieni. È che una categoria intera di richieste future smette di essere un progetto e diventa una query: cosa è cambiato fra due momenti, chi lo ha cambiato, con quale motivo, e come sarebbe andata se ci fossimo fermati prima. Sono domande che nei sistemi normali si affrontano una alla volta, a caro prezzo, quando qualcuno le fa.
Quello che paghi è una disciplina: una funzione sola per applicare gli eventi, nessun UPDATE mai, la proiezione trattata come una comodità e non come la verità, e i dati personali tenuti fuori dagli eventi o cifrati con una chiave che si può buttare. Se in squadra non c'è nessuno che presidia queste quattro regole, aspetta: un CRUD fatto bene è meglio di un event sourcing fatto male, e questa non è una battuta.
Se la parte che ti interessa è imparare a prendere decisioni di questo tipo con un metodo, invece che pattern per pattern, è esattamente il mestiere su cui è costruito il corso da architetto software.
E se dovessi tenere una sola frase di tutto l'articolo, questa: l'event sourcing non serve a non perdere i dati. Serve a non perdere le decisioni.
Domande frequenti
È il modo di salvare i dati che la tua banca usa da sempre: non conserva il saldo, conserva i movimenti, e il saldo è la somma. Applicato al software vuol dire che una modifica non riscrive una riga, ne aggiunge una nuova che dice cosa è successo, chi lo ha fatto, quando e perché. Lo stato di adesso si ricava rileggendo i fatti in ordine. La conseguenza pratica è che tutti gli stati intermedi restano disponibili senza aver fatto niente di speciale per conservarli, e che la domanda «chi ha cambiato questo, e quando» ha sempre una risposta.
No, e sono indipendenti. CQRS dice solo che le operazioni di scrittura e quelle di lettura possono usare modelli diversi, e non dice niente su come i dati vengono salvati: la maggior parte di chi fa CQRS usa un database relazionale con normalissimi UPDATE. L'event sourcing dice solo che si salvano i fatti invece dello stato, e non dice niente su come organizzi le letture. Si trovano spesso insieme perché chi salva solo eventi prima o poi ha bisogno di una tabella su cui fare le query normali, e quella tabella è di fatto un modello di lettura separato.
No. SQL Server, PostgreSQL e perfino SQLite vanno benissimo: serve una tabella su cui si fa solo INSERT, con un vincolo di unicità su aggregato più numero progressivo e un indice sulle stesse due colonne. I database costruiti apposta per gli eventi hanno senso quando i volumi diventano seri o quando servono sottoscrizioni in tempo reale, ma la loro assenza non è una ragione per non iniziare. La Fabbrica dei contenuti del mio monitor SEO gira su SQLite e regge senza problemi.
Un evento tipico occupa fra 200 byte e 2 KB a seconda di quanto grande è il dato che porta con sé: un milione di eventi da 1 KB è un gigabyte. La ricostruzione di un aggregato con cinquanta eventi costa pochi millisecondi se l'indice su aggregato e numero c'è. Il numero che conta per le decisioni non è il totale della tabella ma quanti eventi ha ogni singolo aggregato: dieci milioni di eventi su centomila aggregati vanno benissimo, centomila eventi su dieci aggregati soli no.
Con la tecnica della chiave buttata. I dati personali dentro gli eventi non si scrivono in chiaro: si cifrano con una chiave diversa per ogni persona, tenuta in una tabella a parte. Quando arriva la richiesta di cancellazione si elimina la chiave: gli eventi restano al loro posto, i numeri progressivi non hanno buchi, la ricostruzione continua a funzionare e i campi personali diventano illeggibili per chiunque. Aggiunge lavoro vero, quindi la regola pratica migliore resta tenere i dati personali fuori dagli eventi tutte le volte che è possibile, lasciando dentro solo l'identificativo della persona.
In tre casi. Sulle anagrafiche di cui nessuno chiederà mai la storia, come le province o le aliquote, dove è complessità pagata per niente e basta un UPDATE. Quando la squadra non ha mai visto il pattern e nessuno lo presidia, perché un event sourcing fatto male è peggio di un CRUD fatto bene. E quando ci sono obblighi di cancellazione di dati personali che non si riescono a tenere fuori dagli eventi, dove la soluzione esiste ma costa lavoro in più. In un sistema sano gli aggregati in event sourcing sono due, tre, cinque: se ne hai quaranta hai applicato il pattern a tutto invece che a quello che lo meritava.
No, e non dovresti nemmeno provarci. L'event sourcing è una scelta che si prende per singolo aggregato, non per applicazione: puoi avere una tabella di eventi per i prezzi e centoventi tabelle normali per tutto il resto, e le due cose non si accorgono l'una dell'altra. Il primo aggregato da convertire è quello su cui vi chiedono più spesso chi ha cambiato cosa, e conviene che sia piccolo, con pochi campi e poche transizioni, perché serve a imparare. Non c'è nessuna migrazione grande da pianificare e nessun cambio di database.
