Le problème

Notre boutique en ligne est découpée en services. order enregistre les commandes, stock réserve la marchandise, shipping prépare les colis et billing facture. Ils échangent des événements par RabbitMQ, et des fronts Vue affichent l'état des commandes.

Les décisions d'architecture y arrivent rarement ensemble. Elles se prennent une à une, au fil des incidents :

  • deux services modifient le statut d'une commande, et rien ne dit lequel fait foi ;
  • un message reserve-stock est diffusé à tous, et deux services l'exécutent ;
  • une queue partagée répartit les messages au lieu de les diffuser ;
  • chaque front interroge chaque API à intervalle régulier ;
  • un package commun accumule des entités Doctrine de plusieurs services ;
  • un événement se perd au redémarrage d'un service, un autre arrive deux fois.

Chaque symptôme reçoit son correctif, valable pris isolément. Mis bout à bout, ils se contredisent : une copie sans propriétaire, un format sans version, une règle de retry par cas d'usage. Ce qui manque n'est pas un choix technique de plus, mais une grille qui relie les choix entre eux.

Les options

Décider au fil de l'eau, service par service

  • Ce qu'elle règle : chaque service avance à son rythme, sans attendre de cadre commun.
  • Ce qu'elle coûte : les conventions divergent d'un service à l'autre. Brancher un nouveau consommateur demande alors de relire le code de chaque producteur.

Une bibliothèque commune qui cache la messagerie

  • Ce qu'elle règle : une bibliothèque partagée publie, consomme, réessaie et journalise à la place des services. Les conventions s'appliquent d'office.
  • Ce qu'elle coûte : chaque service dépend de cette bibliothèque et de sa version. La faire évoluer oblige à redéployer les services ensemble, ce que le découpage voulait éviter.

Six axes décidés une fois, chacun avec son signal

  • Ce qu'elle règle : chaque question a une réponse par défaut, écrite avant le premier service. Les services restent libres de leur code, pas de leurs contrats.
  • Ce qu'elle coûte : un document à tenir à jour, et des règles à vérifier en CI et en relecture.

Notre recommandation

Décider six axes pour tout le système, dans cet ordre, chacun avec un choix par défaut et le signal qui le remet en cause.

L'ordre compte : chaque axe s'appuie sur le précédent. Nous ne nommons pas un événement avant de savoir quel service possède la donnée. Nous ne choisissons pas les queues avant de savoir quels messages circulent.

Six décisions distinctes, toutes lisibles sur le flux d'une commande 5 app-contracts : nom de l'exchange, clés de routage, DTO, JSON Schemas, fixtures le seul code partagé ; outbox, queues, handlers et sérialiseurs restent dans chaque service order 1 possède les commandes 2 publie des faits : order.order.placed.v1 6 table outbox écrite avec la commande 3 app.events topic exchange billing.events lue par billing possède les factures shipping.events lue par shipping possède les expéditions stock.events lue par stock possède le stock et les réservations 6 dans chaque consommateur : processed_event et failure transport après le commit 4 hub Mercure topics en IRI SSE fronts shop et admin EventSource , puis relecture de l'API 1 2 3 4 5 6 Fonctionnel : un propriétaire par donnée Message : des faits au passé, versionnés Transport : une exchange, une queue par service Diffusion : SSE vers les fronts Code : seuls les contrats sont partagés Transactionnel : outbox et idempotence

1. Fonctionnel : un propriétaire par donnée

Chaque donnée a un seul service propriétaire. Lui seul l'écrit, et lui seul publie ses changements :

  • order possède les commandes, leurs lignes et les clients (Order, OrderLine, Customer) ;
  • stock possède les quantités disponibles et les réservations (Reservation) ;
  • shipping possède les expéditions (Shipment) ;
  • billing possède les factures.

Un service qui a besoin de la donnée d'un autre la demande en HTTP, ou en garde une copie alimentée par les événements. shipping garde ainsi une copie du statut de chaque commande, sans jamais la modifier. Si l'ordre compte, un numéro de version empêche un événement en retard d'écraser un état plus récent.

2. Message : événement, commande, requête

Trois sortes de messages circulent, et chacune a son canal :

  • un événement est un fait passé, diffusé par l'exchange d'événements à qui veut l'entendre ;
  • une commande demande une action à un service précis, en point à point ;
  • une requête attend une réponse immédiate : elle reste synchrone, en HTTP.

Les événements suivent la convention <contexte>.<entité>.<fait au passé>.v<N> : order.order.placed.v1, stock.reservation.confirmed.v1. Le contexte est le service propriétaire du fait, ce qui relie cet axe au précédent. Le message reserve-stock du début était une commande déguisée : diffusée, elle pouvait être exécutée par deux services, ou par aucun.

Chaque événement voyage dans une enveloppe JSON commune : nom, date, producteur, révision de schéma, identifiant de corrélation et payload. Son event_id, un UUID v7 posé par le producteur, permet aux consommateurs d'écarter les doublons.

