Les architectures événementielles sont séduisantes sur le papier. Au lieu d'avoir des services qui s'appellent directement, vous avez des services qui publient des événements et d'autres services qui écoutent ces événements et réagissent.

C'est découplé. C'est élégant. Chaque service ne connaît que les événements qu'il émet et ceux qu'il consomme. Vous pouvez ajouter de nouveaux consommateurs sans modifier les producteurs. Vous pouvez scaler indépendamment.

Sauf que cette simplicité conceptuelle cache une complexité.

Dans une architecture synchrone classique, quand vous appelez paymentService.charge(userId, amount), vous avez un fil d'exécution clair. Vous pouvez mettre un breakpoint. Vous pouvez suivre la stack trace. Si ça échoue, vous avez une exception qui remonte avec tout le contexte. C'est séquentiel, c'est déterministe, c'est debuggable.

Dans une architecture événementielle, vous publiez un événement PaymentRequested et... puis quoi ? Qui va le consommer ? Quand ? Dans quel ordre si plusieurs consommateurs écoutent le même événement ? Que se passe-t-il si le consommateur échoue ? L'événement sera-t-il rejoué ? Combien de fois ? Et si le consommateur a fait la moitié de son traitement avant d'échouer ?

Vous n'avez plus un fil d'exécution. Vous avez un graphe d'exécution, potentiellement asynchrone, potentiellement distribué sur plusieurs machines, avec des délais inconnus entre chaque étape.

Les événements ne respectent pas toujours l'ordre chronologique. Un utilisateur s'inscrit, puis met à jour son profil immédiatement après. Deux événements : UserCreated à 10:00:00.000, UserUpdated à 10:00:00.100. Dans un monde parfait, le service de notification reçoit d'abord UserCreated, envoie l'email de bienvenue, puis reçoit UserUpdated et met à jour ses données. Simple.

Dans le monde réel, voici ce qui peut arriver.

Le broker de messages (Kafka, RabbitMQ, SQS) garantit l'ordre au sein d'une partition, mais pas entre partitions. Si vos deux événements tombent dans des partitions différentes, parce que le partitioning est basé sur un hash de l'userId et qu'un caractère a changé, ou parce qu'un repartitioning a eu lieu, ils peuvent arriver dans le désordre.

Même au sein de la même partition, le consommateur peut avoir plusieurs workers qui traitent les messages en parallèle. Le worker qui traite UserUpdated peut finir avant celui qui traite UserCreated, simplement parce qu'il a été plus rapide ou moins chargé.

Résultat, votre service de notification reçoit d'abord la mise à jour d'un utilisateur qui n'existe pas encore dans sa base locale. Il rejette l'événement. Puis il reçoit la création. Mais la mise à jour a été perdue.

Et maintenant, votre système est dans un état incohérent. L'utilisateur existe, mais avec d'anciennes données. Personne ne le sait. Il n'y a pas d'erreur. Les logs montrent que tout s'est bien passé. C'est juste que les événements sont arrivés dans le mauvais ordre.

Dans un système synchrone, à tout moment, votre système est dans un état bien défini. Vous pouvez faire une capture de la mémoire et de la base de données et vous avez un snapshot cohérent.

Dans une architecture événementielle, votre système est distribué sur des dizaines de services, chacun avec sa propre base de données, son propre état. Et ces états ne sont jamais parfaitement synchronisés.

Quand un événement OrderPlaced est publié, il va être consommé par le service d'inventaire, le service de facturation, le service de notification, le service d'analytics. Ces quatre services vont tous traiter cet événement, mais pas au même moment. Pendant quelques millisecondes, ou secondes, ou minutes, votre système est dans un état de "cohérence éventuelle" où différentes parties du système ont des visions différentes de la réalité.

Le service d'inventaire a déjà décrémenté le stock. Le service de facturation n'a pas encore créé la facture. Le service de notification a envoyé l'email. Le service d'analytics n'a rien enregistré parce qu'il était down pendant 30 secondes.

Maintenant, imaginez qu'un autre événement arrive : OrderCancelled. Mais le service de facturation n'a toujours pas traité le OrderPlaced. Il reçoit donc une annulation pour une commande qu'il ne connaît pas.

Comment gérez-vous ça ? Vous mettez l'événement de côté en attendant ? Pendant combien de temps ? Que se passe-t-il si le OrderPlaced ne vient jamais parce qu'il a été perdu en route ?

Et ce n'est qu'avec deux événements. Dans un vrai système, vous avez des dizaines d'événements qui interagissent. OrderPlaced, PaymentProcessed, PaymentFailed, InventoryReserved, InventoryReleased, OrderShipped, OrderDelivered, OrderReturned. Chacun peut arriver dans n'importe quel ordre, certains peuvent être dupliqués, d'autres perdus.

