Vai al contenuto

Pattern per dati e transazioni distribuite

Ogni microservizio ha i suoi dati. Questo rende i servizi indipendenti, ma crea un problema nuovo: una operazione di business tocca spesso più servizi, e non esiste una transazione unica che li copra tutti. I pattern di questa categoria servono a tenere i dati coerenti senza rinunciare all'autonomia dei servizi.

flowchart TD
    subgraph dati["🗄️ Chi possiede i dati"]
        dbps["Database per Service"]
    end
    subgraph eventi["📨 Pubblicare eventi senza perderli"]
        outbox["Transactional Outbox"]
        cdc["Transaction Log Tailing / CDC"]
        idem["Idempotent Consumer"]
    end
    subgraph tx["🔁 Operazioni su più servizi"]
        chor["Saga Coreografia"]
        orch["Saga Orchestrazione"]
        tpc["Two-Phase Commit (solo riferimento)"]
    end
    subgraph lettura["🔍 Letture e storia"]
        cqrs["CQRS"]
        es["Event Sourcing"]
    end
    dbps --> eventi
    eventi --> tx
    tx --> lettura
Pattern Problema che risolve Usalo quando
Database per Service Servizi accoppiati dallo stesso schema del database Vuoi rilasciare e scalare ogni servizio in modo indipendente
Transactional Outbox Scrivere sul DB e pubblicare un evento in modo atomico Un servizio deve notificare gli altri in modo affidabile
Idempotent Consumer Lo stesso messaggio arriva due volte e viene elaborato due volte Usi un broker con consegna at-least-once
Saga Coreografia Transazione di business su più servizi, senza coordinatore Pochi servizi coinvolti, flusso semplice, team autonomi
Saga Orchestrazione Transazione di business su più servizi, con flusso esplicito Flusso con molti passi, serve vedere lo stato in un punto solo
CQRS Un solo modello serve male sia scritture che letture Letture complesse o molto più frequenti delle scritture
Event Sourcing Perdi la storia dei cambiamenti dello stato Serve audit completo, replay o ricostruzione dello stato
Transaction Log Tailing CDC Pubblicare i cambiamenti del DB senza polling Hai Debezium o simile e vuoi latenza bassa
Two-Phase Commit Commit atomico su più database Quasi mai nei microservizi, solo in sistemi monolitici o legacy

Il problema di fondo

In un monolite una operazione come "crea ordine, scala il magazzino, addebita il pagamento" sta dentro una sola transazione ACID. Se un passo fallisce, il database annulla tutto. Nei microservizi ogni servizio ha il suo database, e nessuna transazione copre più servizi insieme. La coerenza non è più un regalo del database: va costruita.

Il caso più comune in cui si rompe la coerenza è il dual write: un servizio scrive sul suo database e poi pubblica un evento sul broker. Sono due sistemi diversi, quindi due scritture separate. Se la seconda fallisce, il database dice una cosa e il resto del sistema ne sa un'altra.

sequenceDiagram
    participant O as Servizio Ordini
    participant DB as Database Ordini
    participant B as Broker
    participant P as Servizio Pagamenti
    O->>DB: INSERT ordine 42
    DB-->>O: ok
    O--xB: publish OrdineCreato 42
    Note over O,B: il broker non risponde o il servizio va giù
    Note over P: Pagamenti non riceve nulla
    Note over DB,P: ordine 42 esiste ma nessuno lo paga

Invertire l'ordine non aiuta: se pubblichi prima e poi la scrittura sul database fallisce, gli altri servizi reagiscono a un ordine che non esiste. I pattern che seguono risolvono questo problema in modi diversi, con compromessi diversi fra semplicità, latenza e garanzie.

Database per Service

In una frase: ogni servizio possiede il suo database e nessun altro servizio ci accede direttamente.

Problema che risolve: se due servizi leggono e scrivono le stesse tabelle, ogni cambio di schema richiede di coordinare due team e due rilasci. Un servizio con una query lenta rallenta anche l'altro. Nei fatti i due servizi sono un monolite distribuito.

Come funziona:

flowchart LR
    ordini["📦 Servizio Ordini"] -->|"mio"| dbO[("🗄️ DB Ordini")]
    catalogo["🏷️ Servizio Catalogo"] -->|"mio"| dbC[("🗄️ DB Catalogo")]
    ordini -->|"chiedi prezzo via API"| catalogo
    ordini -.->|"🚫 VIETATO"| dbC
