---
# === IDENTITY ===
id: business/erp-integration/outbox-pattern-erp/2026
canonical_question: "How do you implement the outbox pattern for reliable event publishing from ERP transactions?"
aliases:
  - "Transactional outbox pattern for ERP integration"
  - "How to guarantee event delivery from ERP database transactions"
  - "Outbox table pattern vs CDC vs dual-write for ERP events"
  - "Reliable event publishing from Salesforce SAP NetSuite Dynamics 365"
entity_type: erp_integration
domain: business > erp-integration > outbox-pattern-erp
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Cross-ERP (Pattern-level)"
    vendor: "N/A -- architecture pattern"
    version: "N/A"
    edition: "All"
    deployment: "cloud"
    api_surface: "Database + Message Broker"
  - name: "Debezium"
    vendor: "Red Hat"
    version: "2.x"
    edition: "Open Source"
    deployment: "cloud"
    api_surface: "Kafka Connect"
  - name: "Salesforce Platform Events"
    vendor: "Salesforce"
    version: "API v62.0"
    edition: "Enterprise+"
    deployment: "cloud"
    api_surface: "Platform Events, CDC"
  - name: "SAP Event Mesh"
    vendor: "SAP"
    version: "BTP 2024"
    edition: "Cloud"
    deployment: "cloud"
    api_surface: "REST, AMQP, MQTT"

# === VERIFICATION ===
last_verified: 2026-03-07
confidence: 0.87
version: 1.0
first_published: 2026-03-07

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: null
  next_review: 2026-09-03
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Outbox pattern provides at-least-once delivery, NOT exactly-once -- consumers MUST be idempotent"
  - "Polling relay introduces latency (typically 1-5s) -- use CDC relay for sub-second requirements"
  - "Outbox table grows unbounded without cleanup -- implement retention/archival policy"
  - "CDC-based relay (Debezium) requires database logical replication enabled -- not available on all managed DB tiers"
  - "Event ordering is guaranteed per aggregate only -- no global ordering across aggregates"
  - "ERP-native event systems (Salesforce Platform Events, SAP Event Mesh) have retention limits (3 days for SF, configurable for SAP)"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs strong ACID consistency across multiple ERPs in a single transaction"
    use_instead: "business/erp-integration/saga-pattern-erp-transactions/2026"
  - condition: "User needs to replicate entire ERP datasets for analytics"
    use_instead: "business/erp-integration/change-data-capture-erp/2026"
  - condition: "Integration is simple request-reply with no event delivery guarantee needed"
    use_instead: "Direct REST API call with retry -- outbox is overkill"
  - condition: "User wants event sourcing as the primary persistence model"
    use_instead: "Event sourcing pattern -- outbox is for traditional CRUD systems publishing events"

# === AGENT HINTS ===
inputs_needed:
  - key: integration_pattern
    question: "What integration pattern do you need?"
    type: choice
    options:
      - "real-time sync (individual records, <1s latency)"
      - "batch/bulk (scheduled, high volume)"
      - "event-driven (webhook, CDC, platform events)"
      - "guaranteed event delivery from database transactions"
  - key: relay_preference
    question: "What relay mechanism do you prefer?"
    type: choice
    options:
      - "Polling (simple, no extra infrastructure)"
      - "CDC/Debezium (low latency, requires Kafka)"
      - "ERP-native events (Salesforce Platform Events, SAP Event Mesh)"
      - "Not sure -- recommend based on constraints"
  - key: erp_system
    question: "Which ERP system is the event source?"
    type: choice
    options:
      - "Salesforce"
      - "SAP S/4HANA"
      - "Oracle NetSuite"
      - "Microsoft Dynamics 365"
      - "Custom / other system with relational DB"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/outbox-pattern-erp/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-03-07)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "business/erp-integration/idempotency-erp-integrations/2026"
      label: "Idempotency patterns for ERP integrations -- consumers must handle duplicate events"
  related_to:
    - id: "business/erp-integration/change-data-capture-erp/2026"
      label: "Change data capture for ERP -- CDC as relay mechanism or alternative to outbox"
    - id: "business/erp-integration/error-handling-dead-letter-queues/2026"
      label: "Error handling and dead letter queues for failed event delivery"
    - id: "business/erp-integration/erp-event-driven-comparison/2026"
      label: "Event-driven architecture comparison across ERPs"
  solves:
    - id: "business/erp-integration/order-to-cash-integration/2026"
      label: "Order-to-cash integration -- outbox ensures reliable order event propagation"
    - id: "business/erp-integration/procure-to-pay-integration/2026"
      label: "Procure-to-pay integration -- outbox ensures reliable PO/invoice event delivery"
  alternative_to:
    - id: "business/erp-integration/saga-pattern-erp-transactions/2026"
      label: "Saga pattern -- coordinates multi-step transactions; outbox ensures reliable event delivery within each step"
  often_confused_with:
    - id: "business/erp-integration/change-data-capture-erp/2026"
      label: "CDC captures ALL row changes; outbox publishes domain events explicitly. CDC can be the relay FOR outbox (Debezium), or an alternative pattern."

