Nelle architetture a microservizi, uno dei problemi più complessi è l’aggiornamento atomico del database locale e la contestuale notifica ad altri servizi tramite un message broker (come Kafka, RabbitMQ o AWS SNS). Se il database si aggiorna ma il broker fallisce, i servizi a valle rimarranno disallineati. Se l’invio al broker riesce ma il database fa rollback, avremo inviato dati fantasma (“phantom reads”).
Il Transactional Outbox Pattern risolve questo problema garantendo la consistenza eventuale (“eventual consistency”) attraverso l’uso di transazioni ACID native del database.
Cos’è il Transactional Outbox Pattern e Casi d’Uso
Il pattern prevede l’inserimento di una tabella di supporto (chiamata appunto outbox) all’interno dello stesso database del microservizio.
Quando si verifica un evento di business (es. la creazione di un ordine):
- Si aggiornano le tabelle di business (es.
ordini). - Si inserisce un record che descrive l’evento nella tabella
outbox.
Queste due operazioni avvengono all’interno della stessa transazione database. Se la transazione va a buon fine, entrambi i dati sono salvati; se fallisce, viene eseguito il rollback di tutto. Un componente separato (Message Relay) leggerà poi la tabella outbox e pubblicherà i messaggi sul broker.
Casi d’uso principali
- Sistemi di E-commerce: Creazione di un ordine che deve notificare il servizio spedizioni e il servizio fatturazione.
- Sistemi Finanziari: Movimenti di conto che devono triggerare notifiche push o SMS agli utenti.
- Sincronizzazione CQRS: Aggiornamento del database di scrittura (OLTP) che deve disallineare e aggiornare le viste di lettura (es. su Elasticsearch o Read Model dedicati).
Strategie di Implementazione del Message Relay
Una volta salvato il record nella tabella outbox, come viene inviato al message broker? Esistono tre approcci principali:
1. Polling Publisher (Schedulazione)
Un processo in background (es. un cron job, un worker Quarkus/Spring, o un tool come Quartz) interroga regolarmente la tabella outbox (es. ogni 200ms) alla ricerca di record non ancora inviati.
- Pro: Semplice da implementare; non richiede infrastrutture aggiuntive.
- Contro: Introduce latenza; non scala benissimo se il database è enorme; rischia di sovraccaricare il database con continue query di
SELECT.
2. Change Data Capture (CDC)
Un tool specializzato (il più famoso è Debezium) legge direttamente i log di transazione del database (es. il WAL di PostgreSQL o il Binlog di MySQL) anziché fare query sulla tabella.
- Pro: Latenza quasi zero (near real-time); nessun impatto sulle performance delle query del database; scalabile.
- Contro: Richiede un’infrastruttura dedicata (es. Kafka Connect) e competenze sistemistiche avanzate.
3. Database Triggers
Un trigger sul database intercetta l’inserimento nella tabella outbox e chiama una procedura o una funzione esterna che notifica il broker.
- Pro: Immediato.
- Contro: Sconsigliato nelle architetture moderne. Lega la logica applicativa al database, è difficile da testare e rischia di bloccare la transazione principale se il sistema esterno risponde lentamente.
Esempio Pratico: Struttura della Tabella Outbox e 5 Record d’Esempio
Caso d’uso specifico: Piattaforma E-commerce di Booking Viaggi
Immaginiamo un servizio che gestisce le prenotazioni dei viaggi. Quando un utente acquista un pacchetto, dobbiamo aggiornare l’ordine e notificare i servizi esterni (Servizio Email di Conferma, Servizio Generazione Biglietti, Servizio Statistiche).
Ecco come appare la struttura di una tabella outbox generica:
| id (UUID) | aggregate_type | aggregate_id | event_type | payload (JSON) | status | created_at |
|---|---|---|---|---|---|---|
6b2d3e... | Order | ORD-9921 | OrderCreated | {"userId": 45, "total": 250.00, "tripId": "IT-ROM-2026"} | PENDING | 2026-09-16 19:00:01 |
7f4a1c... | Order | ORD-9921 | PaymentAuthorized | {"transactionId": "TX_88127", "amount": 250.00} | PROCESSED | 2026-09-16 19:00:03 |
a81b2c... | Customer | CUST-450 | ProfileUpdated | {"email": "mario.rossi@email.it", "phone": "+39333..."} | PENDING | 2026-09-16 19:01:15 |
c93d4e... | Order | ORD-9922 | OrderCancelled | {"reason": "CustomerTimeout", "tripId": "FR-PAR-2026"} | FAILED | 2026-09-16 19:02:00 |
e05f6a... | Inventory | TRIP-IT-ROM | StockDecreased | {"tripId": "IT-ROM-2026", "remainingSeats": 4} | PENDING | 2026-09-16 19:02:05 |
Nota sullo stato FAILED: Viene usato se il Message Relay riscontra errori bloccanti ripetuti (es. payload malformato), spostando il record in una coda di dead-letter o contrassegnandolo per l’intervento manuale.
L’Idempotence Key (Chiave di Idempotenza)
Nel mondo dei sistemi distribuiti vige una regola d’oro: la consegna dei messaggi è garantita “at-least-once” (almeno una volta). Questo significa che, a causa di timeout di rete o riavvii dei servizi, il consumatore finale potrebbe ricevere lo stesso identico messaggio due o più volte.
L’Idempotence Key è un identificativo unico (spesso un UUID o una combinazione di ID entità + stato) incluso nell’header o nel payload del messaggio. Il consumatore riceve la chiave e controlla se l’ha già processata in passato prima di eseguire nuovamente la logica di business.
Esempi in cui è fondamentale:
- Servizio di Pagamento: Se il messaggio
OrderCreatedviene consegnato due volte a causa di un lag di rete, il servizio di pagamento non deve addebitare la carta di credito dell’utente due volte. L’ID ordine (ORD-9921) o l’ID transazione outbox funge da chiave di idempotenza. - Invio Email: Evita che un utente riceva 5 email identiche di “Benvenuto” o di “Conferma Ordine” se il broker sperimenta micro-interruzioni.
Risposta ai Dubbi Architetturali Comuni
Se ci sono molte tabelle coinvolte nella transazione, serve l’Idempotence Key?
Sì, assolutamente. Il fatto che ci siano molte tabelle coinvolte lato produttore (es. inserimento in ordini, aggiornamento di inventario, modifica dei punti_fedelta) garantisce solo la consistenza atomica interna al tuo database.
Quando l’Outbox Relay pubblicherà l’evento sul broker, il consumatore a valle (es. il servizio spedizioni) non sa quante tabelle hai modificato a monte. Sa solo che ha ricevuto un messaggio. Se il broker duplica il messaggio, il consumatore eseguirà l’azione due volte a meno che non usi la chiave di idempotenza.
Se l’entità è una sola, si può mettere direttamente lo stato lì dentro ed evitare una tabella Outbox dedicata?
Sì, ed è un’ottima variante chiamata “State-based Outbox” (o State-driven pattern).
Se hai un’unica entità (es. una tabella User), invece di creare una tabella separata outbox, puoi aggiungere colonne di stato direttamente sulla tabella dell’entità:
| user_id | status | integration_status | |
|---|---|---|---|
| 101 | test@test.com | ACTIVE | PENDING_DELIVERY |
In questo scenario:
- Apri la transazione.
- Aggiorni l’utente e imposti
integration_status = 'PENDING_DELIVERY'. - Fai il commit.
Il tuo Message Relay (o un processo di polling) farà una query del tipo: SELECT * FROM users WHERE integration_status = 'PENDING_DELIVERY'. Dopo aver inviato il messaggio al broker, aggiornerà lo stato in DELIVERED.
Quando conviene questo approccio “senza tabella outbox”?
- Quando l’architettura è semplice e le entità sono isolate.
- Quando non hai bisogno di mantenere uno storico cronologico accurato di tutti gli eventi intermedi (es. se ti interessa solo inviare lo “stato finale” corrente dell’entità).
Quando invece la tabella Outbox dedicata è obbligatoria?
- Se l’entità subisce modifiche rapide in sequenza: Se un ordine passa da
CREATEDaPAIDin 50 millisecondi, l’approccio basato sullo stato sovrascriverebbe lo stato precedente. Il Relay vedrebbe soloPAID, perdendo l’eventoCREATED. La tabellaoutboxdedicata preserva la sequenzialità storica (immutabilità dei log). - Se l’operazione coinvolge più tabelle: Se l’evento scaturisce dall’unione di dati di 3 tabelle diverse, non avresti un unico posto logico in cui inserire la colonna
integration_status.
⚠️ Attenzione ai Tranelli: Il Problema degli Aggiornamenti Rapidi (Race Conditions del Relay)
Quando si valuta l’approccio State-based (usare lo stato interno dell’entità come outbox senza una tabella dedicata), un’obiezione spontanea è: “Ma se un’entità cambia stato troppo velocemente prima che il Message Relay lo invii, non significa che c’è un errore di progettazione a monte?”
La risposta è no. Non è un errore del codice applicativo, ma una conseguenza intrinseca dell’asincronia del Message Relay.
Lo Scenario: Il “Malinteso” del Tempo
Immaginiamo che un ordine passi dallo stato CREATED allo stato PAID in soli 50 millisecondi a causa di una risposta fulminea del gateway di pagamento (es. Stripe). A livello di codice e di database, tutto avviene secondo le regole ACID tramite due transazioni separate e legittime:
- Transazione 1 (Creazione): L’utente clicca su “Acquista”. L’applicazione inserisce l’ordine nel database con
stato = 'CREATED'ed esegue il Commit. - Transazione 2 (Pagamento): Il webhook del pagamento risponde istantaneamente. L’applicazione esegue
UPDATE ordini SET stato = 'PAID'ed esegue il Commit.
Il database è perfettamente coerente. Il problema nasce se il tuo Message Relay controlla il database (tramite Polling) ogni 200 millisecondi.
Come reagiscono i due approcci
❌ Se usi lo “State-based Outbox” (Niente tabella dedicata)
Al millisecondo 200, il Relay si sveglia ed esegue la query: SELECT * FROM ordini WHERE integration_status = 'PENDING'.
- Cosa trova? Trova la riga dell’ordine il cui
statosul database è giàPAID, poiché la Transazione 2 ha sovrascritto il valore precedente. - Il disastro: Il Relay genererà e invierà un unico messaggio:
OrderPaid. L’eventoOrderCreatedè andato perso per sempre. Se un servizio a valle (es. il Servizio Logistica) aveva bisogno di intercettare la creazione per inizializzare il packaging, quel servizio rimarrà bloccato o fallirà.
Se usi la Tabella Outbox Dedicata
Grazie alla tabella outbox, le due transazioni applicative scrivono dei log storici e immutabili all’interno della stessa transazione atomica:
- Transazione 1: Salva l’ordine e inserisce una riga nella tabella outbox:
[Evento: OrderCreated]. - Transazione 2: Aggiorna l’ordine e inserisce una nuova riga nella tabella outbox:
[Evento: OrderPaid].
Quando il Relay si sveglia al millisecondo 200 e interroga la tabella outbox, troverà due righe distinte. Leggerà i record rispettando l’ordine cronologico sequenziale e invierà al broker entrambi i messaggi: prima OrderCreated e poi OrderPaid. I servizi a valle riceveranno l’esatta storia evolutiva del dato.
La Regola d’Oro per la scelta
- Scegli lo Stato (State Capture): Solo se ai servizi a valle non importa la storia dell’entità, ma interessa esclusivamente sapere “Dimmi com’è l’entità in questo preciso momento”.
- Scegli la Tabella Outbox Dedicata: Se il tuo sistema è guidato dagli eventi (Event-Driven) e la sequenza cronologica di ogni singolo cambio di stato è cruciale per la logica di business globale.
Outbox Pattern vs Event Sourcing: Qual è la differenza?
Sebbene sia il Transactional Outbox Pattern che l’Event Sourcing abbiano a che fare con l’architettura guidata dagli eventi (Event-Driven), i due pattern affrontano la persistenza e la gestione dei dati in modi diametralmente opposti.
La differenza fondamentale risiede nella fonte della verità (Source of Truth) del microservizio.
Outbox Pattern:
[Azione] ──> ( Transazione ACID ) ──> [Tabella di Stato (Stato Corrente)]
└──> [Tabella Outbox (Eventi da Inviare)]
Event Sourcing:
[Azione] ──> [Event Store (Log Immutabile di Eventi)] ──> Proiezione ──> [Stato Corrente (Solo in memoria/cache)]
1. Dove risiede lo “Stato della Verità”?
- Outbox Pattern: La fonte della verità è il database relazionale/documentale tradizionale, che memorizza lo stato corrente dell’entità (es. la riga dell’ordine
ORD-9921constatus = 'SHIPPED'). La tabella Outbox serve solo come “canale di comunicazione sicuro” verso l’esterno per notificare il resto del sistema. - Event Sourcing: Non esiste una tabella con lo stato corrente. La fonte della verità è l’Event Store, un log immutabile di tutti gli eventi atomici accaduti nel tempo (
OrderCreated,PaymentReceived,OrderShipped). Se vuoi conoscere lo stato attuale di un ordine, devi fare il “replaying” (ricostruzione) di tutti i suoi eventi dall’inizio alla fine.
2. Tabella Comparativa
| Caratteristica | Transactional Outbox Pattern | Event Sourcing |
|---|---|---|
| Modello di Persistenza | Stato corrente (tabelle classiche tipo CRUD). | Log cronologico e immutabile di eventi. |
| Scopo Primario | Integrazione sicura e affidabile tra microservizi. | Tracciabilità totale, audit log nativo e storico evolutivo del dato. |
| Complessità | Bassa/Media: Si adatta facilmente ad applicazioni esistenti senza stravolgere la logica di business. | Alta: Richiede un cambio di paradigma completo nel design del codice e nella gestione delle query (CQRS obbligatorio). |
| Cancellazione Dati | Semplice (es. operazione di DELETE o UPDATE standard sulla tabella di business). | Impossibile per natura (il log è in sola aggiunta; gli errori si correggono con eventi di compensazione). |
Quando scegliere l’uno o l’altro?
Scegli l’Outbox Pattern se hai un’applicazione tradizionale o se il tuo unico problema è evitare l’inconsistenza dei dati quando notifichi eventi ad altri microservizi.
Scegli l’Event Sourcing se operi in domini complessi dove il “come” si è arrivati a uno stato è fondamentale quanto lo stato stesso (es. sistemi bancari, tracciamento spedizioni medicali, audit legali) e hai bisogno di poter viaggiare nel tempo per ricostruire lo stato dell’applicazione a qualsiasi data passata.