3. Transport : une exchange, une queue par service

Une seule topic exchange, app.events, porte tous les événements. Chaque service consommateur possède une queue à son nom (stock.events, shipping.events, billing.events) et choisit ses binding keys. Le producteur publie une fois, et RabbitMQ remet une copie à chaque queue intéressée.

Les binding keys restent précises : jamais #, et le joker * seulement pour une famille d'événements traitée en entier. Une queue ne se découpe que sur un signal mesuré, et les queues découpées gardent le préfixe du service. Le détail est dans notre article sur la topologie RabbitMQ.

4. Diffusion : vers les fronts, par SSE

Les fronts ne lisent pas RabbitMQ. Un hub Mercure leur pousse les changements par Server-Sent Events (SSE), après le commit du service qui les publie. Chaque onglet ouvre une seule connexion, quel que soit le nombre de services.

Chaque service publie sous ses propres préfixes de topics, avec un jeton limité à ces préfixes. Les données personnelles partent en mises à jour privées. Un seul service pose le cookie des abonnés, sur le domaine parent qu'il partage avec le hub et les fronts.

Les garanties ne sont pas celles du broker. Une mise à jour perdue ne retarde que l'affichage : le front relit l'API à chaque connexion, car Mercure notifie et l'API fait foi.

5. Code : partager les contrats, rien d'autre

Le monorepo rend le partage de code facile, et c'est un piège. Nous ne partageons qu'un package léger, app-contracts, sans dépendance d'exécution. Il contient le nom de l'exchange, les clés de routage, un DTO readonly par événement, les JSON Schemas et des fixtures figées.

Le reste vit dans chaque service : entités Doctrine, outbox, queues, binding keys, handlers, sérialiseur Messenger. Une entité partagée couplerait deux bases, un handler partagé deux déploiements.

Les messages évoluent par ajout de champs optionnels, et tout autre changement crée une v2, publiée à côté de la v1. Le producteur valide chaque message contre son schéma, et le consommateur ignore les champs qu'il ne connaît pas. Notre article sur les contrats de messages en monorepo détaille ces règles.

6. Transactionnel : outbox, idempotence, frontières

Une transaction ne couvre qu'une base, celle d'un seul service. Elle n'inclut ni le broker, ni le hub Mercure, ni un autre service. Deux mécanismes comblent l'écart, un de chaque côté du broker.

Côté producteur, l'outbox : l'événement est écrit dans une table, dans la même transaction que le changement métier. Un relais publie chaque ligne, attend la confirmation du broker, la marque publiée, puis commite. Au pire un doublon, jamais une perte : notre article sur le pattern Outbox détaille ce relais.

Côté consommateur, l'idempotence : un middleware placé après doctrine_transaction insère l'event_id dans une table processed_event, dans la transaction de l'effet. Un doublon n'insère rien, et aucun handler n'est appelé. Un effet hors base, comme un e-mail, demande sa propre clé d'idempotence ou un message envoyé après le commit.

Reste le message qui échoue à chaque tentative. Messenger le retente avec un délai croissant, puis le range dans un failure transport, où il reste lisible et rejouable. La DLX de RabbitMQ reste réservée aux consommateurs écrits dans un autre langage.

La carte des services

Le tableau croise les premiers axes sur notre fil rouge. Les préfixes de topics sont relatifs à https://example.com.