# === SOURCES ===
sources:
  - id: src1
    title: "Pattern: Transactional Outbox"
    author: Chris Richardson
    url: https://microservices.io/patterns/data/transactional-outbox.html
    type: technical_blog
    published: 2023-06-01
    reliability: authoritative
  - id: src2
    title: "Outbox Event Router -- Debezium Documentation"
    author: Red Hat / Debezium
    url: https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
    type: official_docs
    published: 2025-09-01
    reliability: authoritative
  - id: src3
    title: "Transactional Outbox Pattern -- AWS Prescriptive Guidance"
    author: Amazon Web Services
    url: https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src4
    title: "The Transactional Outbox Pattern: Reliable Event Publishing"
    author: James Carr
    url: https://james-carr.org/posts/2026-01-15-transactional-outbox-pattern/
    type: technical_blog
    published: 2026-01-15
    reliability: high
  - id: src5
    title: "The Transactional Outbox Pattern: A Rigorous Examination"
    author: Mubashar
    url: https://medium.com/@nustianrwp/the-transactional-outbox-pattern-a-rigorous-examination-for-distributed-systems-engineers-9c189836f470
    type: technical_blog
    published: 2026-01-01
    reliability: moderate_high
  - id: src6
    title: "Design Considerations for Change Data Capture and Platform Events"
    author: Salesforce
    url: https://developer.salesforce.com/blogs/2022/10/design-considerations-for-change-data-capture-and-platform-events
    type: official_docs
    published: 2022-10-01
    reliability: authoritative
  - id: src7
    title: "Reliable Microservices Data Exchange With the Outbox Pattern"
    author: Red Hat / Debezium
    url: https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/
    type: technical_blog
    published: 2019-02-19
    reliability: high
---

# Outbox Pattern for Reliable Event Publishing from ERP Transactions

## TL;DR

- **Bottom line**: Write events to an outbox table in the same database transaction as business data, then use a separate relay process (polling or CDC) to publish events to a message broker. This solves the dual-write problem -- either both the data change AND the event are committed, or neither is. [src1]
- **Key limit**: The pattern provides at-least-once delivery, not exactly-once. Consumers must be idempotent -- use event IDs and deduplication to handle inevitable duplicates. [src4]
- **Watch out for**: Dual-writing directly to both database and message broker (without outbox) is the #1 anti-pattern. If the broker write fails after the DB commit, the event is lost. If the DB write fails after broker publish, downstream systems act on phantom events. [src1]
- **Best for**: Any ERP integration where you need guaranteed event delivery from database transactions -- order creation, invoice posting, inventory adjustments, master data changes. [src3]
- **Authentication**: N/A for the pattern itself. Each ERP and message broker authenticates independently. The relay process needs credentials for both the source database and the target broker.

## System Profile

The transactional outbox pattern is an architecture pattern that applies to any system using a relational database for business data that needs to reliably publish events to external consumers. It is the canonical solution for the dual-write problem in distributed systems. [src1]

This card covers: (1) the general pattern with implementation in custom databases, (2) ERP-specific adaptations for Salesforce, SAP, NetSuite, and Dynamics 365, (3) relay mechanisms including polling publisher and CDC with Debezium, and (4) a decision framework comparing outbox vs CDC vs dual-write vs event sourcing.

| System | Role | API Surface | Direction |
|---|---|---|---|
| ERP Database (source) | Business data persistence + outbox table | SQL (same transaction) | Source |
| Relay Process | Reads outbox, publishes to broker | Polling SQL or CDC log tailing | Bridge |
| Message Broker (Kafka, RabbitMQ, SQS, Azure Service Bus) | Event distribution to consumers | Broker-native protocol | Target |
| Debezium (optional) | CDC-based relay -- reads DB transaction log | Kafka Connect | Bridge |

## API Surfaces & Capabilities

The outbox pattern does not define a specific API surface -- it defines how a database transaction atomically produces both business data and an event record. The relay mechanism then bridges the outbox table to the message broker. [src1, src3]

| Relay Mechanism | Latency | Infrastructure | DB Load | Ordering | Complexity |
|---|---|---|---|---|---|
| Polling Publisher | 1-5s (poll interval) | Minimal -- background worker | Moderate -- periodic queries | Per-aggregate (via sequence_id) | Low |
| Debezium CDC | Sub-second | Kafka + Kafka Connect | Minimal -- reads WAL/binlog | Per-partition (aggregate_id) | Medium |
| DynamoDB Streams | Sub-second | AWS-native | None -- built-in | Per-partition-key | Low (AWS-only) |
| Salesforce Platform Events | Near real-time | SF-native | None -- built-in | Per-replay-ID | Low (SF-only) |
| SAP Event Mesh | Near real-time | BTP | None -- built-in | Per-topic | Medium (SAP-only) |

## Rate Limits & Quotas

### Per-System Event Limits

