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.