Magento 2 e Outbox Pattern: integrazioni affidabili con sistemi esterni

outbox pattern magento 2

Integrare Magento 2 con ERP, OMS, CRM, PIM, sistemi di pagamento, middleware e servizi esterni è una delle attività più comuni nei progetti ecommerce enterprise.

Ma cosa succede quando Magento salva correttamente un ordine nel database e, subito dopo, la chiamata verso il sistema esterno fallisce?

Oppure quando un messaggio viene inviato a RabbitMQ, ma la transazione Magento che avrebbe dovuto generarlo viene successivamente annullata?

Sono problemi tipici dei sistemi distribuiti.

Una soluzione architetturale particolarmente efficace è il Transactional Outbox Pattern, comunemente chiamato semplicemente Outbox Pattern.

In questo articolo vediamo perché è utile nelle integrazioni Magento 2, quale problema risolve e come implementarlo in un modulo Adobe Commerce/Magento Open Source.

Il problema: Magento e i sistemi esterni non condividono la stessa transazione

Consideriamo un caso molto comune.

Quando viene creato un ordine dobbiamo:

  1. salvare l’ordine in Magento;
  2. notificare l’ordine a un ERP.

Una prima implementazione potrebbe essere concettualmente simile a questa:

$orderRepository->save($order);

$erpClient->sendOrder($order);

Sembra ragionevole.

Il problema è che abbiamo due operazioni indipendenti:

Magento Database
       +
External ERP

Non esiste una transazione ACID che comprenda entrambe.

Possiamo quindi trovarci in questa situazione:

SAVE ORDER
    ↓
COMMIT DATABASE
    ↓
CALL ERP
    ↓
TIMEOUT

Magento contiene l’ordine.

L’ERP no.

Il sistema è entrato in uno stato inconsistente.

Il problema del Dual Write

In architettura distribuita questo scenario viene generalmente chiamato dual write problem.

Un’operazione di business deve modificare due sistemi differenti:

Database Magento
       +
Sistema esterno

e vorremmo che entrambe le operazioni fossero atomiche.

Idealmente:

BEGIN TRANSACTION

SAVE ORDER
SEND ORDER TO ERP

COMMIT

Ma il database Magento non può effettuare il rollback di una richiesta HTTP già elaborata dall’ERP.

Allo stesso modo, l’ERP non partecipa normalmente alla transazione MySQL di Magento.

Il problema diventa ancora più evidente se utilizziamo un message broker.

Supponiamo di fare:

$orderRepository->save($order);

$publisher->publish(
    'order.created',
    $message
);

Abbiamo comunque due sistemi:

MySQL
 +
RabbitMQ

Anche in questo caso le operazioni non sono automaticamente atomiche.

Perché pubblicare l’evento prima del commit non risolve il problema

Potremmo provare a pubblicare il messaggio prima del commit:

BEGIN TRANSACTION

SAVE ORDER

PUBLISH EVENT
        ↓
     RabbitMQ

COMMIT

Ma immaginiamo che RabbitMQ riceva correttamente il messaggio e successivamente il database effettui un rollback.

Avremo:

RabbitMQ → OrderCreated #123

Magento → Order #123 NON ESISTE

Il consumer potrebbe quindi elaborare un evento relativo a uno stato che non è mai stato realmente confermato nel sistema sorgente.

Pubblicare dopo il commit non basta

Possiamo invertire il problema:

BEGIN TRANSACTION

SAVE ORDER

COMMIT

PUBLISH EVENT

Ora siamo sicuri che l’ordine esista.

Ma cosa succede se il processo PHP termina subito dopo il commit?

COMMIT
   ↓
PHP PROCESS CRASH
   ↓
EVENT NOT PUBLISHED

L’ordine esiste nel database, ma nessun sistema esterno ne viene informato.

Abbiamo eliminato un problema creandone un altro.

Il Transactional Outbox Pattern

L’Outbox Pattern cambia completamente l’approccio.

Invece di cercare di aggiornare contemporaneamente database e sistema esterno, registriamo l’intenzione di inviare il messaggio nello stesso database della transazione principale.

L’architettura diventa:

                 ┌──────────────────────┐
                 │      Magento 2       │
                 │                      │
                 │  Business Operation  │
                 └──────────┬───────────┘
                            │
                       TRANSACTION
                            │
                ┌───────────┴───────────┐
                │                       │
                ▼                       ▼
        ┌───────────────┐       ┌───────────────┐
        │ sales_order   │       │    outbox     │
        │               │       │               │
        │ Order #123    │       │ OrderCreated  │
        └───────────────┘       └───────────────┘
                │                       │
                └───────────┬───────────┘
                            │
                          COMMIT