| System | Limit Type | Value | Notes |
|---|---|---|---|
| Salesforce Platform Events | Standard-volume events | 100K/day (Enterprise), 500K/day (Unlimited) | High-volume requires add-on license |
| Salesforce CDC | Event delivery allocation | Shared with Platform Events daily limit | CDC generates 1 event per field change, not per record |
| SAP Event Mesh | Messages/month | 1M (standard), custom enterprise tiers | Queue depth: 1,000 messages default |
| NetSuite SuiteScript | Governance units per script | 1,000 (client), 10,000 (server) | Each https.request() costs 10 units |
| D365 Business Events | Endpoint throughput | Azure Service Bus limits apply | No D365-specific rate limit on events |
| Debezium (Kafka) | Throughput | 100K+ events/sec (Kafka cluster dependent) | Bottleneck is source DB WAL throughput |

[src6, src2]

### Outbox Table Performance Limits

| Metric | Recommended Threshold | Impact of Exceeding |
|---|---|---|
| Outbox table size | < 100,000 unpublished rows | Query performance degrades; index bloat |
| Poll interval | 1-5 seconds | Too low: DB load. Too high: delivery latency |
| Batch size per poll | 100-500 rows | Too large: long transactions. Too small: relay overhead |
| Row retention | Delete after publish + 24h buffer | Unbounded growth causes vacuum/analyze issues in PostgreSQL |

[src4]

## Authentication

| Component | Auth Method | Notes |
|---|---|---|
| Outbox table (same DB as business data) | DB credentials (connection pool) | Same transaction context -- no separate auth |
| Debezium connector | DB replication credentials | Requires REPLICATION role in PostgreSQL, binlog access in MySQL |
| Kafka broker | SASL/SCRAM or mTLS | Debezium connector config includes broker credentials |
| Salesforce Pub/Sub API | OAuth 2.0 JWT bearer | For external subscribers consuming Platform Events |
| SAP Event Mesh | OAuth 2.0 client credentials | BTP service binding provides credentials |
| Azure Service Bus | Managed Identity or SAS token | D365 Business Events connect via SAS policy |

### Authentication Gotchas

- Debezium requires a dedicated PostgreSQL replication slot -- if the slot is dropped while Debezium is down, events are lost permanently. Monitor replication slot lag. [src2]
- Salesforce Platform Event subscriptions use CometD replay IDs -- if the subscriber falls behind by >3 days (72h retention), events are lost. Use high-volume platform events (add-on license) for 72h guaranteed retention. [src6]
- SAP Event Mesh requires BTP entitlement -- not included in base S/4HANA Cloud license. Check your BTP subscription before architecting around Event Mesh.

## Constraints
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- At-least-once delivery only -- exactly-once requires idempotent consumers or transactional message consumption on the consumer side
- Outbox table must be in the SAME database as business data -- cross-database transactions defeat the purpose of the pattern
- CDC relay (Debezium) requires database support: PostgreSQL (logical replication), MySQL (binlog), SQL Server (CT/CDC), Oracle (LogMiner) -- NoSQL support varies
- Event ordering is per-aggregate (partition key) only -- no global ordering guarantee across different aggregates
- Polling relay has a latency floor equal to the poll interval -- cannot achieve sub-second latency with polling
- ERP-native event systems (Salesforce Platform Events, SAP Event Mesh) are NOT true outbox implementations -- they approximate the pattern but have platform-specific limitations (retention, volume caps, delivery semantics)

## Integration Pattern Decision Tree

```
START -- Need reliable event publishing from ERP transactions?
|
+-- Do you control the database schema?
|   |
|   +-- YES (custom DB, self-managed ERP)
|   |   |
|   |   +-- Need sub-second latency?
|   |   |   +-- YES --> Debezium CDC relay with outbox table
|   |   |   +-- NO --> Polling publisher (simpler, fewer moving parts)
|   |   |
|   |   +-- Already running Kafka?
|   |       +-- YES --> Debezium outbox event router (purpose-built)
|   |       +-- NO --> Polling publisher to SQS/RabbitMQ/Service Bus
|   |
|   +-- NO (SaaS ERP: Salesforce, NetSuite, D365)
|       |
|       +-- Which ERP?
|           +-- Salesforce --> Platform Events + CDC (native outbox equivalent)
|           |   +-- High volume (>100K events/day)? --> High-Volume Platform Events (add-on)
|           |   +-- Standard volume? --> Standard Platform Events
|           |
|           +-- SAP S/4HANA Cloud --> Event Mesh + Business Event Handling
|           |   +-- On-premise SAP? --> Event Add-on for ERP + AEM
|           |
|           +-- NetSuite --> SuiteScript afterSubmit + custom outbox record
|           |   +-- Webhook-style? --> User Event Script with https.post()
|           |   +-- Reliable? --> Custom outbox record + Scheduled Script relay
|           |
|           +-- D365 F&O --> Business Events + Azure Service Bus
|           +-- D365 CE/Dataverse --> Dataverse Business Events + Power Automate/Webhooks
|
+-- Pattern comparison (see Cross-System Comparison)
    +-- Need domain-specific events? --> Outbox pattern
    +-- Need all row changes captured? --> CDC (without outbox)
    +-- Cannot modify application code? --> CDC on existing tables
    +-- Need audit trail of all state changes? --> Event sourcing
```