Le nombre d'états possibles explose de façon combinatoire. Et vous devez gérer tous ces états. Sinon, vous avez des bugs qui n'apparaissent que dans des séquences très spécifiques d'événements que vous n'avez jamais testées.

Quand un bug apparaît en production dans une architecture événementielle, par où commencez-vous ?

Vous avez un utilisateur qui dit "j'ai été facturé mais je n'ai jamais reçu ma commande". Classique. Dans une architecture monolithique, vous regardez les logs de la transaction. Vous voyez exactement ce qui s'est passé, dans quel ordre, avec quelles données.

Dans une architecture événementielle, vous devez :

  1. Identifier tous les services impliqués dans le flow de commande

  2. Récupérer les logs de chaque service

  3. Corréler ces logs par... quoi exactement ? L'userId ? L'orderId ? Un correlation ID que vous avez pensé à propager à travers tous vos événements ?

  4. Reconstituer la chronologie en tenant compte des décalages d'horloges entre les machines

  5. Identifier quels événements ont été publiés, lesquels ont été consommés, par qui, quand

  6. Trouver si des événements ont échoué et été rejoués

  7. Comprendre l'état de chaque base de données à chaque moment clé

Et ça, c'est si vous avez de la chance et que vos logs sont corrects et complets. Souvent, le service critique a un log level trop élevé et n'a rien enregistré. Ou le système de collecte de logs avait un problème et des logs manquent. Ou les logs existent mais ils sont noyés dans des millions d'autres logs et vous passez une heure à écrire des requêtes Elasticsearch pour trouver les bonnes lignes.

Il existe des outils pour ça. Ils sont censés vous donner une vue d'ensemble de votre flow distribué. Sauf qu'il faut les avoir implémentés correctement, ce qui nécessite que chaque service propage les trace IDs, que chaque appel soit instrumenté, que la configuration soit bonne.

Et même avec ça, vous voyez des spans, des timelines, des dépendances. Mais vous ne voyez pas le "pourquoi". Pourquoi cet événement a été consommé trois fois ? Pourquoi ce service a décidé de faire ça plutôt que ça ? Pour ça, il faut plonger dans le code, comprendre la logique métier, hypothétiser, tester.

La réponse standard à beaucoup de ces problèmes est : "rendez vos consommateurs idempotents". Si un événement est consommé plusieurs fois, ça ne doit pas avoir d'effet de bord supplémentaire.

Sur le papier, c'est simple. En pratique, c'est l'enfer.

L'idempotence nécessite généralement de garder un état quelque part. "J'ai déjà traité cet événement avec cet ID, je ne le retraite pas.". Mais où stockez-vous cet état ? Dans une base de données ? Alors vous avez une dépendance supplémentaire. Si la base est down, vous ne pouvez rien traiter. Si le réseau est lent, chaque vérification d'idempotence ajoute de la latence.

Et puis il y a le problème de la granularité. Vous êtes idempotent au niveau de l'événement entier ? Ou au niveau de chaque action dans le traitement de l'événement ? Si votre consommateur fait trois choses, écrire en base, appeler une API externe, publier un nouvel événement, et qu'il échoue après la première, comment rejouez-vous l'événement de façon idempotente ?

Vous devez rendre chaque action individuellement idempotente. Ce qui signifie que chacune doit vérifier si elle a déjà été faite. Vous multipliez les appels à la base de données, la complexité du code, les points de défaillance possibles.

Et parfois, l'idempotence est fondamentalement impossible. Envoyer un email est-il idempotent ? Techniquement non, chaque envoi crée un nouvel email. Vous pouvez garder un cache "j'ai déjà envoyé un email pour cet événement", mais pendant combien de temps ? Une heure ? Un jour ? Pour toujours ? Et si l'utilisateur devrait légitimement recevoir deux emails pour des événements différents qui ont le même contenu ?

On vous vend les architectures événementielles en disant que chaque service est isolé. Qu'un service peut tomber sans affecter les autres. Que vous avez de la résilience.

C'est techniquement vrai pour les pannes franches. Si le service de notification tombe, les commandes continuent d'être traitées. Les événements s'accumulent dans la queue, et quand le service redémarre, il les traite.

Mais ça ne marche que si tout le reste fonctionne parfaitement. Et surtout, si votre message broker fonctionne. Parce que le message broker, lui, n'est pas isolé. C'est un single point of failure déguisé.

Si Kafka tombe, plus aucun événement ne circule. Tous vos services deviennent muets. Pire, si Kafka perd des messages parce qu'une partition a eu un problème de réplication, vous vous retrouvez avec des événements manquants et aucun moyen de savoir lesquels.