L’ordine e l’evento vengono salvati nella stessa transazione database.

Questa è la caratteristica fondamentale del pattern.

Se la transazione fallisce:

ROLLBACK
   ↓
Order     → NON salvato
Outbox    → NON salvato

Se invece la transazione viene confermata:

COMMIT
   ↓
Order     → salvato
Outbox    → salvato

Non può esistere uno senza l’altro, purché entrambi partecipino realmente alla stessa transazione.

Il Message Relay

A questo punto non abbiamo ancora inviato nulla all’ERP.

Abbiamo soltanto registrato l’evento.

Serve quindi un secondo componente:

Message Relay

Il suo compito è leggere gli eventi pendenti dalla tabella outbox.

Magento Transaction
       │
       ▼
┌──────────────┐
│    Outbox    │
└──────┬───────┘
       │
       │ read pending events
       ▼
┌──────────────┐
│ Message Relay│
└──────┬───────┘
       │
       ▼
    RabbitMQ
       │
       ▼
    Consumer
       │
       ▼
      ERP

Il relay può essere implementato attraverso:

  • cron Magento;
  • CLI command;
  • background worker;
  • consumer dedicato;
  • processo esterno;
  • Change Data Capture, in architetture più avanzate.

In una prima implementazione Magento, cron o worker sono spesso sufficienti.

Progettare una Outbox Table in Magento 2

Possiamo creare una tabella dedicata:

devlogica_outbox_event

con una struttura concettuale simile:

event_id
event_uuid
event_type
aggregate_type
aggregate_id
payload
status
attempts
created_at
available_at
processed_at
last_error

Per esempio:

Campo Valore
event_uuid 550e8400-e29b-41d4-a716-446655440000
event_type order.created
aggregate_type order
aggregate_id 123
status pending
attempts 0
created_at 2026-09-14 09:30:00

Il payload potrebbe essere JSON:

{
    "event_id": "550e8400-e29b-41d4-a716-446655440000",
    "event_type": "order.created",
    "order_id": 123,
    "increment_id": "000000123",
    "store_id": 1
}

Declarative Schema Magento 2

La tabella può essere creata tramite db_schema.xml.

Un esempio semplificato:

<table name="devlogica_outbox_event"
       resource="default"
       engine="innodb"
       comment="Transactional Outbox Events">

    <column xsi:type="bigint"
            name="event_id"
            unsigned="true"
            nullable="false"
            identity="true"
            comment="Event ID"/>

    <column xsi:type="varchar"
            name="event_uuid"
            nullable="false"
            length="36"
            comment="Event UUID"/>

    <column xsi:type="varchar"
            name="event_type"
            nullable="false"
            length="255"
            comment="Event Type"/>

    <column xsi:type="varchar"
            name="aggregate_type"
            nullable="false"
            length="100"
            comment="Aggregate Type"/>

    <column xsi:type="varchar"
            name="aggregate_id"
            nullable="false"
            length="255"
            comment="Aggregate ID"/>

    <column xsi:type="text"
            name="payload"
            nullable="false"
            comment="Event Payload"/>

    <column xsi:type="varchar"
            name="status"
            nullable="false"
            length="32"
            default="pending"
            comment="Status"/>

    <column xsi:type="int"
            name="attempts"
            unsigned="true"
            nullable="false"
            default="0"
            comment="Attempts"/>

    <column xsi:type="timestamp"
            name="created_at"
            nullable="false"
            default="CURRENT_TIMESTAMP"
            comment="Created At"/>

    <column xsi:type="timestamp"
            name="available_at"
            nullable="true"
            comment="Available At"/>

    <column xsi:type="timestamp"
            name="processed_at"
            nullable="true"
            comment="Processed At"/>

    <column xsi:type="text"
            name="last_error"
            nullable="true"
            comment="Last Error"/>

    <constraint xsi:type="primary"
                referenceId="PRIMARY">
        <column name="event_id"/>
    </constraint>

    <constraint xsi:type="unique"
                referenceId="DEVLOGICA_OUTBOX_EVENT_UUID">
        <column name="event_uuid"/>
    </constraint>

    <index referenceId="DEVLOGICA_OUTBOX_STATUS_AVAILABLE"
           indexType="btree">
        <column name="status"/>
        <column name="available_at"/>
    </index>