Service Émet Consomme Vers les fronts
order order.order.placed.v1, order.order.cancelled.v1 aucun événement /orders/*, privé
stock stock.reservation.confirmed.v1 order.order.placed.v1, order.order.cancelled.v1 /stock/*, public
shipping shipping.shipment.dispatched.v1 order.order.*.v1, stock.reservation.confirmed.v1 /shipments/*, privé
billing aucun événement order.order.*.v1, shipping.shipment.dispatched.v1 aucune mise à jour

Une ligne dit ce qu'un service déclare : sa queue, ses binding keys, ses topics. La colonne « Consomme » dit qui sera touché par un changement de contrat. billing consomme sans rien émettre : ses factures se consultent par son API.

Les compromis assumés

Plus de pièces à exploiter. Une outbox et son relais par producteur, une table processed_event par consommateur, un hub Mercure, un package de contrats. Chacun demande une surveillance et, pour les tables, une purge.

Une cohérence à terme. Entre deux services, une donnée converge avec un délai : celui du relais, puis celui du consommateur. Un écran qui doit refléter l'état exact relit l'API du propriétaire.

Des conventions à faire respecter. Le nommage et les ajouts optionnels ne tiennent que s'ils sont vérifiés. Nous les confions à la CI, par les constantes partagées et les fixtures figées. La revue de code repère le reste, comme une v2 publiée sans sa v1.

Des horizons à aligner. La rétention de l'outbox, la purge de processed_event et le séjour en failure transport dépendent les uns des autres. Purger processed_event trop tôt laisse un rejeu retraiter des événements déjà appliqués.

Les signaux qui doivent faire réexaminer un axe :

  • le rejeu devient une routine plutôt qu'un geste d'exception : un journal comme Kafka devient pertinent ;
  • le relais, mesuré, prend trop de retard ou charge la base : la capture des changements (CDC) remplace la lecture périodique. Si le volume en est la cause, Kafka devient pertinent ;
  • les écrans assemblent plusieurs services, ou les droits ne s'expriment plus par préfixes de topics : un BFF revient ;
  • un consommateur est écrit dans un autre langage : les schémas servent à générer son code, et une DLX gère sa quarantaine ;
  • des messages lourds retardent des messages critiques dans une même queue : cette queue se découpe.

Mise en œuvre

Restent des gestes courants, que nous écrivons comme des procédures versionnées avec le code.

Faire évoluer un contrat

  1. Modifier ensemble, dans app-contracts, le JSON Schema, le DTO et, pour un nouvel événement, sa constante de routage.
  2. Pour un ajout optionnel, monter la révision (1.0 vers 1.1) et ajouter une fixture, sans toucher aux anciennes.
  3. Pour tout autre changement, créer l'événement v2 et le publier à côté de la v1, par deux lignes d'outbox dans la même transaction.
  4. Laisser la CI valider chaque fixture figée contre son schéma et son DTO : un écart bloque la fusion.
  5. Lier chaque consommateur à la v2, handler compris, puis supprimer dans RabbitMQ la binding key de la v1 : Messenger n'en retire jamais. Entre-temps, chaque fait arrive deux fois, sous deux event_id : le handler écarte le doublon par clé métier.
  6. Retirer la v1 quand aucune queue ne la reçoit plus et qu'aucun message v1 ne peut être rejoué.

Ajouter un consommateur

Prenons billing, arrivé après les autres services.

  1. Déclarer la queue billing.events et ses binding keys, sans #, dans la configuration de billing. Le producteur ne change pas.
  2. Écrire un handler pour chaque événement que ces binding keys laissent passer, jokers compris.
  3. Mettre en place la réception : sérialiseur de l'enveloppe, middleware processed_event après doctrine_transaction, retry et failure transport.
  4. Déployer, puis lancer messenger:setup-transports : la queue reçoit le flux à partir de ce moment.
  5. Si l'historique compte, rejouer les événements passés depuis l'outbox de chaque producteur (order, shipping), sans ordre garanti entre eux. processed_event absorbe le recouvrement avec le flux normal.
  6. Mettre à jour le catalogue des abonnements, ou le régénérer depuis la configuration des services.

Rejouer des événements

Le rejeu republie des lignes de l'outbox d'un producteur vers la queue d'un seul consommateur. Notre article « Kafka ou RabbitMQ ? » explique pourquoi ce rejeu suffit tant qu'il reste ponctuel.

  1. Identifier le producteur, la clé de routage, la période et la queue cible.
  2. Vérifier que la queue cible est liée à cette clé : le rejeu passe par l'exchange par défaut, qui ignore les bindings.
  3. Vérifier que la période reste dans l'horizon de l'outbox et dans celui de processed_event chez le consommateur.
  4. Compter les lignes sans rien publier (option --dry-run), puis republier.
  5. Après un bug qui a produit un mauvais effet sans erreur, corriger d'abord les données. Retirer ensuite les lignes concernées de processed_event, sinon le consommateur écarte l'événement rejoué.

Traiter la quarantaine

  1. Lister les messages avec messenger:failed:show, et lire l'exception de chacun avec l'option -vv.
  2. Corriger la cause : une donnée manquante, un bug du handler, une dépendance indisponible.
  3. Retraiter avec messenger:failed:retry. Si une autre copie a été appliquée entre-temps, processed_event l'écarte.
  4. Écarter avec messenger:failed:remove un message qui ne doit pas être traité.
  5. Pour un consommateur écrit dans un autre langage, lire la queue alimentée par la DLX et son en-tête x-death.

Checklist

  • Un service propriétaire pour chaque donnée ; ailleurs, des copies en lecture seule.
  • Des événements au passé, nommés <contexte>.<entité>.<fait au passé>.v<N>.
  • Les commandes en point à point et les requêtes en HTTP, hors de l'exchange d'événements.
  • Une seule topic exchange, une queue par service consommateur, aucune binding key #.
  • Vers les fronts, des préfixes de topics propres à chaque service, et un jeton limité à ces préfixes.
  • Un package de contrats sans dépendance d'exécution ; entités, queues et handlers hors du package.
  • Une outbox chez chaque producteur, une table processed_event chez chaque consommateur.
  • Un seul niveau de quarantaine par queue.
  • Des horizons alignés : rétention de l'outbox, purge de processed_event, séjour en quarantaine.
  • Des procédures écrites pour les contrats, les consommateurs, le rejeu et la quarantaine.
  • Pour chaque axe, le signal qui doit le faire réexaminer.

Sources