Et puis il y a le problème de la backpressure. Si un consommateur lent ne traite pas ses événements assez vite, la queue grandit. Tôt ou tard, vous atteignez les limites, mémoire sur le broker, retention policy qui supprime les vieux messages, disque qui se remplit. Et là, tout le système commence à s'effondrer.

Vous pouvez scaler votre consommateur lent, bien sûr. Ajouter plus de workers. Mais maintenant vous avez un nouveau problème. Ces workers parallèles peuvent traiter les événements dans le désordre. Et on revient au problème de causalité du début.

Face à tous ces problèmes, la tentation est forte de réintroduire de l'orchestration. Au lieu de laisser les services réagir aux événements de façon autonome, vous créez un orchestrateur central qui coordonne tout.

"Quand une commande arrive, l'orchestrateur appelle le service d'inventaire, attend la réponse, puis appelle le service de paiement, attend la réponse, puis appelle le service de livraison." C'est synchrone, c'est coordonné, c'est debuggable.

Sauf que maintenant, vous avez recréé un monolithe distribué. Vous avez toute la complexité de la distribution, network failures, latence, partial failures, sans aucun des avantages. Votre orchestrateur devient un goulot d'étranglement. Il doit connaître tous les services. Il doit gérer toutes les erreurs possibles. Il est couplé à tout.

Et vous vous retrouvez avec le pire des deux mondes. La complexité opérationnelle d'une architecture distribuée et la rigidité d'une architecture monolithique.

Certains essaient les sagas orchestrées. Des workflows complexes avec des compensations. Si l'étape 3 échoue, on annule l'étape 2, puis l'étape 1. C'est censé garantir la cohérence. Sauf que maintenant, vous devez implémenter toute la logique de compensation. Et tester tous les cas d'échec possibles. Et gérer les cas où la compensation elle-même échoue.

Les architectures événementielles ne simplifient pas votre système. Elles déplacent la complexité.

Au lieu d'avoir de la complexité dans votre code, des if imbriqués, des try-catch compliqués, de la logique métier dense, vous avez de la complexité dans votre infrastructure. Dans la façon dont les messages sont routés. Dans l'ordre dans lequel les événements arrivent. Dans la gestion des pannes et des retries.

Cette complexité infrastructurelle est peut-être plus "propre" architecturalement. Elle est peut-être plus "scalable" théoriquement. Mais elle est aussi beaucoup plus difficile à comprendre, à tester, à debugger.

Et surtout, elle nécessite une expertise différente. Debugger un monolithe, c'est comprendre du code. Debugger un système événementiel distribué, c'est comprendre des systèmes distribués, des garanties de message delivery, des problèmes de synchronisation, des network partitions, des clock skews.

Combien de développeurs dans votre équipe ont cette expertise ? Et quand ils partent, qui reprend le flambeau ?

Tout ça ne veut pas dire que les architectures événementielles sont mauvaises. Elles ont leur place. Peut être simplement plus étroite qu’on ne le croît.

Elles ont du sens quand vous avez vraiment besoin de découplage temporel, quand le producteur et le consommateur ne peuvent pas être synchrones. Quand vous devez traiter des millions d'événements par seconde et que vous avez besoin de parallélisme massif. Quand vous avez plusieurs équipes complètement indépendantes qui doivent réagir aux mêmes événements sans se coordonner.

Mais pour un système CRUD classique ? Pour une application web standard avec quelques milliers d'utilisateurs ? Vous n'avez probablement pas besoin de tout ça. Un bon monolithe modulaire avec des transactions ACID fera très bien l'affaire. Et quand vous aurez vraiment besoin de scaler, vous pourrez extraire les morceaux qui en ont besoin.

La plupart des systèmes n'ont jamais besoin de scaler au niveau où une architecture événementielle devient nécessaire. Et même pour ceux qui en ont besoin, c'est rarement tout le système. C'est un sous-système spécifique, le traitement des paiements, l'ingestion de logs, l'envoi de notifications.

Commencez simple. Gardez les choses debuggables. Ajoutez de la complexité seulement quand vous avez un problème réel que cette complexité résout. Pas parce que c'est dans la mode. Pas parce que c'est sur votre CV. Pas parce que c'est ce que font les GAFAM.

Parce que dans trois ans, quand vous debuggerez un bug de facturation à 3h du matin en essayant de reconstituer une chaîne d'événements qui traverse 15 services, vous vous souviendrez de cet lecture.

Si ce contenu vous a plus les ami(e)s codeurs, n’hésitez pas à partager sur vos réseaux et à vous abonner a la newsletter afin d’être averti des prochains billets !

Keep Reading