</table>

In produzione gli indici devono naturalmente essere progettati in funzione del volume e delle query effettuate dal relay.

Outbox Writer

È utile isolare completamente la creazione degli eventi.

Per esempio:

interface OutboxWriterInterface
{
    public function add(
        string $eventType,
        string $aggregateType,
        string $aggregateId,
        array $payload
    ): void;
}

Una possibile implementazione:

final class OutboxWriter implements OutboxWriterInterface
{
    public function __construct(
        private readonly ResourceConnection $resource,
        private readonly SerializerInterface $serializer
    ) {
    }

    public function add(
        string $eventType,
        string $aggregateType,
        string $aggregateId,
        array $payload
    ): void {
        $connection = $this->resource->getConnection();

        $connection->insert(
            $this->resource->getTableName(
                'devlogica_outbox_event'
            ),
            [
                'event_uuid' => Uuid::uuid4()->toString(),
                'event_type' => $eventType,
                'aggregate_type' => $aggregateType,
                'aggregate_id' => $aggregateId,
                'payload' => $this->serializer->serialize($payload),
                'status' => 'pending',
                'attempts' => 0,
            ]
        );
    }
}

Il punto importante non è tanto la classe in sé.

È dove viene eseguita questa INSERT.

La Outbox deve partecipare alla stessa transazione

Questo è il dettaglio che distingue un vero Transactional Outbox da una semplice tabella utilizzata come coda.

L’operazione:

SAVE BUSINESS DATA

e:

INSERT OUTBOX EVENT

devono appartenere alla stessa transazione database.

Concettualmente:

$connection->beginTransaction();

try {

    // modifica stato applicativo
    $this->processBusinessOperation();

    // registra evento
    $this->outboxWriter->add(
        'order.export.requested',
        'order',
        (string)$orderId,
        $payload
    );

    $connection->commit();

} catch (\Throwable $exception) {

    $connection->rollBack();

    throw $exception;
}

Se qualcosa fallisce, entrambe le modifiche vengono annullate.

Non mettere la chiamata HTTP nella transazione

Un errore importante sarebbe fare:

$connection->beginTransaction();

try {

    $orderRepository->save($order);

    $erpClient->sendOrder($order);

    $connection->commit();

} catch (\Throwable $e) {

    $connection->rollBack();
}

Tecnicamente possiamo mantenere aperta la transazione mentre effettuiamo la chiamata HTTP.

Architetturalmente è una pessima idea.

Una richiesta esterna potrebbe impiegare:

50 ms
500 ms
5 secondi
30 secondi
TIMEOUT

Durante questo periodo manteniamo aperta una transazione database, aumentando potenzialmente lock contention, tempi di risposta e rischio di failure.

Con Outbox, la transazione deve essere breve:

BEGIN
 ↓
business update
 ↓
insert outbox
 ↓
COMMIT

La comunicazione lenta viene spostata fuori dalla transazione.

Outbox + RabbitMQ in Magento 2

Magento/Adobe Commerce dispone già di un Message Queue Framework.

Possiamo quindi utilizzare l’Outbox come livello di affidabilità prima della pubblicazione sul broker.

L’architettura diventa:

                    MAGENTO
                       │
                 DB TRANSACTION
                       │
              ┌────────┴────────┐
              │                 │
              ▼                 ▼
         sales_order          outbox
              │                 │
              └──────COMMIT─────┘
                                │
                                ▼
                         Outbox Publisher
                                │
                                ▼
                           RabbitMQ
                                │
                                ▼
                       Magento Consumer
                                │
                                ▼
                              ERP

È importante capire che:

RabbitMQ e Outbox non sono alternative.

Risolvono problemi differenti.

RabbitMQ gestisce la comunicazione asincrona.

L’Outbox garantisce che l’intenzione di pubblicare il messaggio sia persistita atomicamente insieme alla modifica di business.

Perché RabbitMQ da solo non risolve il Dual Write

Consideriamo:

$orderRepository->save($order);

$publisher->publish(
    'devlogica.order.export',
    $message
);

Abbiamo comunque:

DB WRITE
   ↓
BROKER WRITE

Se il processo muore tra le due operazioni:

Order saved
Message missing