flowchart LR
    svcOrdini["Servizio Ordini"] --> dbOrdini[("DB Ordini")]
    svcPagamenti["Servizio Pagamenti"] --> dbPagamenti[("DB Pagamenti")]
    svcMagazzino["Servizio Magazzino"] --> dbMagazzino[("DB Magazzino")]
    svcOrdini -- "API o eventi" --> svcPagamenti
    svcOrdini -- "API o eventi" --> svcMagazzino

Ogni servizio ha un suo schema, un suo database o almeno tabelle private. Il servizio Ordini non legge la tabella stock del Magazzino: chiede al servizio Magazzino tramite API, oppure ascolta i suoi eventi e tiene una copia locale dei dati che gli servono. Il database può essere anche di tipo diverso: Catalogo può usare un database documentale, Pagamenti uno relazionale.

Quando usarlo: - Vuoi rilasciare un servizio senza coordinare lo schema con altri team. - I servizi hanno esigenze di scalabilità o di tipo di dato diverse. - È il punto di partenza di quasi tutti gli altri pattern di questo file.

Quando NON usarlo / rischi: - L'anti-pattern opposto è lo Shared Database: un solo database condiviso da tutti i servizi. Sembra comodo, perché hai transazioni ACID e join fra tabelle, ma accoppia i servizi in modo nascosto. Un ALTER TABLE può rompere un servizio che non conosci. È accettabile solo come passo temporaneo durante una migrazione da monolite. - Le query che univano tabelle di più domini diventano chiamate di rete o proiezioni (vedi CQRS). - Le transazioni fra servizi non esistono più: servono Saga e Transactional Outbox.

Esempio pratico: il servizio Catalogo tiene i prodotti con descrizione e prezzo in un database documentale. Il servizio Ordini, quando crea un ordine, salva una copia di nome e prezzo del prodotto nella riga dell'ordine. Se Catalogo cambia il prezzo domani, l'ordine di ieri resta corretto, e Ordini non ha bisogno di leggere il database di Catalogo.

Pattern correlati: Transactional Outbox, Saga Coreografia, CQRS, Strangler Fig.

Transactional Outbox

In una frase: salva l'evento nella stessa transazione del dato, in una tabella outbox, e un processo separato lo pubblica sul broker.

Problema che risolve: il dual write descritto sopra. Il servizio deve scrivere sul database e pubblicare un evento, ma non può farlo in modo atomico. Con l'outbox la pubblicazione diventa una conseguenza della scrittura, non una seconda operazione che può fallire da sola.

Come funziona:

Illustrazione: Ordini scrive ordine ed evento nella stessa transazione, il relay legge l'outbox e consegna al broker

sequenceDiagram
    participant O as Servizio Ordini
    participant DB as Database Ordini
    participant R as Relay
    participant B as Broker
    participant P as Servizio Pagamenti
    O->>DB: BEGIN
    O->>DB: INSERT ordini (42)
    O->>DB: INSERT outbox (OrdineCreato 42)
    O->>DB: COMMIT
    loop ogni N ms o via CDC
        R->>DB: leggi righe outbox non pubblicate
        DB-->>R: OrdineCreato 42
        R->>B: publish OrdineCreato 42
        B-->>R: ack
        R->>DB: marca come pubblicata o cancella
    end
    B->>P: OrdineCreato 42
  1. Il servizio apre una transazione, scrive l'ordine e scrive una riga nella tabella outbox con il payload dell'evento. Il commit è unico: o ci sono entrambe le righe, o nessuna.
  2. Un componente chiamato relay (o message relay) legge le righe dell'outbox e le pubblica sul broker.
  3. Dopo l'ack del broker, il relay marca la riga come pubblicata o la cancella.
  4. Se il relay muore fra la pubblicazione e la marcatura, al riavvio ripubblica lo stesso evento. La consegna è quindi at-least-once: i consumatori devono essere idempotenti.

