Un acquisto, tre servizi, nessuna transazione che li copra
In un monolite l'acquisto è una transazione: crei l'ordine, scali lo stock, registri il pagamento, e se qualcosa va storto
il database annulla tutto. Con tre servizi, ciascuno col suo database, un BEGIN ... COMMIT che li attraversa non
esiste. Il protocollo classico per farlo è il two-phase commit: un coordinatore chiede a tutti di prepararsi, poi ordina il
commit. Funziona, ma tiene bloccate le risorse dei partecipanti finché il coordinatore decide, e li vuole tutti
raggiungibili nello stesso momento: due condizioni che nei servizi indipendenti, con API di terzi nel mezzo, non puoi
garantire.
La saga rinuncia all'atomicità globale e la sostituisce con una sequenza di transazioni locali, ciascuna confermata subito, più per ognuna una transazione compensativa che ne annulla l'effetto di business se un passo successivo fallisce. Il concetto viene da Hector Garcia-Molina e Kenneth Salem, che lo proposero nel 1987 per le transazioni di lunga durata nei database.
Il prezzo è che per un po' il sistema è visibilmente a metà: lo stock è scalato e il pagamento non c'è ancora. Questo articolo costruisce la saga di un acquisto in due modi, con un orchestratore e con una coreografia di eventi, li esegue sui quattro casi che contano (successo, rifiuto, errore temporaneo, compensazione che fallisce) e dice dove ciascun modello fa male. Presuppone il vocabolario di eventi e comandi.
I tre servizi della prova e la chiave di idempotenza
Le tre classi seguenti sono i partecipanti: Inventario, Pagamenti e Ordini, in memoria. Ogni operazione che modifica qualcosa riceve una chiave di idempotenza (id della saga più nome del passo) e ricorda quelle già eseguite: riceverne due volte la stessa non ripete l'effetto. È la ragione per cui, più avanti, una compensazione consegnata due volte non rilascia lo stock due volte.
Le due manopole transitori e rifiuta distinguono un errore temporaneo (il
gateway va in timeout, vale la pena riprovare) da un rifiuto definitivo (carta respinta, riprovare non serve): la distinzione
decide se si ritenta o si compensa. nonRaggiungibile fa fallire il rilascio dello stock, per provare che cosa
accade quando è la compensazione a non riuscire.
export class ErroreTransitorio extends Error {}
// Ogni operazione porta una chiave di idempotenza: ripeterla non ripete l'effetto.
export class Inventario {
stock = new Map<string, number>([['penna', 5]]);
private fatte = new Set<string>();
nonRaggiungibile = 0; // numero di chiamate a rilascia() che falliranno prima di funzionare
riserva(chiave: string, prodotto: string, q: number): void {
if (this.fatte.has(chiave)) return;
const disponibili = this.stock.get(prodotto) ?? 0;
if (disponibili < q) throw new Error('InventarioInsufficiente');
this.stock.set(prodotto, disponibili - q);
this.fatte.add(chiave);
}
rilascia(chiave: string, prodotto: string, q: number): void {
if (this.nonRaggiungibile > 0) { this.nonRaggiungibile--; throw new ErroreTransitorio('Inventario non raggiungibile'); }
if (this.fatte.has(chiave)) return;
this.stock.set(prodotto, (this.stock.get(prodotto) ?? 0) + q);
this.fatte.add(chiave);
}
}
export class Pagamenti {
transitori = 0; // fallimenti temporanei prima del successo
rifiuta = false; // rifiuto definitivo (carta respinta)
incassi = 0;
private fatte = new Set<string>();
paga(chiave: string): void {
if (this.fatte.has(chiave)) return;
if (this.transitori > 0) { this.transitori--; throw new ErroreTransitorio('Gateway pagamenti in timeout'); }
if (this.rifiuta) throw new Error('PagamentoRifiutato');
this.incassi++;
this.fatte.add(chiave);
}
}
export class Ordini {
stati = new Map<string, string>();
imposta(id: string, stato: string): void { this.stati.set(id, stato); }
}
Questo modulo non ha un output proprio: lo esercitano i due programmi seguenti. Una nota di realismo: la memoria delle chiavi
già viste è un Set in RAM, quindi si perde al riavvio. In produzione la chiave si salva nello stesso database
e nella stessa transazione dell'effetto, altrimenti l'idempotenza protegge solo finché il processo non muore.
Orchestrazione: un solo posto sa a che punto è la saga
Nell'orchestrazione un componente centrale conosce la sequenza dei passi, li invoca uno a uno e, se uno fallisce, esegue le compensazioni dei passi già completati in ordine inverso. L'ordine inverso non è una convenzione: la compensazione di un passo può dipendere dal fatto che quelli successivi siano già stati disfatti.
Nel programma,
eseguiSaga tiene la lista dei passi fatti e, al primo errore, la percorre al contrario saltando i passi senza
compensazione (qui paga, che se fallisce non ha lasciato nulla da disfare, e conferma ordine, che
è l'ultimo). conRetry ritenta solo gli ErroreTransitorio e raddoppia l'attesa a ogni tentativo; l'attesa è
scritta nel log ma non eseguita, per non rallentare la prova. Gli altri errori passano direttamente alla compensazione.
import { Inventario, Pagamenti, Ordini, ErroreTransitorio } from './servizi.ts';
type Passo = { nome: string; esegui: () => void; compensa?: () => void; tentativi?: number };
type Esito = 'COMPLETATA' | 'COMPENSATA' | 'BLOCCATA';
// Ripete solo gli errori transitori, con attesa raddoppiata (qui scritta nel log, non aspettata).
function conRetry(nome: string, f: () => void, max: number, traccia: string[]): void {
let attesa = 2;
for (let t = 1; ; t++) {
try { f(); return; } catch (e) {
if (!(e instanceof ErroreTransitorio) || t >= max) throw e;
traccia.push(` ${nome}: tentativo ${t} fallito, riprovo dopo ${attesa} s`);
attesa *= 2;
}
}
}
function eseguiSaga(id: string, passi: Passo[], traccia: string[]): Esito {
const fatti: Passo[] = [];
for (const p of passi) {
try {
conRetry(p.nome, p.esegui, p.tentativi ?? 1, traccia);
traccia.push(`ok ${p.nome}`);
fatti.push(p);
} catch (e) {
traccia.push(`FALLITO ${p.nome}: ${(e as Error).message}`);
// Compensazione: i soli passi completati, in ordine inverso.
for (const c of [...fatti].reverse()) {
if (!c.compensa) continue;
try { conRetry(`compensa ${c.nome}`, c.compensa, 3, traccia); traccia.push(`compensato ${c.nome}`); }
catch (e2) { traccia.push(`BLOCCATA su compensa ${c.nome}: ${(e2 as Error).message}`); return 'BLOCCATA'; }
}
return 'COMPENSATA';
}
}
return 'COMPLETATA';
}
function scenario(titolo: string, prepara: (inv: Inventario, pag: Pagamenti) => void) {
const inv = new Inventario(), pag = new Pagamenti(), ord = new Ordini();
prepara(inv, pag);
const id = 'saga-1';
const passi: Passo[] = [
{ nome: 'crea ordine', esegui: () => ord.imposta(id, 'PENDING'), compensa: () => ord.imposta(id, 'ANNULLATO') },
{ nome: 'riserva stock', esegui: () => inv.riserva(`${id}:riserva`, 'penna', 2), compensa: () => inv.rilascia(`${id}:rilascia`, 'penna', 2) },
{ nome: 'paga', esegui: () => pag.paga(`${id}:paga`), tentativi: 3 },
{ nome: 'conferma ordine', esegui: () => ord.imposta(id, 'CONFERMATO') },
];
const traccia: string[] = [];
const esito = eseguiSaga(id, passi, traccia);
console.log(`== ${titolo}\n${traccia.join('\n')}\nesito ${esito} | ordine ${ord.stati.get(id)} | stock penna ${inv.stock.get('penna')} | incassi ${pag.incassi}`);
}
scenario('tutto bene', () => {});
scenario('pagamento rifiutato', (_i, p) => { p.rifiuta = true; });
scenario('pagamento: due timeout, poi ok', (_i, p) => { p.transitori = 2; });
scenario('rifiuto + inventario irraggiungibile una volta', (i, p) => { p.rifiuta = true; i.nonRaggiungibile = 1; });
scenario('rifiuto + inventario irraggiungibile sempre', (i, p) => { p.rifiuta = true; i.nonRaggiungibile = 99; });
// La stessa compensazione consegnata due volte non rilascia due volte.
const inv = new Inventario();
inv.riserva('x:riserva', 'penna', 2);
inv.rilascia('x:rilascia', 'penna', 2);
inv.rilascia('x:rilascia', 'penna', 2);
console.log('== compensazione ripetuta | stock penna', inv.stock.get('penna'), '(5 all\'inizio)');
// Output:
// == tutto bene
// ok crea ordine
// ok riserva stock
// ok paga
// ok conferma ordine
// esito COMPLETATA | ordine CONFERMATO | stock penna 3 | incassi 1
// == pagamento rifiutato
// ok crea ordine
// ok riserva stock
// FALLITO paga: PagamentoRifiutato
// compensato riserva stock
// compensato crea ordine
// esito COMPENSATA | ordine ANNULLATO | stock penna 5 | incassi 0
// == pagamento: due timeout, poi ok
// ok crea ordine
// ok riserva stock
// paga: tentativo 1 fallito, riprovo dopo 2 s
// paga: tentativo 2 fallito, riprovo dopo 4 s
// ok paga
// ok conferma ordine
// esito COMPLETATA | ordine CONFERMATO | stock penna 3 | incassi 1
// == rifiuto + inventario irraggiungibile una volta
// ok crea ordine
// ok riserva stock
// FALLITO paga: PagamentoRifiutato
// compensa riserva stock: tentativo 1 fallito, riprovo dopo 2 s
// compensato riserva stock
// compensato crea ordine
// esito COMPENSATA | ordine ANNULLATO | stock penna 5 | incassi 0
// == rifiuto + inventario irraggiungibile sempre
// ok crea ordine
// ok riserva stock
// FALLITO paga: PagamentoRifiutato
// compensa riserva stock: tentativo 1 fallito, riprovo dopo 2 s
// compensa riserva stock: tentativo 2 fallito, riprovo dopo 4 s
// BLOCCATA su compensa riserva stock: Inventario non raggiungibile
// esito BLOCCATA | ordine PENDING | stock penna 3 | incassi 0
// == compensazione ripetuta | stock penna 5 (5 all'inizio)
Nel caso del rifiuto l'uscita mostra l'ordine corretto: prima si rilascia lo stock, poi si annulla
l'ordine, e alla fine lo stock è tornato a 5, l'ordine è ANNULLATO e gli incassi sono zero. Nel caso dei
due timeout il pagamento riesce al terzo tentativo: se avessi compensato al primo errore avresti annullato
un acquisto che sarebbe andato a buon fine.
L'ultimo caso è quello da ricordare: quando anche il rilascio dello stock fallisce
per tre volte, la saga finisce in BLOCCATA, con l'ordine ancora PENDING e due penne tolte dal magazzino
per un acquisto che non esisterà. Nessun meccanismo automatico risolve questa situazione: serve uno stato esplicito della saga
che un allarme possa leggere, e un intervento umano o una coda di ritentativi lunghi. L'ultima riga mostra l'idempotenza:
chiamare due volte la stessa compensazione lascia lo stock a 5.
Coreografia: nessuno sa l'intera storia
Nella coreografia non c'è un orchestratore. Ogni servizio ascolta gli eventi che lo riguardano, fa il proprio
passo e ne pubblica uno nuovo; la compensazione è un altro ascolto (qui Inventario e Ordini reagiscono a
PagamentoFallito). Il bus del programma è una coda e una mappa di ascoltatori, con la traccia di chi ha
ricevuto che cosa.
import { Inventario, Pagamenti, Ordini } from './servizi.ts';
type Evento = { tipo: string; sagaId: string };
const inv = new Inventario(), pag = new Pagamenti(), ord = new Ordini();
pag.rifiuta = true; // il pagamento verra' rifiutato
// Bus minimo: una coda e, per ogni tipo di evento, i servizi che lo ascoltano.
const coda: Evento[] = [];
const ascoltatori = new Map<string, ((e: Evento) => void)[]>();
const traccia: string[] = [];
const ascolta = (tipo: string, servizio: string, f: (e: Evento) => void) =>
ascoltatori.set(tipo, [...(ascoltatori.get(tipo) ?? []), (e) => { traccia.push(`${e.tipo.padEnd(20)} -> ${servizio}`); f(e); }]);
const pubblica = (tipo: string, sagaId: string) => coda.push({ tipo, sagaId });
// Ognuno conosce solo il proprio passo e gli eventi a cui reagisce.
ascolta('OrdineCreato', 'inventario', (e) => {
try { inv.riserva(`${e.sagaId}:riserva`, 'penna', 2); pubblica('InventarioRiservato', e.sagaId); }
catch { pubblica('InventarioNonDisponibile', e.sagaId); }
});
ascolta('InventarioRiservato', 'pagamenti', (e) => {
try { pag.paga(`${e.sagaId}:paga`); pubblica('PagamentoConfermato', e.sagaId); }
catch { pubblica('PagamentoFallito', e.sagaId); }
});
ascolta('PagamentoConfermato', 'ordini', (e) => ord.imposta(e.sagaId, 'CONFERMATO'));
ascolta('PagamentoFallito', 'inventario', (e) => inv.rilascia(`${e.sagaId}:rilascia`, 'penna', 2)); // compensa
ascolta('PagamentoFallito', 'ordini', (e) => ord.imposta(e.sagaId, 'ANNULLATO')); // compensa
ascolta('InventarioNonDisponibile', 'ordini', (e) => ord.imposta(e.sagaId, 'ANNULLATO'));
ord.imposta('saga-1', 'PENDING');
pubblica('OrdineCreato', 'saga-1');
for (let e = coda.shift(); e; e = coda.shift()) (ascoltatori.get(e.tipo) ?? []).forEach((f) => f(e));
console.log(traccia.join('\n'));
console.log(`ordine ${ord.stati.get('saga-1')} | stock penna ${inv.stock.get('penna')} | incassi ${pag.incassi}`);
// Output:
// OrdineCreato -> inventario
// InventarioRiservato -> pagamenti
// PagamentoFallito -> inventario
// PagamentoFallito -> ordini
// ordine ANNULLATO | stock penna 5 | incassi 0
La traccia è l'unica prova che la saga è esistita: dice che l'ordine, partito da OrdineCreato, ha attraversato
inventario e pagamenti e che il fallimento è stato ascoltato da due servizi. Questa leggibilità è finta, perché l'ho prodotta io
con un push sulla stessa lista: in un sistema vero la traccia non esiste, e per sapere a che punto è una saga devi
raccogliere gli eventi di più servizi con un identificativo di correlazione (sagaId).
Nessun servizio vede l'intero percorso, nessun punto singolo può fermarsi, e proprio per questo un ciclo di eventi o un passo dimenticato si scoprono tardi. Su un broker vero cambia anche un altro fatto: gli ascoltatori sono consumatori indipendenti, e la consegna è almeno una volta, quindi la chiave di idempotenza della sezione precedente non è facoltativa.
Una compensazione non è un rollback
Un rollback fa sparire l'effetto come se non fosse mai accaduto; una compensazione ne produce uno nuovo che lo neutralizza dal punto di vista del business. Lo stock rilasciato torna disponibile, ma qualcuno potrebbe averlo visto esaurito nel frattempo; un pagamento incassato si rimborsa, ma il cliente vede due movimenti; una mail di conferma già partita non si ritira, si manda un'altra.
Per questo il lavoro di progetto sta nel sapere, per ogni passo, quale sia la sua compensazione e se esista.
Quando non esiste (l'invio della mail), il passo va messo dopo quelli che possono fallire, non prima. La
compensazione deve poter essere ripetuta senza danni, e deve poter riuscire sempre: se può fallire a sua volta, la saga ha
bisogno dello stato BLOCCATA di cui sopra.
Orchestrazione su un servizio gestito: Step Functions
I prodotti gestiti incorporano gli stessi due meccanismi. In AWS Step Functions il flusso si scrive come macchina a stati in
Amazon States Language: un passo Task può avere un blocco Retry per gli errori temporanei e un
blocco Catch che indirizza un errore definitivo al passo di compensazione. Il frammento sotto è lo schema
equivalente del pagamento della prova. Non è stato eseguito (richiede un account e delle funzioni Lambda): è «schema da
adattare», scritto sulla documentazione.
{
"StartAt": "ProcessaPagamento",
"States": {
"ProcessaPagamento": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-west-1:111122223333:function:processa-pagamento",
"Retry": [
{ "ErrorEquals": ["TransientPaymentError"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 }
],
"Catch": [
{ "ErrorEquals": ["PagamentoRifiutato"], "Next": "RilasciaInventario" }
],
"Next": "ConfermaOrdine"
},
"RilasciaInventario": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:rilascia-inventario", "Next": "AnnullaOrdine" },
"AnnullaOrdine": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:annulla-ordine", "Next": "SagaFallita" },
"SagaFallita": { "Type": "Fail", "Error": "SagaFailed" },
"ConfermaOrdine": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:conferma-ordine", "End": true }
}
}
Il Retry riproduce conRetry: la documentazione definisce IntervalSeconds (attesa prima
del primo tentativo, 1 per default), MaxAttempts (3 per default) e BackoffRate (moltiplicatore
dell'attesa, 2 per default). Qui i valori sono scritti esplicitamente per non dipendere dai default. Il Catch manda
PagamentoRifiutato verso RilasciaInventario, poi AnnullaOrdine, poi uno stato
Fail: la catena di compensazioni qui è scritta a mano nel grafo, non derivata automaticamente. Il vantaggio rispetto
al codice è che lo stato di ogni esecuzione è visibile nel servizio; il costo è un vincolo a quella piattaforma.
Quale scegliere
| Aspetto | Coreografia | Orchestrazione |
|---|---|---|
| Dove vive la logica | Distribuita nei servizi | Concentrata nell'orchestratore |
| Stato della saga | Da ricostruire dagli eventi | Esplicito in un punto |
| Accoppiamento | Ognuno conosce gli eventi altrui | L'orchestratore conosce tutti i servizi |
| Aggiungere un passo | Si aggiunge un ascoltatore | Si modifica l'orchestratore |
| Rischio tipico | Cicli e passi dimenticati | Orchestratore troppo grosso |
La regola pratica è il numero di passi. Con due o tre servizi e team indipendenti la coreografia è leggera e basta; oltre quattro passi, o quando serve sapere in ogni momento dov'è la saga, l'orchestrazione costa meno di quanto fa risparmiare la sua visibilità. Chris Richardson, che ha catalogato il pattern, descrive entrambe le coordinazioni e rimanda all'event sourcing e all'outbox transazionale come modi per aggiornare lo stato e pubblicare i messaggi in modo atomico.
Questo è il punto debole che i programmi sopra non risolvono: il servizio che salva e poi pubblica può morire a metà, ed è il tema del pattern outbox. I ritentativi e le compensazioni che non riescono si gestiscono con la dead letter queue.
Che cosa sai fare adesso
Sai spiegare perché una transazione distribuita diventa una sequenza di passi con compensazioni, sai implementare un orchestratore che compensa in ordine inverso e ritenta solo gli errori temporanei, e sai riconoscere lo stato in cui la saga si blocca. Sai anche che le chiavi di idempotenza sono il prerequisito di tutto, e perché la scelta tra coreografia e orchestrazione si decide sul numero di passi e sulla visibilità richiesta. Per le basi di come i servizi si scambiano gli eventi, vedi il confronto tra SQS, SNS ed EventBridge. Se gli eventi della saga finiscono in un event store, la ricostruzione delle viste è in Event Sourcing + CQRS; per ciò che l'utente vede mentre la saga è a metà, vedi la consistenza eventuale.
Fonti
- Hector Garcia-Molina, Kenneth Salem, Sagas (ACM SIGMOD 1987) - l'origine del concetto di saga e delle transazioni compensative per transazioni di lunga durata, copia ospitata dal corso CS 711 della Cornell University, consultata il 2026-11-12
- Chris Richardson, Pattern: Saga - le due coordinazioni (coreografia e orchestrazione) e i pattern correlati (event sourcing, transactional outbox), consultata il 2026-11-12
- AWS Step Functions, Handling errors in workflows - campi
RetryeCatch, e valori di default diIntervalSeconds,MaxAttempts,BackoffRate, consultata il 2026-11-12
I due programmi (orchestrazione.ts e coreografia.ts, con il modulo servizi.ts) sono stati eseguiti con Node.js v24.11.0; gli output nei commenti sono quelli reali. Il frammento Step Functions non è stato eseguito (schema da adattare). I broker veri non sono coinvolti: bus, coda e chiavi di idempotenza sono in memoria.

Commenti
Caricamento commenti...
Accedi per commentare