## Quick Reference

### Outbox Table Schema (PostgreSQL)

| Column | Type | Purpose | Notes |
|---|---|---|---|
| `id` | `UUID` | Unique event identifier | PK, used for consumer deduplication |
| `sequence_id` | `BIGSERIAL` | Relay ordering | Monotonically increasing; DO NOT use `created_at` for ordering |
| `aggregate_type` | `VARCHAR(255)` | Entity type (e.g., `SalesOrder`, `Invoice`) | Used for topic routing |
| `aggregate_id` | `UUID` | Business entity ID | Kafka partition key; ensures per-entity ordering |
| `event_type` | `VARCHAR(255)` | Domain event name (e.g., `OrderCreated`) | Consumer uses this for deserialization |
| `payload` | `JSONB` | Serialized event data | Keep under 1MB for Kafka compatibility |
| `created_at` | `TIMESTAMPTZ` | Event timestamp | NOT used for ordering (concurrent txns can produce out-of-order timestamps) |
| `published_at` | `TIMESTAMPTZ` | When relay published event | NULL = unpublished; set by relay after broker ack |

[src4, src7]

### Debezium Outbox Event Router Configuration

| Property | Default | Purpose |
|---|---|---|
| `transforms.outbox.type` | `io.debezium.transforms.outbox.EventRouter` | Enable outbox SMT |
| `route.by.field` | `aggregatetype` | Column that determines Kafka topic |
| `route.topic.replacement` | `outbox.event.${routedByValue}` | Topic naming pattern |
| `table.field.event.id` | `id` | Unique event ID column |
| `table.field.event.key` | `aggregateid` | Kafka message key column |
| `table.field.event.payload` | `payload` | Event data column |
| `table.expand.json.payload` | `false` | Expand JSON strings in payload |
| `table.op.invalid.behavior` | `warn` | Handle non-INSERT operations |

[src2]

## Step-by-Step Integration Guide

### 1. Create the outbox table

Create the outbox table in the same database as your business data. The table schema must support reliable ordering (use `BIGSERIAL`, not timestamps) and efficient polling (partial index on unpublished rows). [src4]

```sql
-- PostgreSQL outbox table
CREATE TABLE outbox_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sequence_id     BIGSERIAL NOT NULL,
    aggregate_type  VARCHAR(255) NOT NULL,
    aggregate_id    UUID NOT NULL,
    event_type      VARCHAR(255) NOT NULL,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at    TIMESTAMPTZ
);

-- Partial index for unpublished events (relay queries ONLY unpublished rows)
CREATE INDEX idx_outbox_unpublished
    ON outbox_events (sequence_id)
    WHERE published_at IS NULL;

-- Index for cleanup job
CREATE INDEX idx_outbox_published_at
    ON outbox_events (published_at)
    WHERE published_at IS NOT NULL;
```

**Verify**: `SELECT COUNT(*) FROM outbox_events WHERE published_at IS NULL;` --> expected: `0` (empty table initially)

### 2. Write business data and outbox event in same transaction

The critical guarantee: business data and outbox event are committed atomically. If either fails, both roll back. [src1]

```python
# Python (psycopg2 / SQLAlchemy)
# Input:  Order data to persist
# Output: Committed order + outbox event in same transaction

import uuid
import json
from datetime import datetime

def create_order_with_event(conn, order_data):
    """Write order + outbox event in single transaction."""
    order_id = uuid.uuid4()
    event_id = uuid.uuid4()

    with conn.cursor() as cur:
        # Step 1: Insert business data
        cur.execute("""
            INSERT INTO sales_orders (id, customer_id, total, status)
            VALUES (%s, %s, %s, 'created')
        """, (str(order_id), order_data['customer_id'], order_data['total']))

        # Step 2: Insert outbox event (SAME transaction)
        cur.execute("""
            INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
            VALUES (%s, %s, %s, %s, %s)
        """, (
            str(event_id),
            'SalesOrder',
            str(order_id),
            'OrderCreated',
            json.dumps({
                'order_id': str(order_id),
                'customer_id': order_data['customer_id'],
                'total': str(order_data['total']),
                'timestamp': datetime.utcnow().isoformat()
            })
        ))

    conn.commit()  # Atomic -- both or neither
    return order_id
```

**Verify**: `SELECT COUNT(*) FROM outbox_events WHERE published_at IS NULL;` --> expected: `1`

### 3. Implement the polling publisher relay

A background process polls the outbox table, publishes events to the broker, and marks them as published. Use `FOR UPDATE SKIP LOCKED` for safe concurrent relay instances. [src4]