Il relay ha due varianti:

  • Polling publisher: un processo interroga la tabella outbox a intervalli regolari (SELECT ... WHERE published = false ORDER BY id LIMIT 100). Semplice da scrivere, funziona con qualsiasi database. Il costo è una latenza pari all'intervallo di polling e un carico costante sul database.
  • Transaction log tailing (CDC): uno strumento come Debezium legge il log delle transazioni del database (WAL di PostgreSQL, binlog di MySQL) e trasforma ogni INSERT nella tabella outbox in un messaggio Kafka. Latenza di millisecondi, nessun carico di polling, ma serve configurare il database e un componente in più. È descritto come pattern a sé in Transaction Log Tailing / CDC.

Quando usarlo: - Ogni volta che un servizio scrive un dato e deve pubblicare un evento su quel dato. - Hai un database relazionale o comunque con transazioni locali. - Vuoi garanzie di consegna senza un broker transazionale.

Quando NON usarlo / rischi: - La tabella outbox cresce: serve una pulizia periodica delle righe pubblicate. - L'ordine degli eventi è garantito solo per righe dello stesso aggregato se il relay rispetta l'ordine di inserimento e usa la stessa partizione del broker. - Non serve se il tuo database è lo stesso broker (per esempio usi solo un event store), o se il dato e l'evento vivono già nello stesso sistema. - La consegna duplicata è possibile: senza Idempotent Consumer il pattern è incompleto.

Esempio pratico: il servizio Ordini riceve POST /ordini. In una sola transazione inserisce la riga in ordini e una riga in outbox con {"tipo": "OrdineCreato", "ordine_id": 42, "totale": 99.90}. Debezium legge il WAL, pubblica il messaggio sul topic ordini.eventi. Pagamenti lo riceve e avvia l'addebito. Se il broker era giù per dieci minuti, la riga resta in outbox e viene pubblicata quando il broker torna: nessun ordine perso.

Pattern correlati: Idempotent Consumer, Transaction Log Tailing / CDC, Saga Coreografia, Messaggistica asincrona.

Idempotent Consumer

In una frase: il consumatore riconosce i messaggi già elaborati tramite il loro id e li ignora.

Problema che risolve: i broker e l'outbox consegnano i messaggi almeno una volta. Dopo un riavvio o un timeout, lo stesso evento OrdineCreato 42 può arrivare due volte. Senza protezione, Pagamenti addebita il cliente due volte.

Come funziona:

flowchart LR
    coda[["📨 Broker"]]
    m1["✉️ OrdineCreato 42, id 9871"]
    m2["✉️ OrdineCreato 42, id 9871 (copia)"]
    pay["💳 Pagamenti"]
    reg[("📒 processed_messages")]
    carta["💶 Addebito una volta"]
    cestino["🗑️ Scartato"]
    coda --> m1 --> pay
    coda --> m2 --> pay
    pay -->|"id 9871 visto?"| reg
    pay -->|"no"| carta
    pay -->|"sì"| cestino
