09 - Introduction aux systèmes event-driven

Un système event-driven s’appuie sur un log d’événements append-only qui matérialise des faits passés. Plutôt que de synchroniser des services via des appels directs, on publie des événements durables dans un broker (ex: Kafka), persistés par partitions et consommés par des groupes de consommateurs. Cette approche transforme la manière de concevoir la fiabilité, la scalabilité et la traçabilité d’un système bancaire.

L’idée n’est pas de remplacer tout le synchrone, mais d’introduire un mécanisme qui découple le “moment où une décision est prise” du “moment où ses effets sont appliqués”. Dans un domaine bancaire, cette séparation est cruciale: elle permet à un virement d’être validé dans un contexte strict (invariants, conformité, autorisations) puis propagé vers des systèmes hétérogènes (ledger, fraude, notifications, reporting) sans créer un enchevêtrement d’appels synchrones.

Pourquoi l’event-driven change la donne

  • Découplage temporel: le producteur n’attend pas tous les consommateurs.
  • Scalabilité horizontale: chaque consumer group se scale indépendamment.
  • Résilience: le broker absorbe les pics (buffering, backpressure).
  • Auditabilité: le log d’events devient un journal inviolable.
  • Évolutivité: ajouter un nouveau consumer ne casse pas l’existant.

Ce modèle est particulièrement adapté aux banques car les exigences d’audit, de traçabilité et de résilience sont non négociables. Un log d’événements permet de reconstituer une position comptable, de prouver un enchaînement de décisions, ou de rejouer un flux après incident.

Synchrone vs asynchrone: contrats et expérience client

L’event-driven ne supprime pas les appels synchrones, il les délimite. Dans un flux bancaire, la décision critique (ex: “le virement est-il autorisé ?”) doit souvent rester synchrone pour fournir une réponse immédiate et juridiquement claire. En revanche, les effets secondaires (notifications, reporting, scoring) se prêtent parfaitement à l’asynchrone.

L’enjeu est contractuel: le client doit savoir si la banque s’est engagée à exécuter une action, ou si elle constate un fait. Une command valide un contrat fort (acceptation), un event diffuse un fait (exécution). Dans un parcours client, on combine les deux: l’API répond “virement accepté”, puis un event TransferInitiated déclenche les traitements aval. Cette séparation clarifie le SLA et réduit le couplage entre services.

Fiabilité côté producer: ACK, idempotence, outbox

Un producteur d’events est responsable de la qualité du log. En Kafka, les paramètres d’ACK déterminent le niveau de garantie: acks=all impose une réplication durable avant l’ACK, tandis que acks=1 favorise la latence. En banque, on préfère une latence légèrement supérieure plutôt qu’un risque de perte d’event.

L’idempotence côté producer évite les doublons en cas de retry réseau. Couplée au Transactional Outbox, elle garantit que l’écriture de l’état et la publication de l’event sont atomiques. Sans outbox, une panne entre la base et Kafka crée des incohérences qui sont très coûteuses à corriger (surtout sur des flux comptables). Dans les systèmes critiques, l’outbox est souvent considérée comme un composant non négociable.

Event, message, stream: le vocabulaire minimal

  • Event: fait immuable, versionné, représentant un changement déjà survenu.
  • Message: enveloppe technique transportant un event (headers, metadata).
  • Stream: séquence ordonnée d’events dans un topic, partitionnée par clé.

Un event n’est pas une “instruction”. C’est un constat: quelque chose s’est produit, et ce fait est consigné. Un message est la forme technique qui permet au broker de transporter ce fait. Un stream est la suite ordonnée de ces faits, utile pour rejouer, reconstruire, analyser.

Anatomie d’un event bancaire

Un event exploitable en production contient un envelope technique et un payload métier. L’envelope garantit la traçabilité, le routing et la compatibilité; le payload capture le fait métier.

Exemple (virement initié):