```python
# Python polling relay
# Input:  Outbox table with unpublished events
# Output: Events published to Kafka, outbox rows marked published

from confluent_kafka import Producer
import json
import time

def poll_and_publish(conn, producer, batch_size=100, poll_interval=2.0):
    """Poll outbox table and publish to Kafka."""
    while True:
        with conn.cursor() as cur:
            # Fetch unpublished events, lock rows to prevent duplicate relay
            cur.execute("""
                SELECT id, aggregate_type, aggregate_id, event_type, payload
                FROM outbox_events
                WHERE published_at IS NULL
                ORDER BY sequence_id ASC
                LIMIT %s
                FOR UPDATE SKIP LOCKED
            """, (batch_size,))

            rows = cur.fetchall()
            if not rows:
                time.sleep(poll_interval)
                continue

            published_ids = []
            for row in rows:
                event_id, agg_type, agg_id, event_type, payload = row
                topic = f"outbox.event.{agg_type}"

                producer.produce(
                    topic=topic,
                    key=str(agg_id),           # Partition by aggregate
                    value=json.dumps(payload),
                    headers={
                        'event_id': str(event_id),
                        'event_type': event_type
                    }
                )
                published_ids.append(str(event_id))

            producer.flush()  # Wait for broker acknowledgment

            # Mark as published AFTER broker confirms
            cur.execute("""
                UPDATE outbox_events
                SET published_at = NOW()
                WHERE id = ANY(%s)
            """, (published_ids,))

        conn.commit()
        time.sleep(poll_interval)
```

**Verify**: `SELECT COUNT(*) FROM outbox_events WHERE published_at IS NULL;` --> expected: `0` (all events published)

### 4. Configure Debezium CDC relay (alternative to polling)

If you run Kafka, use Debezium's outbox event router for sub-second relay without polling overhead. [src2]

```json
{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "erp-db.example.com",
    "database.port": "5432",
    "database.user": "debezium_replication",
    "database.password": "${DEBEZIUM_DB_PASSWORD}",
    "database.dbname": "erp_production",
    "topic.prefix": "erp",
    "table.include.list": "public.outbox_events",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "aggregate_type",
    "transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "aggregate_id",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.expand.json.payload": "true",

    "plugin.name": "pgoutput",
    "slot.name": "outbox_slot",
    "publication.name": "outbox_publication"
  }
}
```

**Verify**: `curl http://kafka-connect:8083/connectors/outbox-connector/status` --> expected: `{"connector":{"state":"RUNNING"}}`

### 5. Implement outbox table cleanup

Published events must be cleaned up to prevent unbounded table growth. Run a scheduled job to delete or archive old published events. [src4]

```sql
-- Delete published events older than 7 days (keep buffer for replay/debugging)
DELETE FROM outbox_events
WHERE published_at IS NOT NULL
  AND published_at < NOW() - INTERVAL '7 days';

-- For Debezium CDC relay: DELETE the row immediately after insert
-- (Debezium captures the INSERT from the WAL, row doesn't need to persist)
-- This keeps the outbox table near-empty at all times
```

**Verify**: `SELECT pg_size_pretty(pg_total_relation_size('outbox_events'));` --> expected: size should remain stable over time

## Code Examples

### Python: Idempotent consumer with deduplication

```python
# Input:  Kafka message from outbox relay
# Output: Processed event with duplicate detection

import json

def process_event(conn, message):
    """Idempotent event consumer -- deduplicates by event_id."""
    event_id = dict(message.headers()).get('event_id', b'').decode()
    payload = json.loads(message.value())

    with conn.cursor() as cur:
        # Check if already processed (idempotency table)
        cur.execute(
            "SELECT 1 FROM processed_events WHERE event_id = %s",
            (event_id,)
        )
        if cur.fetchone():
            return  # Skip duplicate

        # Process the event (business logic here)
        handle_order_created(payload)

        # Record as processed
        cur.execute(
            "INSERT INTO processed_events (event_id, processed_at) VALUES (%s, NOW())",
            (event_id,)
        )
    conn.commit()
```

### JavaScript/Node.js: Outbox write with Knex.js

```javascript
// Input:  Order data + Knex transaction
// Output: Order + outbox event committed atomically

const { v4: uuidv4 } = require('uuid'); // uuid@9.x

async function createOrderWithOutbox(knex, orderData) {
  return knex.transaction(async (trx) => {
    const orderId = uuidv4();

    // Business data
    await trx('sales_orders').insert({
      id: orderId,
      customer_id: orderData.customerId,
      total: orderData.total,
      status: 'created'
    });

    // Outbox event (same transaction)
    await trx('outbox_events').insert({
      id: uuidv4(),
      aggregate_type: 'SalesOrder',
      aggregate_id: orderId,
      event_type: 'OrderCreated',
      payload: JSON.stringify({
        order_id: orderId,
        customer_id: orderData.customerId,
        total: orderData.total
      })
    });

    return orderId;
  });
}
```

### Salesforce Apex: Platform Events as outbox equivalent

