Il saldo non è un numero: è la somma di ciò che è successo
Il saldo del tuo conto corrente non è un valore che la banca sovrascrive a ogni operazione: è il risultato di tutti i movimenti
mai registrati. Se vuoi sapere quanto avevi alle 14:00 di ieri, la banca prende i movimenti fino a quell'ora e li somma.
L'Event Sourcing applica la stessa idea al software: invece di salvare lo stato corrente di un'entità e
aggiornarlo con un UPDATE, salvi la sequenza degli eventi che l'hanno prodotta, e lo stato lo ricavi rileggendoli.
Il vantaggio è che non perdi mai la storia: sai come si è arrivati a un ordine in quello stato, puoi ricostruirlo com'era a un'ora qualsiasi, e puoi correggere un errore aggiungendo un evento invece di riscrivere il passato.
Il prezzo è che leggere lo stato costa un calcolo, che gli eventi vecchi devono restare leggibili per sempre e che le interrogazioni complesse richiedono un secondo modello di lettura (il tema dell'articolo su CQRS). Ti serve aver letto i fondamenti sugli eventi di dominio. Qui costruiamo un piccolo ordine con gli eventi, lo salviamo in un event store su SQLite, lo ricostruiamo a un'ora passata e proviamo a scriverlo in due contemporaneamente.
CRUD ed event sourcing a confronto
| Aspetto | CRUD classico | Event sourcing |
|---|---|---|
| Che cosa si salva | Lo stato corrente, sovrascritto | La sequenza di eventi, solo in aggiunta |
| Storia | Persa, salvo tabelle di audit separate | Intrinseca |
| Stato a un istante passato | Non disponibile | Si rilegge la sequenza fino a quell'istante |
| Lettura dello stato | Una query | Un calcolo sugli eventi, o una proiezione |
| Interrogazioni complesse | Dirette sulle tabelle | Richiedono un modello di lettura separato |
| Correzione di un errore | Si modifica il dato | Si aggiunge un evento compensativo |
Nella tabella, la riga che decide è l'ultima. Nel CRUD correggere un dato lo cancella; in event sourcing l'errore resta nella storia con la sua correzione accanto, ed è la ragione per cui il modello è diffuso dove un revisore deve poter ricostruire cosa è successo: finanza, sanità, logistica.
Dove nessuno ha mai chiesto «com'era ieri?», il modello aggiunge solo complessità. Martin Fowler descrive il modello come la garanzia che tutte le modifiche dello stato applicativo siano memorizzate come una sequenza di eventi; l'Azure Architecture Center ne elenca i costi, tra cui la necessità di snapshot per le sequenze lunghe.
Aggregato: decidere e applicare sono due funzioni diverse
Un aggregato è il confine entro cui le regole di business devono valere sempre: un ordine non si conferma se è vuoto, non si modifica se è già confermato. In event sourcing l'aggregato si scompone in due operazioni distinte. La decisione guarda lo stato corrente e un comando e produce uno o più eventi, oppure rifiuta.
L'evoluzione prende lo stato e un evento e produce il nuovo stato, senza controlli: un evento è un fatto, e non si discute. Tenerle separate ha una conseguenza pratica: quando rileggi la storia applichi solo l'evoluzione, quindi un evento registrato cinque anni fa non viene rivalutato con le regole di oggi.
export type OrderEvent =
| { type: 'OrderCreated'; orderId: string; customerId: string }
| { type: 'ItemAdded'; productId: string; quantity: number; unitPrice: number }
| { type: 'ItemRemoved'; productId: string }
| { type: 'OrderConfirmed' }
| { type: 'OrderCancelled'; reason: string };
export interface OrderState {
id: string;
customerId: string;
items: Record<string, { quantity: number; unitPrice: number }>;
status: 'Draft' | 'Confirmed' | 'Cancelled';
total: number;
}
function totalOf(items: OrderState['items']): number {
return Object.values(items).reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
}
// Funzione pura: (stato, evento) -> nuovo stato. Non valida nulla, non legge l'orologio.
export function evolve(state: OrderState | null, e: OrderEvent): OrderState {
switch (e.type) {
case 'OrderCreated':
return { id: e.orderId, customerId: e.customerId, items: {}, status: 'Draft', total: 0 };
case 'ItemAdded': {
const items = { ...state!.items, [e.productId]: { quantity: e.quantity, unitPrice: e.unitPrice } };
return { ...state!, items, total: totalOf(items) };
}
case 'ItemRemoved': {
const { [e.productId]: _removed, ...items } = state!.items;
return { ...state!, items, total: totalOf(items) };
}
case 'OrderConfirmed':
return { ...state!, status: 'Confirmed' };
case 'OrderCancelled':
return { ...state!, status: 'Cancelled' };
}
}
export function replay(events: OrderEvent[], from: OrderState | null = null): OrderState {
return events.reduce(evolve, from) as OrderState;
}
// Le decisioni: guardano lo stato e restituiscono eventi, oppure rifiutano.
export function addItem(state: OrderState, productId: string, quantity: number, unitPrice: number): OrderEvent[] {
if (state.status !== 'Draft') throw new Error('Non si aggiungono articoli a un ordine non in bozza');
if (quantity <= 0) throw new Error('Quantita non valida');
return [{ type: 'ItemAdded', productId, quantity, unitPrice }];
}
export function confirm(state: OrderState): OrderEvent[] {
if (state.status !== 'Draft') throw new Error('Si confermano solo ordini in bozza');
if (Object.keys(state.items).length === 0) throw new Error('Ordine vuoto');
return [{ type: 'OrderConfirmed' }];
}
evolve è una funzione pura: non legge l'orologio, non lancia eccezioni, restituisce uno stato nuovo con lo spread
invece di modificare quello vecchio. Per questo il replay è deterministico: la stessa sequenza dà sempre lo stesso stato.
replay è un reduce su evolve. Le funzioni addItem e confirm
sono le decisioni: se lo stato non le consente lanciano un errore e non producono eventi.
Si noti che il totale non è un campo
che si somma a ogni evento, ma si ricalcola dalla mappa degli articoli: un evento ItemAdded per un prodotto già
presente ne sostituisce la riga, e va tenuto presente quando scegli la semantica dei tuoi eventi (qui l'evento dice
«la riga di questo prodotto vale ora così»).
L'event store: una tabella dove si scrive solo in coda
Un event store è un database che sa fare poche cose bene: aggiungere eventi a uno stream (la
sequenza di un aggregato, qui Order-789) e rileggerli in ordine. Non aggiorna e non cancella. La parte delicata è la
concorrenza: due operatori possono leggere l'ordine alla versione 4 e decidere entrambi di modificarlo. Se il secondo scrive
senza controlli, la sua decisione si basa su uno stato che non esiste più.
La soluzione è la concorrenza
ottimistica: chi scrive dichiara la versione che ha letto, e l'append fallisce se nel frattempo lo stream è andato
avanti. Il listato usa SQLite, incluso in Node 24 come modulo sperimentale node:sqlite, al posto di PostgreSQL:
la logica è la stessa, cambiano il tipo dell'errore e il modo in cui il database serializza le scritture.
import { DatabaseSync } from 'node:sqlite';
import type { OrderEvent } from './order.ts';
export class ConcurrencyError extends Error {}
export class EventStore {
private db = new DatabaseSync(':memory:');
constructor() {
this.db.exec(`CREATE TABLE event_store (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stream_id TEXT NOT NULL,
version INTEGER NOT NULL,
event_type TEXT NOT NULL,
event_data TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (stream_id, version)
)`);
}
// Accoda eventi allo stream, ma solo se l'ultima versione e' quella che il chiamante ha letto.
append(streamId: string, expectedVersion: number, events: OrderEvent[], at: string): number {
this.db.exec('BEGIN IMMEDIATE');
try {
const row = this.db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM event_store WHERE stream_id = ?').get(streamId) as { v: number };
if (row.v !== expectedVersion) {
throw new ConcurrencyError(`${streamId}: atteso v${expectedVersion}, trovato v${row.v}`);
}
const insert = this.db.prepare('INSERT INTO event_store (stream_id, version, event_type, event_data, created_at) VALUES (?, ?, ?, ?, ?)');
events.forEach((e, i) => insert.run(streamId, expectedVersion + i + 1, e.type, JSON.stringify(e), at));
this.db.exec('COMMIT');
return expectedVersion + events.length;
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
}
load(streamId: string, opts: { afterVersion?: number; until?: string } = {}): { version: number; event: OrderEvent }[] {
const rows = this.db
.prepare('SELECT version, event_data FROM event_store WHERE stream_id = ? AND version > ? AND created_at <= ? ORDER BY version')
.all(streamId, opts.afterVersion ?? 0, opts.until ?? '9999') as { version: number; event_data: string }[];
return rows.map((r) => ({ version: r.version, event: JSON.parse(r.event_data) as OrderEvent }));
}
}
BEGIN IMMEDIATE prende subito il lock di scrittura di SQLite (la documentazione dice che parte una scrittura
senza aspettare la prima istruzione), così il controllo di MAX(version) e gli INSERT sono atomici
rispetto agli altri scrittori. Se la versione trovata non coincide con quella attesa lancia ConcurrencyError e il
ROLLBACK annulla tutto.
Il vincolo UNIQUE (stream_id, version) è una seconda difesa che vale anche
se qualcuno scrive saltando il controllo. Su PostgreSQL non c'è BEGIN IMMEDIATE: si lascia fallire l'INSERT
sul vincolo e si traduce l'errore (codice SQLSTATE 23505, violazione di unicità) in un conflitto. In entrambi i casi
il chiamante deve poter ricaricare lo stream e rifare la decisione.
Prova: ricostruire, tornare indietro, scrivere in due
Il programma seguente crea un ordine, aggiunge due articoli, lo conferma e poi mostra le tre cose che un event store deve saper fare. Ogni append porta l'istante in cui è avvenuto, passato esplicitamente, in modo che il risultato sia ripetibile.
import { addItem, confirm, replay } from './order.ts';
import { EventStore, ConcurrencyError } from './store.ts';
const store = new EventStore();
const stream = 'Order-789';
// Cronologia: ogni append porta l'istante in cui e' avvenuto.
let v = store.append(stream, 0, [{ type: 'OrderCreated', orderId: '789', customerId: 'cli-3' }], '2026-11-12T09:00:00Z');
let state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, addItem(state, 'penna', 2, 3.5), '2026-11-12T09:05:00Z');
state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, addItem(state, 'quaderno', 1, 4.0), '2026-11-12T09:10:00Z');
state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, confirm(state), '2026-11-12T09:20:00Z');
console.log('versione dello stream:', v);
const finale = replay(store.load(stream).map((r) => r.event));
console.log('stato attuale :', finale.status, finale.total, Object.keys(finale.items).join(','));
// Time travel: tutti gli eventi con created_at fino alle 09:07.
const alle0907 = replay(store.load(stream, { until: '2026-11-12T09:07:00Z' }).map((r) => r.event));
console.log('stato alle 09:07:', alle0907.status, alle0907.total, Object.keys(alle0907.items).join(','));
// Concorrenza ottimistica: due operatori leggono v4, solo il primo scrive.
const letto = store.load(stream).at(-1)!.version;
try {
store.append(stream, letto, [{ type: 'OrderCancelled', reason: 'cliente' }], '2026-11-12T10:00:00Z');
store.append(stream, letto, [{ type: 'OrderCancelled', reason: 'errore' }], '2026-11-12T10:00:01Z');
} catch (e) {
console.log(e instanceof ConcurrencyError ? 'conflitto: ' + (e as Error).message : 'altro errore');
}
// Una decisione rifiutata non scrive nulla.
try {
addItem(finale, 'gomma', 1, 1);
} catch (e) {
console.log('rifiutato:', (e as Error).message);
}
// Output:
// versione dello stream: 4
// stato attuale : Confirmed 11 penna,quaderno
// stato alle 09:07: Draft 7 penna
// conflitto: Order-789: atteso v4, trovato v5
// rifiutato: Non si aggiungono articoli a un ordine non in bozza
La versione finale è 4 perché i quattro append hanno scritto un evento ciascuno. Lo stato attuale (Confirmed, 11
euro: due penne a 3,5 più un quaderno a 4) si ottiene rileggendo l'intero stream. La riga «alle 09:07» usa
until, che filtra su created_at: a quell'ora l'ordine aveva solo le penne, quindi 7 euro e ancora in
bozza.
Il conflitto è la riga più istruttiva: entrambi gli operatori hanno letto la versione 4, il primo ha scritto (portandola
a 5), il secondo si è visto rifiutare con atteso v4, trovato v5. L'ultima riga mostra che una decisione rifiutata
non scrive nulla. Su un database di produzione il time travel per istante richiede un indice su created_at; il
mio store non lo ha, e per uno stream di migliaia di eventi lo scorrimento sarebbe lineare.
Snapshot: non rileggere mille eventi per sapere il totale
Un aggregato con migliaia di eventi diventa costoso da caricare, perché ogni lettura applica tutta la storia. Lo snapshot è una fotografia dello stato a una certa versione dello stream: per ricostruire parti dalla fotografia e applichi solo gli eventi successivi. Non sostituisce gli eventi, che restano la fonte di verità; è una cache che puoi buttare e rifare. Il programma conta gli eventi applicati nei due modi su un ordine con mille articoli, e verifica che il risultato sia identico.
import { evolve, replay } from './order.ts';
import type { OrderEvent, OrderState } from './order.ts';
// Un ordine con una bozza lunghissima: 1 evento di creazione e 1000 aggiunte di articolo.
const eventi: OrderEvent[] = [{ type: 'OrderCreated', orderId: 'big', customerId: 'cli-1' }];
for (let i = 0; i < 1000; i++) eventi.push({ type: 'ItemAdded', productId: `p${i}`, quantity: 1, unitPrice: 2 });
let applicati = 0;
const conteggio = (s: OrderState | null, e: OrderEvent) => { applicati++; return evolve(s, e); };
// Senza snapshot: si riapplicano tutti gli eventi.
const pieno = eventi.reduce(conteggio, null as OrderState | null)!;
console.log('senza snapshot: eventi applicati =', applicati, '| totale =', pieno.total);
// Snapshot allo stream version 1001 (tutti gli eventi sopra), poi 5 eventi nuovi.
const snapshot = { version: 1001, state: replay(eventi) };
const nuovi: OrderEvent[] = Array.from({ length: 5 }, (_, i) => ({ type: 'ItemAdded', productId: `n${i}`, quantity: 1, unitPrice: 2 }));
applicati = 0;
const ripreso = nuovi.reduce(conteggio, snapshot.state as OrderState | null)!;
console.log('con snapshot : eventi applicati =', applicati, '| totale =', ripreso.total);
// Controllo di equivalenza: snapshot + coda == replay completo.
const completo = replay([...eventi, ...nuovi]);
console.log('stesso stato :', JSON.stringify(completo) === JSON.stringify(ripreso));
// Output:
// senza snapshot: eventi applicati = 1001 | totale = 2000
// con snapshot : eventi applicati = 5 | totale = 2010
// stesso stato : true
I numeri sono conteggi, non tempi, e quindi riproducibili: 1001 applicazioni di evolve contro 5. L'ultima
riga è il controllo che conta: uno snapshot sbagliato (per esempio salvato prima di una correzione) darebbe uno stato
diverso senza che nessuno se ne accorga, perciò in produzione conviene confrontare ogni tanto snapshot e replay completo. Il
momento in cui crearlo è una scelta: ogni N eventi è la più semplice, ma l'Azure Architecture Center indica proprio gli
intervalli di N eventi come tecnica comune. Non esiste un N giusto in assoluto; si sceglie misurando quanto costa un replay
sul tuo carico.
Quando event sourcing sì e quando no
Conviene quando la storia è un requisito del dominio e non un lusso. I casi in cui ripaga sono questi:
- un audit trail obbligatorio per norma o per contratto;
- domande retrospettive, come «quanti ordini erano in bozza alle 16:00?»;
- la necessità di capire come si è arrivati a uno stato anomalo, riproducendo la sequenza;
- un'architettura a eventi già presente, in cui gli eventi esistono comunque.
Il primo punto è l'unico che da solo giustifica il costo; gli altri sono benefici che si sommano ma che si ottengono spesso anche con una tabella di audit. Se il dominio è un'anagrafica senza obblighi di tracciamento, il CRUD è la scelta giusta: ricordati che ogni evento pubblicato diventa un contratto da mantenere (si veda il versionamento nell'articolo precedente) e che le letture complesse richiedono un modello a parte, di cui parlano CQRS e la sua combinazione con l'event sourcing.
Che cosa sai fare adesso
Sai scrivere un aggregato come coppia di funzioni, decisione ed evoluzione, e ricostruirlo rileggendo gli eventi. Sai progettare un event store con versione per stream e concorrenza ottimistica, sai fare una query a un istante passato e sai quando e come uno snapshot riduce il lavoro. Ti manca ciò che non è in questo articolo: la distribuzione degli eventi agli altri servizi, per cui serve un broker e il pattern outbox. Quando una vista costruita dagli eventi è in ritardo, l'utente vede dati vecchi: ne parla l'articolo sulla consistenza eventuale.
Fonti
- Martin Fowler, Event Sourcing - la definizione: tutte le modifiche allo stato memorizzate come sequenza di eventi, consultata il 2026-11-12
- Azure Architecture Center, Event Sourcing pattern - costi del modello, snapshot a intervalli di N eventi, event store dedicati con concorrenza ottimistica, consultata il 2026-11-12
- SQLite, BEGIN TRANSACTION - comportamento di
BEGIN IMMEDIATEe possibile errore SQLITE_BUSY, consultata il 2026-11-12
I tre programmi (es.ts, snapshot.ts e i moduli order.ts e store.ts che importano) sono stati eseguiti con Node.js v24.11.0; gli output nei commenti sono quelli reali. Il modulo node:sqlite è marcato sperimentale: su altre versioni di Node il comportamento può cambiare. I dettagli di PostgreSQL citati nel testo (codice 23505) non sono stati eseguiti.

Commenti
Caricamento commenti...
Accedi per commentare