CQRS pour streams d'événements B2B
Séparer le modèle d'écriture (commandes EDI entrantes, sagas, journaux d'ACK) du modèle de lecture (KPI partenaire, tracking, archives fiscales) pour que chaque vue se modélise selon son usage réel — pas selon un compromis schématique.
Problème
Un hub B2B ingère des dizaines de flux EDI (ORDERS, DESADV, INVOIC) et doit servir plusieurs publics aux exigences contradictoires : l'ERP attend un format canonique strict, le portail partenaire veut un tracking enrichi par jointures, la BI réclame des agrégats horaires, l'audit fiscal a besoin des messages bruts horodatés pendant 10 ans. Un schéma relationnel unique normalisé devient un compromis permanent où chaque query ralentit, chaque ajout de KPI casse un index, et chaque migration de schéma terrorise les ops. CRUD plat sur table partagée ne passe pas l'échelle des flux B2B en 2026.
Forces
- Évolution indépendante : les besoins du portail tracking changent plus vite que ceux du moteur de validation EDI.
- Performance read-heavy : 10x à 1000x plus de lectures (BI, dashboards, polling) que d'écritures (messages ingérés).
- Conformité fiscale : l'archive 10 ans nécessite un append-only immuable, incompatible avec un schéma normalisé en mutation continue.
- Cohérence éventuelle acceptable : les KPI tracking n'ont pas besoin d'être strictement consistants avec l'instant
td'ingestion ; un lag de quelques secondes est acceptable. - Découplage des équipes : l'équipe portail itère sur sa view PostgreSQL sans toucher au pipeline ingestion Kafka maintenu par l'équipe plateforme.
Solution
Diviser le modèle en deux. Le côté commande reçoit les messages
EDI entrants, applique la validation, persiste un journal append-only d'événements
métier (OrderReceived, OrderValidated, InvoiceAcked),
et publie ces événements sur Kafka. Le côté requête consomme ce
stream pour matérialiser des vues spécialisées : une table PostgreSQL pour le
portail, une vue ClickHouse pour la BI, un index Elasticsearch pour la recherche
plein-texte, un bucket S3 partitionné par mois pour l'archive fiscale. Chaque
projection est responsable de sa propre cohérence et peut être reconstruite en
rejouant le journal d'événements depuis t=0.
Structure
┌─────── Côté COMMAND ───────┐
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
(portail) (BI/KPI) (archive 10 ans) Implémentation EDI
Sur un hub EDI moderne, le journal de commandes peut être une table PostgreSQL
events append-only avec colonnes id (uuid),
aggregate_id (varchar), type (varchar),
payload (jsonb), created_at (timestamptz),
partition_key. Debezium capture les inserts via le WAL et les publie
dans un topic Kafka compacté par partenaire. Chaque consumer matérialise sa
projection :
-- Côté COMMAND : journal append-only
CREATE TABLE edi_events (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL, -- ex: "ORDER-2026-12345"
partner_id VARCHAR(32) NOT NULL,
type VARCHAR(64) NOT NULL, -- ex: "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);
-- Côté QUERY : projection portail (idempotente)
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;
L'idempotence des projections est critique : Kafka livre at-least-once,
donc chaque consumer doit gérer les doublons via une condition WHERE
ou via une table d'événements consommés (cf. Transactional Inbox).
Le replay complet doit être possible : il suffit de tronquer la projection,
réinitialiser l'offset Kafka à 0 et laisser le consumer rejouer.
Anti-patterns
- Imposer la consistance forte entre command et query — annule tout l'intérêt du pattern et réintroduit le 2PC qu'on cherchait à éviter.
- Partager la base entre command et query — chaque projection doit avoir son propre stockage avec sa propre stratégie d'indexation.
- Faire du CQRS sans Event Sourcing puis le regretter — sans journal append-only, impossible de rejouer pour reconstruire une projection cassée.
- Réutiliser le payload Kafka comme « source de vérité » pour répondre aux audits — Kafka n'est pas conçu pour la rétention 10 ans, archiver vers S3/Glacier.
- Modéliser les commandes comme des HTTP REST resources (PUT/POST sur
/orders/123) — perd la traçabilité de l'intention métier (qui a fait quoi, quand, pourquoi).
Patterns liés
- CQRS (vue architecturale) — la version générique du pattern dans la littérature DDD.
- Event Sourcing avec journal EDI append-only — souvent associé à CQRS pour rejouer les projections.
- Transactional Outbox — pour publier les événements vers Kafka de façon atomique avec le commit DB.
- Data Mesh — chaque projection devient un produit data exposé à son consommateur.
Sources
- Young G. — CQRS Documents, 2010. Le PDF fondateur où Greg Young formalise le pattern. cqrs.files.wordpress.com
- Fowler M. — CQRS, martinfowler.com, juillet 2011. La page de référence qui a diffusé le terme dans la communauté DDD. martinfowler.com/bliki/CQRS.html
- Microsoft Patterns & Practices — CQRS Journey, 2012. Un livre complet documentant une implémentation production basée sur Azure Service Bus et Event Store. learn.microsoft.com
- Kleppmann M. — Designing Data-Intensive Applications, O'Reilly 2017, chap. 11 (« Stream Processing »).
- Confluent — Building a Microservices Ecosystem with Kafka Streams and KSQL, Ben Stopford, O'Reilly 2018. Le manuel de référence des implémentations CQRS event-driven avec Kafka.