{
  "event_id": "0f58b8b1-3e1e-4e1a-9d2f-9ad8c3b0f662",
  "type": "TransferInitiated",
  "occurred_at": "2024-01-10T08:00:00Z",
  "schema_version": 1,
  "correlation_id": "corr-6b2a",
  "causation_id": "cmd-91f3",
  "payload": {
    "transfer_id": "T-913",
    "from_account": "ACC-001",
    "to_account": "ACC-987",
    "amount_minor": 59090,
    "currency": "EUR",
    "channel": "mobile"
  }
}

Le correlation_id permet de tracer une chaîne d’events liée à un même flux métier (ex: un virement complet). Le causation_id lie l’event à la commande qui l’a déclenché. Les montants sont exprimés en unités mineures pour éviter les erreurs d’arrondi.

Event vs command vs query (détaillé)

Un design robuste repose sur une distinction claire entre intention, fait et lecture. Cette distinction est critique en banque, où les invariants (solde, conformité AML, limites) ne peuvent pas être compromis.

Aspect Command Event Query
Sémantique Intention d’écrire Fait enregistré Lecture sans effets de bord
Ciblage Handler unique Fan-out (pub/sub) Service de lecture
Timing Avant la décision Après la décision À tout moment
Échec Possible (validation, règles) Non (fait acté) Non, sauf indisponibilité
Consistance Forte sur l’aggregate Éventuelle sur les projections Dépend du read model
Idempotence Obligatoire Recommandée côté consumers N/A
Nommage Impératif Passé Interrogatif
Exemple bancaire InitiateTransfer TransferInitiated GetTransfer

Command: intention, synchronisme, invariants

Une command exprime ce qu’on veut faire. Elle est souvent traitée en synchrone (HTTP/RPC) car le client attend un verdict immédiat. Le command handler est propriétaire de l’état (aggregate) et garantit les invariants: solde suffisant, statut KYC validé, limites de virement respectées, etc.

  • Peut être refusée (ex: InsufficientFunds, KycPending).
  • Doit être idempotente (ex: command_id, déduplication côté service).
  • Exprime un choix: on demande au système d’agir.

Event: fait, diffusion, log durable

Un event décrit ce qui s’est effectivement passé. Il est produit après la validation et la persistance de la décision. Il devient un artefact durable, versionné, immutable, consommable par d’autres systèmes.

  • Ne peut pas “échouer”: il décrit un fait passé.
  • Peut être rejoué (replay) pour reconstruire un read model.
  • Est conçu pour être lu par plusieurs consommateurs indépendants.

Query: lecture, projection, latence maîtrisée

Une query interroge un read model. Ce modèle est souvent alimenté par des événements, donc il peut être légèrement en retard (eventual consistency). En banque, on choisit explicitement le niveau de fraîcheur toléré: un solde “disponible” peut être projeté, tandis que le solde “comptable” peut rester sous contrôle strict du write model.

Le piège du “passive-aggressive command”

C’est un anti-pattern fréquent: on publie un event en espérant qu’un consumer exécute une action obligatoire. Exemple: publier TransferInitiated en espérant que le service Ledger poste les écritures immédiatement, sans mécanisme de commande explicite ni SLA clair. Le résultat est un workflow implicite difficile à monitorer et dangereux en production.

Notification vs event-carried state transfer

Deux patterns structurants expliquent la forme des events.

Event notification

Le producer envoie un signal léger (souvent un ID) et le consumer récupère le reste via un appel synchrone. C’est simple et découplé, mais crée un couplage runtime (le consumer dépend du producer pour enrichir).

Exemple bancaire: AccountProfileUpdated contient account_id et version, le service Mobile Banking appelle Core Banking pour récupérer le profil complet.

Event-carried state transfer

Le producer transporte l’état nécessaire pour que le consumer travaille sans retour à la source. On accepte plus de données échangées pour gagner en latence et en résilience.

Exemple bancaire: BeneficiaryConfirmed contient l’identité complète du bénéficiaire, permettant au service Mobile d’afficher immédiatement la fiche.

Ordering, idempotence et garanties de livraison

