Messaging and Event-Driven Integration

Asynchronous messaging is how you decouple an ERP from everything else without giving up consistency where it matters.

Why messaging in ERP

Problem Messaging solution
The 3PL’s API is down for 20 minutes Queue and retry
An MES line produces 3,000 confirmations/minute Buffer and process at your own rate
12 systems need to know about a shipment Publish once, 12 subscribe
A partner needs an event within a second, not at the nightly batch Event-driven
Peak load at month-end Absorb the spike in the queue

Queue vs topic

QUEUE (point to point)              TOPIC (publish/subscribe)
                                       ┌──▶ Analytics
Producer ──▶ [ Queue ] ──▶ Consumer    │
             one consumer   Producer ──┼──▶ Customer portal
             gets each msg             │
                                       └──▶ Notification service

Queues for commands (“post this invoice”) — exactly one processor. Topics for events (“an invoice was posted”) — anyone interested.

Command vs event — the distinction that matters

COMMAND:  "CreateSalesOrder"       imperative, one handler, can be rejected
EVENT:    "SalesOrderCreated"      past tense, many listeners, a statement of fact

Events are facts. They cannot be rejected. If you publish SalesOrderCreated and a subscriber doesn’t like it, that’s the subscriber’s problem — the order exists. This is what makes event-driven systems decoupled: the publisher doesn’t know or care who listens.

Rule: use past-tense names for events. OrderShipped, not ShipOrder. If you find yourself publishing an “event” that a subscriber can veto, you’ve built a command with extra steps.

Event design

{
  "eventId": "01HQZX8K3M7N2P4R6T8V0W2Y4A",
  "eventType": "Inventory.GoodsIssued",
  "eventVersion": "1.2",
  "occurredAt": "2026-03-15T14:22:11.483Z",
  "tenantId": 1001,
  "correlationId": "req-88213",
  "causationId": "evt-88210",
  "source": "erp.inventory",
  "data": {
    "materialDocument": "4900012345",
    "materialDocumentYear": 2026,
    "plant": "DHK1",
    "movementType": "601",
    "items": [
      { "item": 1, "material": "FAB-1000", "batch": "B2601",
        "quantity": -500, "uom": "EA", "storageLocation": "FG",
        "referenceDocument": "0080001234" }
    ]
  }
}

Essential fields:

  • eventId — unique, for deduplication
  • eventType and eventVersion — routing and schema evolution
  • occurredAt — when it happened, not when it was published
  • tenantId — multi-tenant routing and isolation
  • correlationId — trace one business action across systems
  • causationId — which event caused this one

Correlation and causation IDs are what make distributed debugging possible. Without them, “why did this customer get two shipping notifications?” is unanswerable.

Thin vs fat events

THIN:  { "eventType": "OrderShipped", "orderId": "4711" }
       Subscriber calls back for details.
       ✅ Small, no stale data, no data leakage
       ❌ N callbacks, publisher must stay available

FAT:   { "eventType": "OrderShipped", "orderId": "4711",
         "customer": {...}, "items": [...], "tracking": {...} }
       ✅ Self-contained, subscriber works even if the publisher is down
       ❌ Large, may leak data to subscribers who shouldn't see it, versioning is harder

Practical answer: fat enough that the common subscriber needs no callback. Include the identifiers and the fields 80% of consumers need. Sensitive fields (pricing, margins, personal data) stay out and require a scoped callback.

The transactional outbox

The single most important pattern here. Repeated because it matters:

BEGIN;
  INSERT INTO material_document (...);
  UPDATE stock SET quantity = quantity - 500 WHERE ...;
  INSERT INTO journal_line (...);
  INSERT INTO outbox (event_id, event_type, payload, created_at)
    VALUES (:id, 'Inventory.GoodsIssued', :payload, now());
COMMIT;

A separate relay reads unpublished outbox rows and publishes them, marking them published.

Why: publishing to a broker inside application code gives you two failure modes — a published event for a rolled-back transaction, or a committed transaction whose event was lost. The outbox eliminates both, because the event is written with the same ACID guarantees as the business data.

Alternative: Change Data Capture (Debezium, native logical replication) reads the database WAL and publishes changes. Zero application code, but it publishes table changes rather than business events, so consumers end up reverse-engineering meaning from row diffs. Fine for replication into a warehouse; poor for business integration.