L’Outbox elimina questa finestra.

DB TRANSACTION

Order saved
+
Outbox saved

COMMIT

Dopo il commit il messaggio può essere pubblicato anche qualche secondo dopo.

Abbiamo accettato eventual consistency per ottenere maggiore affidabilità.

Il publisher della Outbox

Un worker può recuperare gli eventi:

SELECT *
FROM devlogica_outbox_event
WHERE status = 'pending'
AND (
    available_at IS NULL
    OR available_at <= NOW()
)
ORDER BY event_id
LIMIT 100;

Per ogni evento:

READ EVENT
    ↓
PUBLISH TO RABBITMQ
    ↓
SUCCESS?
   /     \
 YES      NO
 ↓         ↓
SENT      RETRY

Pseudo-codice:

foreach ($events as $event) {

    try {

        $publisher->publish(
            $event->getEventType(),
            $event->getPayload()
        );

        $repository->markAsProcessed(
            $event->getId()
        );

    } catch (\Throwable $exception) {

        $repository->markAsFailed(
            $event->getId(),
            $exception->getMessage()
        );
    }
}

Ma anche qui esiste un problema interessante.

Cosa succede se RabbitMQ riceve il messaggio ma Magento crasha?

Consideriamo:

PUBLISH
   ↓
RabbitMQ receives message
   ↓
PHP CRASH
   ↓
markAsProcessed() NON eseguito

Quando il worker riparte trova nuovamente:

status = pending

e ripubblica il messaggio.

Risultato:

MESSAGE #1
MESSAGE #1

Abbiamo un duplicato.

Questo comportamento non è necessariamente un bug.

È una conseguenza normale dei sistemi at-least-once delivery.

L’idempotenza è parte dell’architettura

Un sistema robusto deve assumere che un messaggio possa essere ricevuto più di una volta.

Ogni evento dovrebbe quindi avere un identificatore univoco:

{
    "event_id": "550e8400-e29b-41d4-a716-446655440000",
    "event_type": "order.created",
    "order_id": 123
}

Il consumer può mantenere una tabella:

processed_messages

con:

event_id
processed_at

Prima di elaborare:

EVENT RECEIVED
      ↓
event_id already processed?
      ↓
   YES   NO
    │     │
  IGNORE  PROCESS

Questo rende il consumer idempotente.

Idempotenza applicativa

In alcuni casi possiamo ottenere idempotenza direttamente attraverso la logica di dominio.

Per esempio:

Create shipment for order #123

potrebbe utilizzare una chiave:

magento-order-123-shipment

Se il sistema esterno riceve nuovamente la stessa richiesta:

POST /shipments

Idempotency-Key:
magento-order-123-shipment

può restituire il risultato precedente senza creare una seconda spedizione.

Questo approccio è particolarmente importante per:

  • pagamenti;
  • rimborsi;
  • spedizioni;
  • fatture;
  • ordini ERP;
  • movimenti finanziari.

Retry

Un’integrazione esterna non deve considerare ogni errore definitivo.

Un ERP potrebbe temporaneamente rispondere:

HTTP 503

oppure:

Connection timeout

In questi casi possiamo riprovare.

Una strategia semplice potrebbe essere:

attempt 1 → immediately
attempt 2 → +1 minute
attempt 3 → +5 minutes
attempt 4 → +15 minutes
attempt 5 → +1 hour

Questa tecnica è chiamata exponential backoff quando l’intervallo cresce progressivamente.

Il campo:

available_at

permette di stabilire quando un evento può essere nuovamente elaborato.

Non tutti gli errori devono essere ritentati

È importante distinguere:

Transient Failure

da:

Permanent Failure

Per esempio:

HTTP 503
Gateway Timeout
Connection refused

sono tipicamente candidati per un retry.

Mentre:

HTTP 400
Invalid SKU
Invalid Customer
Missing Required Field

potrebbero richiedere intervento umano o correzione dei dati.

Un’integrazione enterprise dovrebbe quindi classificare gli errori.

Dead Letter Queue

Dopo un certo numero di tentativi:

attempts >= MAX_ATTEMPTS

il messaggio non dovrebbe continuare a essere elaborato indefinitamente.

Possiamo spostarlo logicamente nello stato:

failed

oppure utilizzare una vera Dead Letter Queue (DLQ) sul broker.

Il flusso diventa:

EVENT
  ↓
PROCESS
  ↓
ERROR
  ↓