```java
// Input:  Salesforce Opportunity closed-won trigger
// Output: Platform Event published in same transaction context

trigger OpportunityClosedWon on Opportunity (after update) {
    List<Order_Event__e> events = new List<Order_Event__e>();

    for (Opportunity opp : Trigger.new) {
        Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
        if (opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won') {
            events.add(new Order_Event__e(
                Order_Id__c = opp.Id,
                Account_Id__c = opp.AccountId,
                Amount__c = opp.Amount,
                Event_Type__c = 'OpportunityClosedWon'
            ));
        }
    }

    if (!events.isEmpty()) {
        // Platform Events participate in the trigger transaction context
        // If the trigger transaction rolls back, events are NOT published
        List<Database.SaveResult> results = EventBus.publish(events);
        for (Database.SaveResult sr : results) {
            if (!sr.isSuccess()) {
                System.debug('Event publish failed: ' + sr.getErrors());
            }
        }
    }
}
```

## Data Mapping

### Outbox Event Schema Mapping Across ERPs

| ERP Source Event | Outbox aggregate_type | Outbox event_type | Key Payload Fields | Gotcha |
|---|---|---|---|---|
| Salesforce Opportunity closed-won | `SalesOrder` | `OrderCreated` | `opportunity_id`, `account_id`, `amount`, `currency` | Amount is in org's default currency unless multi-currency enabled |
| SAP Sales Order (VA01) | `SalesOrder` | `SAPOrderCreated` | `VBELN`, `KUNNR`, `NETWR`, `WAERK` | SAP amounts in smallest currency unit (cents) -- divide by 100 |
| NetSuite Sales Order created | `SalesOrder` | `NSOrderCreated` | `internalid`, `entity`, `total`, `subsidiary` | Multi-subsidiary orgs: entity ID is subsidiary-scoped |
| D365 Sales Order confirmed | `SalesOrder` | `D365OrderConfirmed` | `SalesOrderNumber`, `OrderAccount`, `InvoiceAccount` | OrderAccount vs InvoiceAccount can differ -- map carefully |

### Data Type Gotchas

- Salesforce datetime is always UTC; SAP timestamps depend on user timezone preference in SU01. Always normalize to UTC in outbox payload. [src6]
- NetSuite amounts use the subsidiary's base currency by default. Multi-currency transactions require explicit `currencyrecord` field in the payload.
- SAP BAPI amounts return strings with leading zeros (e.g., `0000001234.56`) -- parse and normalize before inserting into outbox payload.
- D365 OData dates use ISO 8601 format but may include timezone offset. Kafka consumers expecting UTC will misparse `2026-03-07T14:30:00+01:00`.

## Error Handling & Failure Points

### Common Error Scenarios

| Scenario | Impact | Resolution |
|---|---|---|
| Relay crashes after Kafka publish, before marking outbox row published | Duplicate event on next relay cycle | Consumer idempotency (deduplication by event_id) |
| Database transaction timeout during outbox write | Both business data and event rolled back (safe) | Retry the business operation |
| Kafka broker unavailable during relay | Events accumulate in outbox table | Relay retries with exponential backoff; alert if outbox depth > threshold |
| Debezium replication slot dropped | Events between slot drop and recreation are lost permanently | Monitor WAL lag; alert on slot state changes |
| Consumer processing fails | Event stays in consumer's dead letter queue | DLQ with manual review + retry mechanism |
| Outbox table bloat (no cleanup) | Increasing query latency, disk usage, vacuum overhead | Scheduled cleanup job (delete published events > retention window) |

[src4, src3]

### Failure Points in Production

- **Replication slot lag (Debezium)**: If Debezium falls behind, PostgreSQL retains WAL segments, eventually filling disk. Fix: `monitor pg_replication_slots.active and pg_wal_lsn_diff(); alert when lag exceeds 100MB`. [src2]
- **Polling relay contention**: Multiple relay instances polling without `FOR UPDATE SKIP LOCKED` process the same events, causing duplicates. Fix: `always use FOR UPDATE SKIP LOCKED in the poll query`. [src4]
- **Outbox table vacuum bloat (PostgreSQL)**: High insert+delete rate on outbox table causes autovacuum to fall behind. Fix: `set autovacuum_vacuum_scale_factor = 0.01 and autovacuum_analyze_scale_factor = 0.005 on the outbox table specifically`. [src4]
- **Salesforce Platform Event publish failure**: EventBus.publish() can silently fail if daily event limit is reached. Fix: `check Database.SaveResult errors and implement fallback logging`. [src6]
- **Consumer offset loss (Kafka)**: Consumer group rebalance causes offset to reset to earliest, reprocessing all events. Fix: `idempotent consumer with processed_events table + manual offset management for critical flows`. [src3]

## Anti-Patterns

### Wrong: Dual-write -- publish event directly after DB commit

```python
# BAD -- dual-write: event can be lost if publish fails after commit
def create_order_bad(conn, producer, order_data):
    order_id = save_order_to_db(conn, order_data)  # DB commit
    producer.produce('orders', json.dumps({'order_id': order_id}))  # Broker write
    # If this line fails (network error, broker down), event is LOST
    # The order exists in DB but no downstream system knows about it
```

### Correct: Outbox -- event written in same transaction

```python
# GOOD -- outbox: event is guaranteed to be published eventually
def create_order_good(conn, order_data):
    with conn.cursor() as cur:
        order_id = uuid.uuid4()
        cur.execute("INSERT INTO sales_orders ...", (order_id, ...))
        cur.execute("INSERT INTO outbox_events ...", (order_id, 'OrderCreated', ...))
    conn.commit()  # Atomic -- both or neither
    # Relay process will publish the event from outbox_events
```