Consumer patterns

Idempotent consumers

CREATE TABLE processed_event (
  event_id VARCHAR(50) PRIMARY KEY,
  consumer  VARCHAR(50) NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL
);
INSERT INTO processed_event VALUES (:event_id, :consumer, now())
  ON CONFLICT DO NOTHING;
-- 0 rows → already processed → skip

Mandatory. All brokers deliver at least once; some deliver more than once under failover.

Ordering

Where order matters, use an ordering key so all events for one entity go to one partition:

partition key = tenantId + ":" + customerId

This guarantees CustomerCreated is processed before CustomerUpdated for the same customer. It also caps parallelism per key — a trade-off worth making explicit.

Better still: design for order independence. Full-state snapshots (“here is customer 100 as of now, version 7”) with a version check are order-tolerant. Deltas (“add 500 to the credit limit”) are not.

Poison messages

A message that always fails will block the partition forever if you retry indefinitely.

attempt 1 → fail → retry in 1s
attempt 2 → fail → retry in 5s
attempt 3 → fail → retry in 30s
attempt 4 → fail → retry in 5m
attempt 5 → fail → DEAD LETTER QUEUE + alert

DLQ requirements: the payload, the error, the retry history, an owner, an alert, and a reprocess action.

Saga / process manager

Business processes spanning services can’t use a distributed transaction. Sagas use compensating actions instead.

Order fulfilment saga:
  1. Reserve inventory      → compensate: release reservation
  2. Charge payment         → compensate: refund
  3. Create shipment        → compensate: cancel shipment
  4. Post revenue           → compensate: credit note

If step 3 fails: compensate 2, compensate 1, mark the order failed.

Compensation is not rollback. A refund is a new transaction that leaves a trace, not an undo. In ERP this is actually natural — reversal documents work exactly this way.

Orchestration (a central coordinator drives each step) is easier to reason about and debug than choreography (each service reacts to events). For ERP processes with real money involved, orchestration is usually the right choice — you need one place to see where a stuck order is.

Broker choice

Broker Fit
Kafka High volume, event streaming, replay from a retained log, multiple independent consumers
RabbitMQ Flexible routing, work queues, moderate volume, good ergonomics
AWS SQS/SNS/EventBridge Managed, simple, good enough for most ERP integration
Azure Service Bus Managed, sessions for ordering, strong .NET integration
Google Pub/Sub Managed, simple
The ERP’s native queue SAP qRFC/tRFC, IDoc queues — integrated with the ERP’s monitoring
A database table as a queue Perfectly viable at low volume. Don’t over-engineer

A Postgres table with SELECT ... FOR UPDATE SKIP LOCKED is a legitimate queue for thousands of messages a day, and it gives you transactional enqueue for free. Reach for Kafka when you actually need retention, replay, and high throughput — not because it’s on the architecture diagram.

Schema evolution

Events outlive the code that produced them.

Compatible (safe):
  + add an optional field
  + add a new event type
  + widen an enum for producers (consumers must handle unknown values)

Breaking (needs a new version):
  − remove a field
  − rename a field
  − change a type
  − change semantics silently   ← the worst, because nothing errors

Use a schema registry (Confluent, AWS Glue, or a versioned JSON Schema in your repo) and enforce compatibility in CI. Consumers must tolerate unknown fields — that one rule enables most safe evolution.

Monitoring

Metric Alert on
Publish rate Sudden drop (a silent producer)
Consumer lag Growing — you’re falling behind
Processing latency (p50/p95/p99) Above SLA
Error rate Above threshold
DLQ depth Anything > 0 for critical flows
Outbox unpublished count Growing — the relay is stuck
Duplicate detection hits Spikes indicate broker or client issues

Outbox depth is the underrated metric. If the relay stops, everything looks fine inside the ERP while every downstream system silently falls behind.

When NOT to use messaging

  • Anything needing a synchronous answer — stock check, price, credit check
  • Anything that must be transactionally consistent with a posting — keep it in the same database transaction
  • Simple point-to-point with one consumer and low volume — an API call is simpler and easier to debug
  • When you have two systems and five interfaces — a broker is operational overhead you don’t need yet

Don’t build an event-driven architecture because it’s fashionable. Build it when you have real decoupling, resilience, or fan-out needs. The debugging cost of asynchrony is real.

Next