RETRY
  ↓
ERROR
  ↓
RETRY
  ↓
ERROR
  ↓
DLQ
  ↓
ALERT

Un operatore può quindi analizzare il problema e, dopo averlo risolto, effettuare il replay del messaggio.

Observability: l’integrazione deve essere osservabile

Un sistema affidabile non è semplicemente un sistema che effettua retry.

Deve permettere di capire cosa sta succedendo.

Per ogni evento dovremmo poter rispondere a domande come:

Quando è stato creato?
È stato pubblicato?
Quante volte abbiamo provato?
Qual è stato l'ultimo errore?
Quando verrà effettuato il prossimo retry?
È stato elaborato dall'ERP?

Per questo motivo una Outbox dovrebbe conservare almeno:

event_uuid
event_type
aggregate_id
created_at
status
attempts
available_at
processed_at
last_error

E nei log dovremmo sempre includere:

event_uuid

come correlation identifier.

Correlation ID

Consideriamo un ordine che attraversa:

Magento
   ↓
RabbitMQ
   ↓
Integration Service
   ↓
ERP

Senza un identificatore comune dobbiamo correlare manualmente log provenienti da sistemi diversi.

Con un correlation_id possiamo invece cercare:

CORRELATION_ID = abc-123

in tutta l’infrastruttura.

Un evento più completo potrebbe quindi essere:

{
    "event_id": "550e8400-e29b-41d4-a716-446655440000",
    "correlation_id": "9b856ac0-99d1-4ac6",
    "event_type": "order.created",
    "aggregate": {
        "type": "order",
        "id": "123"
    },
    "occurred_at": "2026-09-14T09:30:00Z",
    "payload": {
        "increment_id": "000000123"
    }
}

Event Payload: snapshot o riferimento?

Un’altra decisione architetturale importante riguarda il payload.

Possiamo salvare soltanto:

{
    "order_id": 123
}

e fare recuperare al consumer lo stato corrente dell’ordine.

Oppure possiamo salvare:

{
    "order_id": 123,
    "status": "processing",
    "total": 125.90,
    "currency": "EUR",
    "items": [...]
}

I due approcci hanno implicazioni differenti.

Reference

Outbox → order_id
            ↓
      load current order

È più leggero, ma il consumer potrebbe leggere uno stato diverso da quello esistente al momento dell’evento.

Snapshot

Outbox → complete event data

L’evento rappresenta esattamente ciò che è accaduto in quel momento.

Per veri domain event, lo snapshot è spesso preferibile.

Event versioning

Quando un’integrazione vive per anni, il formato degli eventi cambia.

Oggi:

{
    "order_id": 123
}

Domani:

{
    "order_id": 123,
    "store_id": 2
}

È quindi utile introdurre:

{
    "event_type": "order.created",
    "event_version": 2
}

Il consumer può così gestire versioni differenti senza rompere immediatamente la compatibilità.

Event naming

Anche il naming è importante.

Meglio utilizzare eventi che descrivono qualcosa che è accaduto:

order.created
order.cancelled
shipment.created
invoice.created
refund.created
customer.registered

rispetto a nomi eccessivamente legati all’implementazione:

send_order_to_erp
call_sap
update_external_system

Il primo approccio riduce l’accoppiamento.

Domani lo stesso evento:

order.created

potrebbe essere utilizzato da:

ERP
CRM
Data Warehouse
Marketing Automation
Fraud Detection

senza modificare il dominio Magento.

Outbox Pattern e Magento Events

Magento utilizza ampiamente eventi e observer.

Per esempio possiamo intercettare eventi relativi a:

order
invoice
shipment
customer
product

Ma un observer Magento non equivale automaticamente a un Outbox.

Questo:

public function execute(
    Observer $observer
): void {
    $order = $observer->getOrder();

    $this->erpClient->sendOrder($order);
}

introduce ancora una comunicazione sincrona con il sistema esterno.

Un observer può invece essere utilizzato per registrare un evento nella Outbox, purché sia garantita la corretta partecipazione alla transazione che vogliamo proteggere.

La distinzione è fondamentale:

Magento Event
     ≠
Transactional Outbox

L’Outbox è una garanzia architetturale sulla persistenza dell’evento.

Outbox Pattern vs Magento Message Queue

Anche questi due concetti non devono essere confusi.