flowchart TD
    msg["Messaggio con message_id"] --> check{"message_id già in processed_messages?"}
    check -- "sì" --> skip["Ignora e manda ack"]
    check -- "no" --> tx["BEGIN transazione"]
    tx --> logic["Esegui la logica di business"]
    logic --> save["INSERT message_id in processed_messages"]
    save --> commit["COMMIT"]
    commit --> ack["Manda ack al broker"]
  1. Ogni messaggio porta un message_id univoco, assegnato dal produttore (per esempio l'id della riga outbox).
  2. Il consumatore tiene una tabella processed_messages nel proprio database.
  3. Prima di elaborare, controlla se l'id è già presente. Se sì, manda l'ack e non fa nulla.
  4. Se no, esegue la logica di business e inserisce l'id nella stessa transazione. Così, se il processo muore a metà, il messaggio non risulta elaborato e verrà ritentato.

Esiste una variante senza tabella: rendere la logica stessa idempotente, per esempio con UPSERT o con una chiave univoca sul dato (UNIQUE(ordine_id) sulla tabella pagamenti).

Quando usarlo: - Sempre, quando consumi messaggi da un broker con consegna at-least-once (Kafka, RabbitMQ, SQS). - Quando la logica ha effetti collaterali non ripetibili: addebiti, invio email, scalo di magazzino.

Quando NON usarlo / rischi: - Se l'operazione è già idempotente per natura (per esempio SET stato = 'spedito'), la tabella di deduplica è un costo inutile. - La tabella processed_messages cresce: serve una scadenza (per esempio tieni gli id degli ultimi 7 giorni). - La deduplica funziona solo se il message_id è stabile: se il produttore rigenera l'id a ogni tentativo, il pattern non protegge.

Esempio pratico: Pagamenti riceve OrdineCreato con message_id = "outbox-9871". Controlla processed_messages, non lo trova, addebita 99.90 euro e inserisce l'id nella stessa transazione. Dopo trenta secondi il broker riconsegna lo stesso messaggio perché l'ack si è perso. Pagamenti trova l'id, manda l'ack e non addebita di nuovo.

Pattern correlati: Transactional Outbox, Retry, Messaggistica asincrona.

Saga — Coreografia

In una frase: una transazione di business su più servizi diventa una catena di transazioni locali, collegate da eventi, ognuna con una azione di compensazione.

Problema che risolve: creare un ordine richiede un passo in Ordini, uno in Pagamenti, uno in Magazzino. Non c'è una transazione che li copra. Se Magazzino non ha scorte, il pagamento già fatto va annullato. La saga definisce chi fa cosa e come si torna indietro.

Come funziona:

Illustrazione: saga a tappe con biglietto di ritorno, le compensazioni annullano le tappe già fatte

sequenceDiagram
    participant O as Servizio Ordini
    participant B as Broker
    participant P as Servizio Pagamenti
    participant M as Servizio Magazzino
    O->>B: OrdineCreato
    B->>P: OrdineCreato
    P->>P: addebita carta
    P->>B: PagamentoEseguito
    B->>M: PagamentoEseguito
    M->>M: scorte insufficienti
    M->>B: ScortaNonDisponibile
    B->>P: ScortaNonDisponibile
    P->>P: rimborsa carta (compensazione)
    P->>B: PagamentoRimborsato
    B->>O: ScortaNonDisponibile
    O->>O: ordine ANNULLATO
  1. Ordini crea l'ordine in stato IN_ATTESA e pubblica OrdineCreato.
  2. Pagamenti ascolta, addebita la carta e pubblica PagamentoEseguito.
  3. Magazzino ascolta, prova a riservare le scorte. Nel caso felice pubblica ScortaRiservata e Ordini porta l'ordine a CONFERMATO.
  4. Nel caso di fallimento Magazzino pubblica ScortaNonDisponibile. Pagamenti lo ascolta ed esegue la compensazione: il rimborso. Ordini lo ascolta e porta l'ordine ad ANNULLATO.

Non esiste un coordinatore: ogni servizio sa quali eventi ascoltare, cosa fare e cosa pubblicare. La compensazione non è un rollback: il pagamento è avvenuto e viene rimborsato, e i due movimenti restano entrambi nella storia.

Quando usarlo: - La saga ha 2-4 passi e il flusso è lineare. - I team vogliono massima autonomia e nessun componente centrale. - Già usi eventi fra i servizi.

Quando NON usarlo / rischi: - Con molti passi il flusso diventa invisibile: per capire cosa succede devi leggere il codice di tutti i servizi. Passa a Saga Orchestrazione. - Rischio di dipendenze cicliche fra servizi che si ascoltano a vicenda. - Difficile testare il flusso completo e difficile capire in che stato è una saga in corso. - Ogni passo richiede Transactional Outbox e Idempotent Consumer, altrimenti la catena si rompe.

Esempio pratico: un cliente ordina due paia di scarpe. Ordini pubblica OrdineCreato, Pagamenti addebita 180 euro, Magazzino scopre che resta un solo paio e pubblica ScortaNonDisponibile. Pagamenti rimborsa 180 euro, Ordini segna l'ordine come annullato, Notifiche manda al cliente l'email "ordine annullato, rimborso in corso".

Pattern correlati: Saga Orchestrazione, Transactional Outbox, Idempotent Consumer, Event-driven.

Saga — Orchestrazione

In una frase: un orchestratore centrale dice a ogni servizio cosa fare, passo per passo, e gestisce le compensazioni quando un passo fallisce.

Problema che risolve: quando la saga ha molti passi o percorsi alternativi, la coreografia diventa un groviglio di eventi. Serve un punto che conosca l'intero flusso, tenga lo stato della saga e decida cosa fare in caso di errore.

Come funziona:

flowchart TD
    orch["🎼 Orchestratore ordine"]
    stato[("📒 Stato saga")]
    pay["💳 Pagamenti"]
    mag["🏭 Magazzino"]
    mail["✉️ Notifiche"]
    orch -->|"salva passo"| stato
    orch -->|"1. AddebitaCarta"| pay
    orch -->|"2. RiservaScorta"| mag
    orch -->|"3. InviaConferma"| mail
    pay -->|"esito"| orch
    mag -->|"esito"| orch
stateDiagram-v2
    [*] --> OrdineCreato
    OrdineCreato --> PagamentoInCorso : comando AddebitaCarta
    PagamentoInCorso --> ScortaInCorso : PagamentoEseguito
    PagamentoInCorso --> Annullato : PagamentoRifiutato
    ScortaInCorso --> Confermato : ScortaRiservata
    ScortaInCorso --> RimborsoInCorso : ScortaNonDisponibile
    RimborsoInCorso --> Annullato : PagamentoRimborsato
    Confermato --> [*]
    Annullato --> [*]
  1. L'orchestratore (spesso vive dentro il servizio Ordini, oppure è un servizio a sé) crea una istanza di saga con stato OrdineCreato e la salva nel suo database.
  2. Manda il comando AddebitaCarta a Pagamenti e passa a PagamentoInCorso.
  3. Alla risposta PagamentoEseguito manda RiservaScorta a Magazzino.
  4. Se Magazzino risponde ScortaNonDisponibile, l'orchestratore manda il comando di compensazione RimborsaCarta e poi porta la saga ad Annullato.

Lo stato della saga è un dato persistente: se l'orchestratore si riavvia, riprende da dove era. I servizi partecipanti non sanno nulla del flusso: ricevono un comando e rispondono con un evento.

Coreografia Orchestrazione
Chi decide il prossimo passo Ogni servizio, ascoltando eventi L'orchestratore, mandando comandi
Accoppiamento Basso, ma nascosto negli eventi Esplicito verso l'orchestratore
Visibilità del flusso Sparsa in più servizi In un posto solo
Stato della saga Nessuno lo conosce intero Persistito nell'orchestratore
Complessità da aggiungere Nessun componente nuovo Un orchestratore da scrivere e gestire
Adatta a 2-4 passi, flusso lineare Molti passi, rami, timeout
Rischio principale Dipendenze cicliche, debug difficile L'orchestratore diventa un "cervello" che sa troppo

Quando usarlo: - La saga ha più di 4 passi, rami condizionali o timeout. - Serve sapere in ogni momento in che stato è una saga (supporto clienti, dashboard). - Vuoi testare il flusso in un punto solo.

Quando NON usarlo / rischi: - L'orchestratore tende ad assorbire logica di business dei servizi: deve coordinare, non decidere il prezzo o lo sconto. - È un componente in più da rendere affidabile: se cade, le saghe si fermano. - Per saghe a due passi è più codice del necessario.

Esempio pratico: l'orchestratore ordine riceve OrdineCreato 42, manda AddebitaCarta a Pagamenti, poi RiservaScorta a Magazzino, poi InviaConferma a Notifiche. Se Magazzino risponde ScortaNonDisponibile dopo 2 secondi, manda RimborsaCarta e InviaAnnullamento. Il supporto clienti apre la dashboard, cerca l'ordine 42 e vede lo stato RimborsoInCorso con il timestamp di ogni passo.

Pattern correlati: Saga Coreografia, Transactional Outbox, Timeout, Command / State.

CQRS

In una frase: separa il modello che gestisce le scritture (comandi) dal modello che serve le letture (query), aggiornando il secondo tramite eventi.

Problema che risolve: con un database per servizio, la pagina "i miei ordini con nome prodotto e stato spedizione" richiede dati di Ordini, Catalogo e Magazzino. Fare tre chiamate a ogni richiesta è lento e fragile. Inoltre lo stesso modello che valida gli ordini è spesso pessimo per fare ricerche e report.

Come funziona:

Illustrazione: sportello scrivi e sportello leggi, collegati dal nastro della proiezione

flowchart LR
    client["Client"] -- "comando CreaOrdine" --> write["Modello di scrittura Ordini"]
    write --> dbWrite[("DB scrittura")]
    write -- "OrdineCreato" --> broker["Broker"]
    catalogo["Servizio Catalogo"] -- "PrezzoAggiornato" --> broker
    broker --> proj["Proiezione"]
    proj --> dbRead[("DB lettura denormalizzato")]
    client -- "query StoricoOrdini" --> read["Modello di lettura"]
    read --> dbRead
  1. I comandi (CreaOrdine, AnnullaOrdine) vanno al modello di scrittura, che valida le regole e salva nel suo database.
  2. Il modello di scrittura pubblica eventi (tramite Transactional Outbox).
  3. Una proiezione ascolta gli eventi di uno o più servizi e aggiorna un database di lettura già nella forma che serve alla query: una tabella piatta storico_ordini con nome prodotto, prezzo, stato spedizione.
  4. Le query leggono solo da questo database. Nessun join a runtime, nessuna chiamata ad altri servizi.

Il database di lettura è eventualmente coerente: fra la scrittura e l'aggiornamento della proiezione passano millisecondi o secondi.

Quando usarlo: - Le letture sono molto più frequenti delle scritture e hanno forme diverse (liste, ricerche, report). - Una vista ha bisogno di dati di più servizi. - Vuoi scalare le letture separatamente, per esempio con repliche o un motore di ricerca.

Quando NON usarlo / rischi: - Per un CRUD semplice raddoppia il codice senza benefici. - L'utente può non vedere subito quello che ha appena scritto ("ho creato l'ordine ma la lista è vuota"): serve gestirlo nell'interfaccia. - Ogni proiezione è codice da mantenere e da poter ricostruire da zero quando cambia.

Esempio pratico: la pagina "i miei ordini" legge da una tabella vista_ordini_cliente con colonne ordine_id, data, nome_prodotto, prezzo, stato_spedizione. La proiezione la aggiorna quando riceve OrdineCreato da Ordini e SpedizioneAvviata da Magazzino. Una query, una tabella, risposta in pochi millisecondi anche con milioni di ordini.

Pattern correlati: Event Sourcing, Database per Service, Transactional Outbox, API Composition.

Event Sourcing

In una frase: invece di salvare lo stato corrente, salva la sequenza di eventi che lo hanno prodotto e ricostruisci lo stato rileggendoli.

Problema che risolve: con una tabella ordini che ha una colonna stato, sai che l'ordine è ANNULLATO ma non sai quando, perché e cosa è successo prima. Per audit, contestazioni e analisi la storia conta più dello stato finale. In più, salvare lo stato e pubblicare eventi è ancora un dual write.

Come funziona:

Illustrazione: il registro degli eventi ricostruisce lo stato dell'ordine con il replay

flowchart LR
    cmd["Comando AnnullaOrdine"] --> agg["Aggregato Ordine"]
    store[("Event store ordine 42")] -- "replay eventi" --> agg
    agg -- "append OrdineAnnullato" --> store
    store --> snap[("Snapshot ogni 100 eventi")]
    snap -- "stato al v100" --> agg
    store -- "stream eventi" --> proj["Proiezioni CQRS"]
  1. Ogni cambiamento è un evento immutabile aggiunto in coda allo stream dell'aggregato: OrdineCreato, ArticoloAggiunto, PagamentoEseguito, OrdineAnnullato.
  2. Per gestire un comando, l'aggregato viene ricostruito facendo il replay di tutti i suoi eventi dall'inizio.
  3. Se gli eventi sono tanti, si salva uno snapshot periodico dello stato: il replay parte dallo snapshot e applica solo gli eventi successivi.
  4. L'event store è anche la fonte degli eventi per le proiezioni: non serve un outbox separato, perché lo stato è la lista degli eventi.

Quando usarlo: - Serve un audit completo e non contestabile (pagamenti, contabilità). - Vuoi poter rispondere a "com'era lo stato il 3 marzo" o ricostruire una proiezione da zero. - Il dominio ragiona naturalmente per eventi.

Quando NON usarlo / rischi: - Curva di apprendimento alta: cambia il modo di modellare, interrogare e testare. - Gli eventi vecchi restano per sempre: cambiare la forma di un evento richiede versioning e upcasting. - Le query sullo stato corrente richiedono quasi sempre CQRS: l'event store da solo serve male le liste. - Per un dominio semplice è un costo sproporzionato.

Esempio pratico: lo stream dell'ordine 42 contiene OrdineCreato, ArticoloAggiunto (scarpe x2), PagamentoEseguito (180.00), ScortaNonDisponibile, PagamentoRimborsato (180.00), OrdineAnnullato. Il supporto clienti vede l'intera storia. Il team finance ricostruisce il totale dei rimborsi di marzo facendo il replay degli eventi PagamentoRimborsato di tutti gli ordini, senza una tabella pensata in anticipo.

Pattern correlati: CQRS, Transactional Outbox, Saga Orchestrazione, Memento.

Transaction Log Tailing / CDC

In una frase: un connettore legge il log delle transazioni del database e trasforma ogni cambiamento in un messaggio sul broker.

Problema che risolve: il polling dell'outbox aggiunge latenza e carico. Il Change Data Capture (CDC) ottiene gli stessi cambiamenti dal log che il database scrive comunque, senza query ripetute e con latenza di millisecondi. Serve anche per replicare dati verso un altro sistema (cache, motore di ricerca, data warehouse) senza toccare il codice del servizio.

Come funziona:

flowchart LR
    ordini["📦 Ordini"] -->|"INSERT"| outbox[("📮 outbox")]
    subgraph pg["🐘 PostgreSQL"]
        outbox --> wal["📜 WAL"]
    end
    wal -->|"legge in streaming"| deb["🔌 Debezium"]
    deb --> kafka[["📨 Kafka"]]
    kafka --> pay["💳 Pagamenti"]
    kafka --> ricerca[("🔍 Indice ricerca")]
flowchart LR
    svc["Servizio Ordini"] -- "INSERT outbox" --> db[("PostgreSQL")]
    db -- "WAL" --> debezium["Debezium connector"]
    debezium -- "topic ordini.eventi" --> kafka["Kafka"]
    kafka --> pagamenti["Servizio Pagamenti"]
    kafka --> search["Indice di ricerca Catalogo"]
  1. Il database scrive ogni transazione nel suo log (WAL in PostgreSQL, binlog in MySQL) prima ancora di aggiornare le tabelle.
  2. Debezium si collega come se fosse una replica e legge il log in streaming.
  3. Per ogni riga inserita nella tabella outbox produce un messaggio Kafka, con la posizione nel log come offset: se riparte, riprende da dove era.
  4. I consumatori leggono dal topic. Nessun polling, nessun codice di relay da scrivere.

Usato con la tabella outbox è la variante migliore del Transactional Outbox. Usato direttamente sulle tabelle di dominio espone lo schema interno del database agli altri servizi: evita, a meno che sia una replica tecnica (cache, ricerca).

Quando usarlo: - Hai già Kafka o un broker compatibile e puoi aggiungere Debezium. - Serve latenza bassa e volume alto di eventi. - Devi alimentare un indice di ricerca o una cache dal database senza modificare il servizio.

Quando NON usarlo / rischi: - Componente infrastrutturale in più da configurare e monitorare (slot di replica, permessi, ritenzione del log). - Il log espone la forma fisica delle righe: pubblica solo da una tabella outbox con payload già pensato come contratto. - Database gestiti senza accesso al log (alcuni servizi cloud) non lo permettono.

Esempio pratico: Catalogo scrive i prodotti in PostgreSQL. Un connettore Debezium legge la tabella outbox e pubblica ProdottoAggiornato su Kafka. Un consumatore aggiorna l'indice Elasticsearch della ricerca. Il team Catalogo non ha scritto una riga di codice di pubblicazione.

Pattern correlati: Transactional Outbox, CQRS, Messaggistica asincrona.

Two-Phase Commit

In una frase: un coordinatore chiede a tutti i partecipanti di prepararsi, e solo se tutti dicono sì ordina il commit a tutti.

Problema che risolve: è la risposta classica al commit atomico su più database. Nei microservizi quasi non si usa, ma va conosciuto per capire perché le saghe esistono e per riconoscerlo nei sistemi legacy (XA, JTA, MSDTC).

Come funziona:

flowchart TD
    coord["🧑‍⚖️ Coordinatore XA"]
    dbO[("🗄️ DB Ordini")]
    dbP[("🗄️ DB Pagamenti")]
    lockO{{"🔒 Lock in attesa"}}
    lockP{{"🔒 Lock in attesa"}}
    http>"☁️ Pagamenti via HTTP"]
    coord -->|"1. prepare, 2. commit"| dbO --> lockO
    coord -->|"1. prepare, 2. commit"| dbP --> lockP
    coord -.->|"🚫 non partecipa"| http
