ediverse Explore the platform

Spotlight PEPPOL BIS Billing 3.0 The EU e-invoicing mandate is here — France Sept 2026, Belgium Jan 2026, Germany 2025.

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_version from day 1 — when an event definition changes (rename a field), old events cannot be replayed.
  • Confusing event and command — OrderShipped is an event (past, done), ShipOrder is a command (intent not yet realised).
  • Big-bang migration from CRUD to event sourcing — prefer a gradual migration bounded context by bounded context.

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