Funzione Outbox Message Queue
Atomicità con DB non automaticamente
Persistenza evento
Comunicazione asincrona indiretta
Retry implementabile
Scaling consumer limitato
Routing limitato
Disaccoppiamento

La combinazione più robusta è spesso:

Transactional Outbox
        +
Message Broker
        +
Idempotent Consumer

Un’architettura Magento enterprise

In un ecommerce con diverse integrazioni possiamo arrivare a:

                        ┌─────────────────┐
                        │    Magento 2    │
                        └────────┬────────┘
                                 │
                         DB TRANSACTION
                                 │
                    ┌────────────┴────────────┐
                    │                         │
                    ▼                         ▼
              BUSINESS DATA                OUTBOX
                                              │
                                              ▼
                                       OUTBOX RELAY
                                              │
                                              ▼
                                         RabbitMQ
                                              │
               ┌──────────────────────────────┼─────────────────────────┐
               │                              │                         │
               ▼                              ▼                         ▼
          ERP Consumer                  CRM Consumer               PIM Consumer
               │                              │                         │
               ▼                              ▼                         ▼
              ERP                            CRM                       PIM

Magento diventa il produttore di eventi.

I sistemi esterni reagiscono agli eventi senza essere direttamente accoppiati alla transazione ecommerce.

Esempio: esportazione ordine verso ERP

Vediamo il flusso completo.

Il cliente completa il checkout:

PLACE ORDER

Magento esegue:

BEGIN TRANSACTION

INSERT sales_order
INSERT sales_order_item
INSERT outbox_event

COMMIT

La Outbox contiene:

order.created

Il relay legge l’evento:

OUTBOX
   ↓
RabbitMQ

Il consumer ERP riceve:

order.created

e chiama:

ERP API

Se l’ERP risponde correttamente:

SUCCESS

Se l’ERP non è disponibile:

ERROR
 ↓
RETRY

Dopo il limite massimo:

DLQ
 ↓
ALERT

Il checkout del cliente non dipende quindi dalla disponibilità momentanea dell’ERP.

Il vantaggio sulla customer experience

Questo aspetto è spesso sottovalutato.

Senza asincronicità:

Checkout
   ↓
Magento
   ↓
ERP call
   ↓
3 seconds
   ↓
response

il cliente paga direttamente la latenza dell’integrazione.

Con Outbox:

Checkout
   ↓
Magento transaction
   ↓
COMMIT
   ↓
response

e separatamente:

Outbox
   ↓
RabbitMQ
   ↓
ERP

Il sistema esterno non è più sul critical path della richiesta del cliente.

Cosa succede se l’ERP rimane offline per due ore?

Con un’integrazione sincrona potremmo avere:

ERP DOWN
   ↓
integration errors
   ↓
checkout problems

Con un’architettura asincrona:

ERP DOWN

Magento
 ↓
Outbox
 ↓
Queue
 ↓
Queue
 ↓
Queue

Quando l’ERP torna disponibile:

ERP UP
 ↓
Consumers restart processing
 ↓
backlog drained

Il sistema assorbe quindi temporaneamente il problema.

Questa proprietà viene spesso definita temporal decoupling.

Attenzione all’ordine degli eventi

Supponiamo che Magento generi rapidamente:

OrderCreated
OrderPaid
OrderCancelled

Il consumer dovrebbe idealmente elaborarli nello stesso ordine.

Altrimenti potremmo ricevere:

OrderCancelled
OrderCreated
OrderPaid

con risultati imprevedibili.

È quindi importante valutare:

  • ordering;
  • partition key;
  • aggregate ID;
  • sequence number;
  • caratteristiche del broker utilizzato.

Una possibile struttura dell’evento può includere:

{
    "aggregate_id": "123",
    "sequence": 3
}

Il consumer può così identificare eventi fuori sequenza.

Exactly once: attenzione alla promessa

Nei sistemi distribuiti è meglio non progettare l’integrazione assumendo che ogni evento venga elaborato esattamente una volta end-to-end.

Una strategia molto più robusta consiste nel progettare per:

At-least-once delivery
+
Idempotent processing

Il sistema accetta quindi che un evento possa essere consegnato più volte, ma garantisce che elaborarlo nuovamente non produca effetti indesiderati.

Questa filosofia semplifica enormemente la costruzione di integrazioni resilienti.

Cleanup della Outbox

La tabella Outbox cresce continuamente.

Non conviene mantenere indefinitamente tutti gli eventi elaborati nel database operativo Magento.