sequenceDiagram
    participant C as Coordinatore
    participant DBO as DB Ordini
    participant DBP as DB Pagamenti
    C->>DBO: PREPARE
    C->>DBP: PREPARE
    DBO-->>C: pronto (lock mantenuti)
    DBP-->>C: pronto (lock mantenuti)
    C->>DBO: COMMIT
    C->>DBP: COMMIT
    Note over C,DBP: se il coordinatore muore dopo PREPARE, i lock restano fino al suo ritorno
  1. Fase 1, prepare: il coordinatore chiede a ogni database di eseguire la transazione fino al punto di commit, senza confermarla. Ogni partecipante risponde "pronto" e tiene i lock sulle righe.
  2. Fase 2, commit: se tutti hanno risposto "pronto", il coordinatore ordina il commit a tutti. Se anche uno ha detto no, ordina il rollback a tutti.
  3. Il protocollo è bloccante: fra le due fasi i partecipanti non possono liberare i lock. Se il coordinatore cade, restano bloccati finché non torna.

Perché non si usa nei microservizi:

  • Tutti i partecipanti devono supportare il protocollo XA: molti database moderni, broker e API HTTP non lo fanno.
  • I lock mantenuti fra le due fasi riducono il throughput e creano contese fra servizi.
  • Il coordinatore è un punto singolo di fallimento e di accoppiamento fra tutti i servizi.
  • Va contro il principio di Database per Service: il coordinatore deve conoscere i database di tutti.

