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.

CQRS for B2B event streams

Split the write model (incoming EDI commands, sagas, ACK journals) from the read model (partner KPIs, tracking, fiscal archives) so each view is shaped to its real usage — not a schema compromise.

Problem

A B2B hub ingests dozens of EDI flows (ORDERS, DESADV, INVOIC) and must serve multiple audiences with conflicting demands: the ERP expects a strict canonical format, the partner portal wants enriched tracking via joins, BI requires hourly aggregates, fiscal audit needs raw timestamped messages kept for 10 years. A single normalised relational schema becomes a permanent compromise where every query slows down, every new KPI breaks an index, every schema migration terrifies ops. Flat CRUD on a shared table does not scale to B2B flows in 2026.

Forces

  • Independent evolution: tracking portal needs change faster than EDI validator needs.
  • Read-heavy performance: 10x to 1000x more reads (BI, dashboards, polling) than writes (ingested messages).
  • Fiscal compliance: the 10-year archive requires immutable append-only, incompatible with a continuously mutating normalised schema.
  • Eventual consistency acceptable: tracking KPIs do not need to be strictly consistent at ingestion time t; a few seconds lag is acceptable.
  • Team decoupling: the portal team iterates on its PostgreSQL view without touching the Kafka ingestion pipeline maintained by the platform team.

Solution

Split the model in two. The command side receives incoming EDI messages, applies validation, persists an append-only journal of business events (OrderReceived, OrderValidated, InvoiceAcked), and publishes them on Kafka. The query side consumes this stream to materialise specialised views: a PostgreSQL table for the portal, a ClickHouse view for BI, an Elasticsearch index for full-text search, an S3 bucket partitioned by month for fiscal archive. Each projection owns its own consistency and can be rebuilt by replaying the event journal from t=0.

Structure

                  ┌─────── COMMAND side ────────┐
   AS4 / AS2  ──► │  Validate EDI message       │
   PEPPOL     ──► │  Persist event in journal   │ ──┐
                  │  Publish to Kafka           │   │
                  └─────────────────────────────┘   │
                                                     │
                              Kafka topic            │
                       ┌───── edi.events ───────────┘
                       │
                       ▼
       ┌───────────────┼─────────────────┐
       │               │                 │
       ▼               ▼                 ▼
  Projection A    Projection B     Projection C
  PostgreSQL      ClickHouse       S3 Parquet
  (portal)        (BI/KPI)         (10y archive)

EDI implementation

On a modern EDI hub, the command journal can be a PostgreSQL append-only events table with columns id (uuid), aggregate_id (varchar), type (varchar), payload (jsonb), created_at (timestamptz), partition_key. Debezium captures inserts via the WAL and publishes them to a Kafka topic compacted by partner. Each consumer materialises its own projection:

-- COMMAND side: append-only journal
CREATE TABLE edi_events (
  id           UUID PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,  -- e.g. "ORDER-2026-12345"
  partner_id   VARCHAR(32) NOT NULL,
  type         VARCHAR(64) NOT NULL,  -- e.g. "OrderReceived"
  payload      JSONB NOT NULL,
  occurred_at  TIMESTAMPTZ NOT NULL,
  recorded_at  TIMESTAMPTZ DEFAULT now(),
  schema_version SMALLINT NOT NULL
);
CREATE INDEX ON edi_events (aggregate_id, occurred_at);

-- QUERY side: portal projection (idempotent)
INSERT INTO portal_orders (order_id, partner, status, last_updated)
VALUES ($1, $2, $3, $4)
ON CONFLICT (order_id) DO UPDATE
  SET status = EXCLUDED.status,
      last_updated = EXCLUDED.last_updated
  WHERE portal_orders.last_updated < EXCLUDED.last_updated;

Projection idempotency is critical: Kafka delivers at-least-once, so each consumer must handle duplicates via a WHERE guard or via a consumed events table (see Transactional Inbox). Full replay must be possible: truncate the projection, reset the Kafka offset to 0, let the consumer replay.

Anti-patterns

  • Enforcing strong consistency between command and query — kills the whole point of the pattern and reintroduces the 2PC you were trying to avoid.
  • Sharing the database between command and query — each projection should have its own storage with its own indexing strategy.
  • Doing CQRS without Event Sourcing then regretting it — without an append-only journal, you cannot replay to rebuild a broken projection.
  • Reusing the Kafka payload as a "source of truth" to answer audits — Kafka is not designed for 10-year retention, archive to S3/Glacier.
  • Modelling commands as HTTP REST resources (PUT/POST on /orders/123) — loses traceability of business intent (who did what, when, why).

Sources

  • Young G. — CQRS Documents, 2010. The founding PDF where Greg Young formalised the pattern. cqrs.files.wordpress.com
  • Fowler M. — CQRS, martinfowler.com, July 2011. The reference page that spread the term in the DDD community. martinfowler.com/bliki/CQRS.html
  • Microsoft Patterns & Practices — CQRS Journey, 2012. A full book documenting a production implementation based on Azure Service Bus and Event Store. learn.microsoft.com
  • Kleppmann M. — Designing Data-Intensive Applications, O'Reilly 2017, ch. 11 ("Stream Processing").
  • Confluent — Building a Microservices Ecosystem with Kafka Streams and KSQL, Ben Stopford, O'Reilly 2018. The reference manual for event-driven CQRS implementations with Kafka.