Possiamo introdurre una retention policy:

processed > 30 days
        ↓
archive/delete

oppure:

processed > 90 days
        ↓
archive

La scelta dipende dai requisiti di audit.

Gli eventi failed, invece, potrebbero avere una retention differente.

Monitoraggio

In produzione dovremmo monitorare almeno:

Pending events
Oldest pending event
Events processed/minute
Failed events
Retry count
DLQ size
Consumer lag

Uno degli indicatori più utili è:

Age of oldest pending event

Se normalmente un evento viene elaborato entro pochi secondi e improvvisamente troviamo:

Oldest event = 47 minutes

probabilmente esiste un problema nel relay, nel broker o nel sistema downstream.

Quando utilizzare l’Outbox Pattern in Magento 2

L’Outbox è particolarmente utile quando Magento deve comunicare eventi importanti verso sistemi esterni.

Per esempio:

ERP

OrderCreated
OrderCancelled
InvoiceCreated
ShipmentCreated
RefundCreated

OMS

OrderPlaced
OrderAllocated
OrderCancelled

CRM

CustomerCreated
CustomerUpdated

Warehouse

ShipmentRequested
ReturnRequested

Payment systems

PaymentCaptured
PaymentVoided
RefundRequested

Data platform

OrderCreated
CustomerRegistered
ProductUpdated

Più critica è l’informazione, maggiore è il valore del pattern.

Quando l’Outbox Pattern può essere eccessivo

Non tutte le integrazioni richiedono questa complessità.

Per operazioni non critiche come:

invalidate cache
send analytics event
update optional recommendation data

la perdita occasionale di un evento potrebbe essere accettabile.

L’architettura deve sempre essere proporzionata al rischio.

Se invece stiamo sincronizzando:

ordini
pagamenti
fatture
spedizioni
rimborsi
stock

la consistenza diventa molto più importante.

Outbox Pattern: vantaggi

L’adozione del pattern permette di ottenere diversi benefici.

Affidabilità

Un evento non viene perso semplicemente perché il sistema esterno è temporaneamente indisponibile.

Atomicità locale

Business data ed evento vengono salvati nella stessa transazione database.

Disaccoppiamento

Magento non deve conoscere la disponibilità immediata del sistema downstream.

Performance

Le API esterne vengono rimosse dal critical path delle operazioni utente.

Retry

Gli errori temporanei possono essere recuperati automaticamente.

Observability

Ogni evento possiede uno stato persistente e può essere tracciato.

Scalabilità

L’elaborazione può essere distribuita attraverso message broker e consumer multipli.

Gli svantaggi

Naturalmente il pattern introduce anche complessità.

Dobbiamo gestire:

  • tabella Outbox;
  • publisher/relay;
  • retry;
  • idempotenza;
  • cleanup;
  • monitoring;
  • ordering;
  • versioning degli eventi;
  • gestione degli errori.

Inoltre il sistema diventa eventually consistent.

Magento potrebbe avere già registrato l’ordine mentre l’ERP lo riceverà alcuni secondi dopo.

Nella maggior parte delle integrazioni enterprise questo compromesso è però preferibile a un’integrazione sincrona fragile.

Una possibile architettura di riferimento

Per un progetto Magento 2 con integrazioni enterprise adotteremmo una struttura simile:

Magento Domain
      │
      ▼
Domain/Application Event
      │
      ▼
Transactional Outbox
      │
      ▼
Outbox Publisher
      │
      ▼
RabbitMQ
      │
      ▼
Idempotent Consumer
      │
      ▼
Integration Service
      │
      ▼
ERP / OMS / CRM / External API

accompagnata da:

Retry
Backoff
DLQ
Correlation ID
Event Versioning
Monitoring
Alerting

Non stiamo più semplicemente “chiamando un’API”.

Stiamo progettando una integrazione distribuita affidabile.

Conclusioni

Integrare Magento 2 con un sistema esterno attraverso una semplice chiamata HTTP può funzionare nei progetti più piccoli, ma diventa fragile quando l’integrazione riguarda processi critici come ordini, pagamenti, spedizioni, fatture e stock.

Il problema fondamentale è il dual write:

Magento Database
+
External System

non possono normalmente essere aggiornati all’interno della stessa transazione.

Il Transactional Outbox Pattern risolve il problema cambiando strategia:

Business Data
+
Outbox Event