Dans un broker comme Kafka, l’ordre est garanti par partition. La clé de partition devient donc une décision métier: on choisit une clé stable (ex: account_id, transfer_id) pour préserver l’ordre des événements critiques.

Kafka fournit des garanties at-least-once par défaut. Cela implique des doublons possibles côté consumer. La règle d’or: les consumers doivent être idempotents. Dans un contexte bancaire, cela se matérialise par des écritures ledger basées sur un event_id unique, ou des tables de déduplication.

Temps d’événement vs temps de traitement

Un event possède un temps d’événement (occurred_at) qui correspond au moment réel où le fait s’est produit, et un temps de traitement qui est le moment où il est consommé. En pratique, ces deux instants divergent: un flux de virements peut être traité en retard à cause d’un rebalance ou d’une panne de consumer. Cette distinction est cruciale pour les reportings financiers et les calculs d’intérêts, où la valeur de date fait foi.

Dans un système bancaire, on doit donc choisir explicitement quel temps est utilisé dans chaque projection. Un tableau de bord temps réel peut se baser sur le temps de traitement, tandis qu’un reporting réglementaire doit utiliser le temps d’événement pour reconstruire l’état à une date donnée. Cette discipline évite les écarts comptables et garantit la cohérence des audits.

Cycle de vie d’un event de bout en bout

1) L’API reçoit une command InitiateTransfer avec une clé d’idempotence. 2) Le service Virements valide les invariants (solde, AML, limites). 3) L’état est persisté; l’event TransferInitiated est écrit via Outbox. 4) Le broker réplique l’event dans un topic partitionné. 5) Les consumers le lisent et commitent leurs offsets. 6) Le service Ledger poste les écritures et publie TransferSettled. 7) Le service Fraude calcule un score et publie TransferFlagged si besoin. 8) Le service Notifications informe le client.

Chaque consumer applique sa propre logique et construit sa projection, sans impacter le producteur.

Bonnes pratiques de base

  • Séparer commandes et événements: topics distincts pour éviter la confusion.
    • prod.corebanking.transfer.cmd.initiate.v1 (command)
    • prod.corebanking.transfer.evt.initiated.v1 (event)
  • Prévoir une DLQ pour les messages non traitables.
    • prod.corebanking.transfer.dlq.v1
  • Adopter un naming stable: <env>.<service>.<domain>.<type>.<action>.<v>.
  • Utiliser une clé métier stable (account_id, transfer_id).
  • Versionner les schémas et valider la compatibilité en CI.
  • Ajouter des metadata de traçabilité: event_id, correlation_id, causation_id, occurred_at.
  • Documenter la rétention/compaction selon les besoins d’audit.

Pièges classiques à éviter

  • Publier des events trop “riches” qui exposent un modèle interne instable.
  • Oublier l’idempotence côté consumers (doublons comptables).
  • Choisir une clé de partition inadéquate (perte d’ordre critique).
  • Mélanger notification et commande (workflow implicite non monitoré).
  • Changer un schéma sans compatibilité (casse silencieuse).

Étude de cas bancaire synthétique

Une banque lance une fonctionnalité de virement instantané. Le service Virements reçoit InitiateTransfer, valide les règles AML et poste un event TransferInitiated. Le service Ledger consomme et produit TransferSettled lorsque les écritures double-entry sont appliquées. Le service Notifications consomme TransferSettled pour informer le client. Le service Risque consomme les mêmes events pour ajuster les limites dynamiques.

Dans ce modèle, chaque service conserve sa propre projection: le backoffice a une vue orientée conformité, le mobile affiche un état opérationnel, et la comptabilité manipule un ledger strict. Le tout repose sur un flux d’events fiable et rejouable.

Pour aller plus loin

Ce socle permet d’aborder ensuite les architectures event-driven, le streaming, le sourcing et les patterns d’intégration (Outbox, CDC, Sagas). Sans cette base, les systèmes deviennent vite opaques et difficiles à opérer. L’event-driven est puissant, mais exigeant: il impose de penser en termes de faits, de contrats, et de temporalité.