### Wrong: Application-level event publishing inside transaction

```python
# BAD -- publishing inside the transaction: if publish succeeds but
# transaction rolls back, consumers act on phantom events
def create_order_phantom(conn, producer, order_data):
    conn.begin()
    order_id = insert_order(conn, order_data)
    producer.produce('orders', json.dumps({'order_id': order_id}))
    # If conn.commit() fails here, event was already published!
    # Consumer processes an order that doesn't exist
    conn.commit()
```

### Correct: Publish only from the relay, never from the application

```python
# GOOD -- application writes to outbox only; relay handles publishing
# Application code is simple and never touches the message broker
def create_order_correct(conn, order_data):
    conn.begin()
    order_id = insert_order(conn, order_data)
    insert_outbox_event(conn, 'OrderCreated', order_id, order_data)
    conn.commit()
    # Done. Relay process handles broker publishing separately.
```

### Wrong: Using timestamps for relay ordering

```sql
-- BAD -- concurrent transactions can produce identical or out-of-order timestamps
SELECT * FROM outbox_events
WHERE published_at IS NULL
ORDER BY created_at ASC;
-- Two transactions committing at the same millisecond: undefined order
```

### Correct: Using monotonic sequence for ordering

```sql
-- GOOD -- BIGSERIAL guarantees monotonic ordering within the database
SELECT * FROM outbox_events
WHERE published_at IS NULL
ORDER BY sequence_id ASC
FOR UPDATE SKIP LOCKED;
-- sequence_id is always monotonically increasing, even under concurrency
```

## Common Pitfalls

- **Not implementing consumer idempotency**: Outbox guarantees at-least-once, meaning duplicates WILL happen. Fix: `every consumer must track processed event_ids and skip duplicates`. [src1]
- **Polling without SKIP LOCKED**: Multiple relay instances process the same batch, causing massive duplication. Fix: `FOR UPDATE SKIP LOCKED in every poll query`. [src4]
- **Outbox table on a different database**: Cross-database transactions are unreliable and defeat the outbox pattern's atomicity guarantee. Fix: `outbox table MUST be in the same database as business data`. [src1]
- **Ignoring Debezium replication slot health**: A dropped or lagging replication slot silently loses events. Fix: `monitor pg_replication_slots, alert on inactive slots and WAL lag > 100MB`. [src2]
- **Publishing full entity state in every event**: Payload bloat slows Kafka, increases storage cost, and causes consumer parsing overhead. Fix: `publish only changed fields + entity ID; let consumers fetch full state if needed`. [src4]
- **No cleanup job for published events**: Outbox table grows to millions of rows, degrading poll query performance and causing PostgreSQL vacuum issues. Fix: `scheduled DELETE of published events older than retention window (7 days recommended)`. [src4]

## Diagnostic Commands

```bash
# Check outbox depth (unpublished events) -- should be near 0
psql -c "SELECT COUNT(*) as pending, MIN(created_at) as oldest_pending FROM outbox_events WHERE published_at IS NULL;"

# Check outbox table size
psql -c "SELECT pg_size_pretty(pg_total_relation_size('outbox_events')) as total_size;"

# Check Debezium connector status
curl -s http://kafka-connect:8083/connectors/outbox-connector/status | jq '.connector.state'

# Check PostgreSQL replication slot lag (Debezium)
psql -c "SELECT slot_name, active, pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) as lag_bytes FROM pg_replication_slots;"

# Check Kafka consumer group lag
kafka-consumer-groups.sh --bootstrap-server kafka:9092 --group outbox-consumer --describe

# Check Salesforce Platform Event usage (REST API)
curl -H "Authorization: Bearer $SF_TOKEN" \
  "https://yourorg.my.salesforce.com/services/data/v62.0/limits" | jq '.DailyStandardVolumePlatformEvents'

# Monitor outbox relay throughput (events published per minute)
psql -c "SELECT date_trunc('minute', published_at) as minute, COUNT(*) as events_published FROM outbox_events WHERE published_at > NOW() - INTERVAL '1 hour' GROUP BY 1 ORDER BY 1;"
```

## Version History & Compatibility