vengono salvati atomicamente nello stesso database.

Successivamente:

Outbox
  ↓
Message Broker
  ↓
Consumer
  ↓
External System

gestisce la comunicazione asincrona.

Combinando Transactional Outbox, RabbitMQ, retry, idempotenza, DLQ e observability è possibile costruire integrazioni Magento 2 molto più robuste e adatte a contesti enterprise.

Il punto centrale non è evitare completamente gli errori.

Nei sistemi distribuiti gli errori sono inevitabili.

L’obiettivo architetturale è costruire un sistema capace di assorbirli, ritentare le operazioni e recuperare automaticamente senza perdere informazioni.

FAQ

Cos’è l’Outbox Pattern in Magento 2?

Il Transactional Outbox Pattern consiste nel salvare un evento destinato a sistemi esterni nello stesso database e nella stessa transazione della modifica di business che lo ha generato. Un processo separato pubblica successivamente l’evento verso RabbitMQ o un altro sistema di messaging.

Perché usare l’Outbox Pattern con Magento 2?

È particolarmente utile nelle integrazioni con ERP, OMS, CRM, warehouse, sistemi di pagamento e altri servizi esterni, perché riduce il rischio di perdere eventi quando una modifica Magento è stata salvata ma la comunicazione esterna fallisce.

RabbitMQ sostituisce l’Outbox Pattern?

No. RabbitMQ gestisce il messaging asincrono, mentre l’Outbox risolve il problema dell’atomicità tra la modifica dei dati applicativi e l’intenzione di pubblicare un evento. Le due tecnologie possono essere utilizzate insieme.

L’Outbox Pattern garantisce exactly-once delivery?

Non necessariamente. Il relay può pubblicare nuovamente un messaggio se si verifica un errore dopo la pubblicazione ma prima dell’aggiornamento dello stato dell’Outbox. Per questo motivo i consumer dovrebbero essere idempotenti.

Cosa significa consumer idempotente?

Significa che elaborare più volte lo stesso evento produce lo stesso risultato di una singola elaborazione. Una strategia comune consiste nell’assegnare un UUID a ogni evento e registrare gli identificativi già elaborati.

È possibile implementare l’Outbox con RabbitMQ?

Sì. Una soluzione comune consiste nel salvare gli eventi nella Outbox durante la transazione Magento e utilizzare successivamente un relay per pubblicarli sul Message Queue Framework di Magento, utilizzando RabbitMQ come broker.

È necessario utilizzare RabbitMQ?

No. L’Outbox Pattern è indipendente dal broker. Gli eventi possono essere elaborati direttamente da un worker oppure pubblicati verso RabbitMQ, ActiveMQ, Kafka, SQS o altre infrastrutture di messaging, a seconda dell’architettura.

Outbox Pattern e Magento Observer sono la stessa cosa?

No. Un observer è un meccanismo applicativo per reagire agli eventi Magento. L’Outbox Pattern riguarda invece la persistenza transazionale affidabile dell’evento. Un observer può contribuire alla creazione dell’evento Outbox, ma non rende automaticamente l’operazione transazionale.

Come gestire gli errori verso ERP o API esterne?

È consigliabile distinguere gli errori temporanei da quelli permanenti, applicare retry con backoff ai primi e spostare gli eventi non recuperabili in uno stato failed o in una Dead Letter Queue.

Quali integrazioni Magento beneficiano maggiormente dell’Outbox?

Soprattutto quelle relative a ordini, pagamenti, rimborsi, fatture, spedizioni, resi, disponibilità e altre operazioni in cui la perdita di un messaggio può creare inconsistenze di business., Adobe Commerce Developer, Software Architect, Tech Lead, Solution Architect.

Fonti e approfondimenti

Adobe Commerce Developer Documentation — Message Queues

Documentazione ufficiale Adobe relativa al Message Queue Framework, ai consumer e alla configurazione dei broker supportati.

Adobe Commerce — RabbitMQ

Documentazione ufficiale relativa all’utilizzo e alla configurazione di RabbitMQ con Adobe Commerce.

AWS Prescriptive Guidance — Transactional Outbox Pattern

Descrizione del pattern, del dual write problem, delle strategie di implementazione e della necessità di gestire messaggi duplicati e consumer idempotenti.

Microservices.io — Transactional Outbox

Descrizione originale e approfondita del Transactional Outbox Pattern, dei partecipanti all’architettura e del Message Relay.