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é.