Event Sourcing with append-only EDI log
Rather than mutating a row orders.status='SHIPPED', persist an immutable OrderShipped event in an append-only log. Current state becomes a replayable projection, and legal history is native.
Problem
The typical EDI flow (ORDERS → ORDRSP → DESADV → INVOIC → REMADV) is intrinsically
a sequence of business events happening in the real world. Storing only the current
state (an orders CRUD table) destroys historical information: why is
the status SHIPPED? At what precise time was the INVOIC acked? Which
mapping version generated this DESADV? When fiscal audit asks for proof 10 years
later, you only have the last row. Worse, in a compensated saga, mutable state
makes it impossible to rebuild the intermediate version to understand what
happened.
Forces
- Fiscal and legal compliance: French tax code and EU eIDAS require immutable proof kept 10 years (invoices, attestations).
- Forensic audit: in a partner dispute ("you never received my ORDERS"), prove the precise sequence of events.
- Replay and debugging: be able to replay a full saga in staging with the exact production events.
- Multiple projections: current state is no longer unique — different views can be materialised without collision.
- Storage cost: an append-only log grows linearly (~5-50 GB per million messages depending on payload richness).
Solution
Define a small set of business events (typically 20-50 per EDI bounded context),
each immutable, dated, versioned, carrying the minimal payload describing what
happened. The log is the system of record: no update, no delete. Aggregates
rebuild their state by sequentially consuming the events that concern them (by
aggregate_id). Read-side views are asynchronous materialised
projections (see CQRS).
For heavy aggregates (orders with thousands of events), insert periodic
snapshots.
Structure
EDI domain events (excerpt):
┌─────────────────────────┬────────────────────────┐
│ MessageReceived │ AS4 ingestion │
│ MessageDecoded │ EDIFACT/UBL parsed │
│ MessageValidated │ Schematron OK │
│ MessageMapped │ Canonical generated │
│ OrderReceived │ Business order created │
│ OrderConfirmed │ ORDRSP sent │
│ ShipmentDispatched │ DESADV sent │
│ InvoiceIssued │ INVOIC sent │
│ InvoiceAcked │ REMADV received │
│ MessageRejected │ ContractViolation │
│ SagaCompensated │ Step undone │
└─────────────────────────┴────────────────────────┘
Stream per aggregate:
order-2026-12345 → [
OrderReceived(t=10:01:23),
OrderValidated(t=10:01:24),
OrderConfirmed(t=10:02:11),
ShipmentDispatched(t=14:33:09),
InvoiceIssued(t=18:45:01),
InvoiceAcked(t=2026-05-21:09:12:55)
] EDI implementation
Three storage options in 2026: (1) PostgreSQL append-only (simple
table with NO UPDATE constraints via row-level security or triggers),
easy to operate, queryable in SQL. (2) Compacted Kafka log with
infinite retention via S3 tiered storage, perfect for real-time streaming but hard
to ad-hoc query. (3) EventStoreDB, dedicated store with stream
API and native projections — ideal for pure event-sourced DDD.
-- PostgreSQL append-only EDI schema
CREATE TABLE edi_event_journal (
event_id UUID PRIMARY KEY,
aggregate_id VARCHAR(80) NOT NULL,
aggregate_type VARCHAR(40) NOT NULL, -- 'Order', 'Shipment', 'Invoice'
sequence_no BIGINT NOT NULL, -- order by aggregate (1, 2, 3, ...)
event_type VARCHAR(80) NOT NULL,
payload JSONB NOT NULL,
metadata JSONB NOT NULL, -- correlation_id, user, source_msg
schema_version SMALLINT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (aggregate_id, sequence_no)
);
-- Prevent updates/deletes (row-level)
REVOKE UPDATE, DELETE ON edi_event_journal FROM PUBLIC;
-- Rebuild an aggregate
SELECT event_type, payload, occurred_at
FROM edi_event_journal
WHERE aggregate_id = 'ORDER-2026-12345'
ORDER BY sequence_no; For fiscal archive, partition the table by month and move to cold storage (S3 Glacier, Azure Archive) after 13 months. Events remain accessible via Athena / BigQuery External Tables. Cryptographic signature (XAdES, eIDAS) of events at the boundary guarantees non-repudiation and durable integrity.
Anti-patterns
- Storing the raw EDI message as an "event" — an event should be a business intent, not a technical blob. Store the raw EDIFACT separately in an object store.
- Versioning events in place (
UPDATE) — destroys append-only property and invalidates projections. - No
schema_versionfrom day 1 — when an event definition changes (rename a field), old events cannot be replayed. - Confusing event and command —
OrderShippedis an event (past, done),ShipOrderis a command (intent not yet realised). - Big-bang migration from CRUD to event sourcing — prefer a gradual migration bounded context by bounded context.
Related patterns
- Event Sourcing (architectural view) — the generic version of the pattern.
- CQRS for B2B streams — complementary pattern to expose projections.
- Message Store — the EIP version of the same need to persist everything that passes.
- Compensating Transactions — each compensation is itself an event in the journal.
Sources
- Fowler M. — Event Sourcing, martinfowler.com, December 2005. The founding page. martinfowler.com/eaaDev/EventSourcing.html
- Young G. — Versioning in an Event Sourced System, Leanpub 2017. The reference manual on event versioning management.
- Vernon V. — Implementing Domain-Driven Design, Addison-Wesley 2013, ch. 8 ("Domain Events").
- Stopford B. — Designing Event-Driven Systems, O'Reilly 2018. confluent.io
- eIDAS Regulation 910/2014 — Annex I on advanced electronic signatures required for legal archiving of B2B documents. eur-lex.europa.eu