Quando usarlo: - Dentro un monolite che tocca due database dello stesso team, con driver XA e volumi bassi. - Integrazione con sistemi legacy che lo richiedono.

Quando NON usarlo / rischi: - Fra microservizi: preferisci Saga Coreografia o Saga Orchestrazione, che accettano la coerenza eventuale in cambio di disponibilità. - Con servizi esposti via HTTP o con broker: non partecipano al protocollo. - Con traffico alto: i lock distribuiti sono un collo di bottiglia.

Esempio pratico: un vecchio sistema gestionale scrive l'ordine su Oracle e il movimento contabile su DB2 nella stessa transazione XA. Funziona finché i due database sono nello stesso datacenter e il volume è di poche centinaia di ordini all'ora. Quando Pagamenti diventa un servizio con API HTTP, il commit a due fasi non può più includerlo: l'ordine viene riscritto come saga.

Pattern correlati: Saga Coreografia, Saga Orchestrazione, Database per Service.

Come scegliere

flowchart TD
    q1{"I servizi condividono lo stesso database?"}
    q1 -- "sì" --> dbps["Database per Service"]
    q1 -- "no" --> q2{"Devi scrivere un dato e pubblicare un evento?"}
    q2 -- "sì" --> q3{"Hai Debezium o CDC disponibile?"}
    q3 -- "sì" --> cdc["Transactional Outbox con CDC"]
    q3 -- "no" --> poll["Transactional Outbox con polling"]
    cdc --> idem["Idempotent Consumer sempre"]
    poll --> idem
    q2 -- "no" --> q4{"Una operazione tocca più servizi?"}
    q4 -- "sì" --> q5{"Più di 4 passi, rami o timeout?"}
    q5 -- "sì" --> orch["Saga Orchestrazione"]
    q5 -- "no" --> chor["Saga Coreografia"]
    q4 -- "no" --> q6{"Letture complesse o su più servizi?"}
    q6 -- "sì" --> cqrs["CQRS"]
    q6 -- "no" --> q7{"Serve audit completo o replay dello stato?"}
    q7 -- "sì" --> es["Event Sourcing con CQRS"]
    q7 -- "no" --> fine["Transazione locale, nessun pattern"]

Regola pratica: parti da Database per Service, aggiungi Transactional Outbox e Idempotent Consumer appena un servizio pubblica eventi, introduci una Saga solo per le operazioni che toccano più servizi, e valuta CQRS ed Event Sourcing solo quando le letture o l'audit lo chiedono davvero. Two-Phase Commit resta un riferimento storico, non una opzione.