| Component | Version | Status | Notes |
|---|---|---|---|
| Debezium Outbox Event Router | 2.x (current) | GA | Stable since 1.9; MongoDB requires separate MongoEventRouter |
| Debezium Outbox Event Router | 1.x | Deprecated | Upgrade to 2.x for improved JSON expansion and header routing |
| Salesforce Platform Events | API v62.0 (Spring '26) | Current | High-volume events GA since Winter '22 |
| Salesforce CDC | API v62.0 (Spring '26) | Current | Enriched change events since Summer '23 |
| SAP Event Mesh | BTP 2024 | Current | Renamed from SAP Enterprise Messaging; Advanced Event Mesh is successor |
| SAP Advanced Event Mesh | BTP 2025 | Current | Successor to Event Mesh; Solace-based, higher throughput |
| D365 Business Events | 2024 Release Wave 2 | Current | Available in F&O and CE/Dataverse |

### Deprecation Policy

The outbox pattern itself is a stable architectural pattern with no deprecation risk. Debezium follows semantic versioning with at least 6 months of support for major versions. ERP-native event systems follow vendor release cycles -- Salesforce (3 releases/year), SAP (continuous on BTP), Microsoft (2 release waves/year). [src2]

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Business transaction must guarantee downstream event delivery | Simple request-reply integration with no delivery guarantee needed | Direct REST API call with retry |
| Source system uses a relational database (PostgreSQL, MySQL, SQL Server, Oracle) | Source system is a SaaS ERP with no DB access (Salesforce, NetSuite) | ERP-native events (Platform Events, SuiteScript) |
| Need domain events (OrderCreated, InvoicePosted) not just row changes | Need ALL row-level changes captured regardless of business meaning | CDC without outbox (Debezium on business tables directly) |
| Multiple consumers need the same event (fan-out) | Single consumer, point-to-point integration | Direct API call or simple queue |
| Already running Kafka and want sub-second relay | No Kafka and no plans to adopt it | Polling relay to SQS or RabbitMQ |
| Need event replay for debugging or rebuilding consumer state | Events are ephemeral notifications with no replay value | Fire-and-forget webhooks |

## Cross-System Comparison

| Capability | Outbox + Polling | Outbox + Debezium CDC | Dual-Write (no outbox) | CDC on Business Tables | Event Sourcing |
|---|---|---|---|---|---|
| **Atomicity** | Guaranteed (same DB txn) | Guaranteed (same DB txn) | NOT guaranteed | N/A (reads existing txn log) | Guaranteed (event IS the write) |
| **Delivery guarantee** | At-least-once | At-least-once | At-most-once (events can be lost) | At-least-once | At-least-once |
| **Latency** | 1-5s (poll interval) | Sub-second | Near real-time | Sub-second | Sub-second |
| **DB load** | Moderate (polling queries) | Minimal (reads WAL) | None (no outbox) | Minimal (reads WAL) | None (events are primary store) |
| **Infrastructure** | Background worker only | Kafka + Kafka Connect | None additional | Kafka + Kafka Connect | Event store (EventStoreDB, Kafka) |
| **Event semantics** | Domain events (explicit) | Domain events (explicit) | Application-level (fragile) | Row-level changes (implicit) | Domain events (inherent) |
| **Schema control** | Full control of outbox schema | Full control + Debezium routing | No schema control | Tied to DB table schema | Full event schema control |
| **Code changes** | Application must write to outbox | Application must write to outbox | Application publishes to broker | NONE -- non-intrusive | Complete rewrite required |
| **Ordering** | Per-aggregate (sequence_id) | Per-partition (aggregate_id) | No guarantee | Per-table | Per-aggregate (event version) |
| **Replay** | Yes (retain published events) | Yes (Kafka retention) | No | Yes (Kafka retention) | Yes (event store is the replay log) |
| **Best for** | Simple setups, no Kafka | Production-grade, already have Kafka | Prototype only (unsafe for production) | Legacy systems, no code changes | Greenfield, complex domain logic |

[src1, src3, src4]

## Important Caveats

- The outbox pattern provides at-least-once delivery, NOT exactly-once. Any architecture that claims exactly-once delivery from the outbox pattern is either wrong or has pushed the complexity to the consumer side (transactional message consumption).
- ERP-native event systems (Salesforce Platform Events, SAP Event Mesh, D365 Business Events) are NOT true outbox implementations -- they approximate the pattern but have platform-specific retention limits, volume caps, and delivery semantics that differ from a database-backed outbox.
- CDC-based relay (Debezium) requires careful operational management -- replication slot monitoring, WAL retention policies, and connector health checks are production-critical operational concerns, not optional.
- Outbox table cleanup is not optional. Without a retention policy, the table grows unbounded, causing query degradation, index bloat, and PostgreSQL autovacuum pressure that can impact the entire database.
- This card covers the pattern at an architecture level. Each ERP system has specific implementation details -- see the related units for Salesforce Platform Events, SAP Event Mesh, NetSuite SuiteScript, and D365 Business Events for system-specific guidance.

## Related Units

- [Idempotency Patterns for ERP Integrations](/business/erp-integration/idempotency-erp-integrations/2026) -- consumer-side deduplication is mandatory with outbox
- [Change Data Capture for ERP](/business/erp-integration/change-data-capture-erp/2026) -- CDC as relay mechanism or alternative
- [Error Handling and Dead Letter Queues](/business/erp-integration/error-handling-dead-letter-queues/2026) -- handling failed event delivery
- [Event-Driven Architecture Comparison](/business/erp-integration/erp-event-driven-comparison/2026) -- comparing event approaches across ERPs
- [Saga Pattern for ERP Transactions](/business/erp-integration/saga-pattern-erp-transactions/2026) -- outbox ensures reliable delivery within each saga step
