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-stockest 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.
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 :
-
orderpossède les commandes, leurs lignes et les clients (Order,OrderLine,Customer) ; -
stockpossède les quantités disponibles et les réservations (Reservation) ; -
shippingpossède les expéditions (Shipment) ; billingpossè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.,
order.order.
|
aucun événement | /orders/*, privé |
stock |
stock.reservation.
|
order.order.,
order.order.
|
/stock/*, public |
shipping |
shipping.shipment.
|
order.order.*.v1,
stock.reservation.
|
/shipments/*, privé |
billing |
aucun événement |
order.order.*.v1,
shipping.shipment.
|
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
-
Modifier ensemble, dans
app-contracts, le JSON Schema, le DTO et, pour un nouvel événement, sa constante de routage. -
Pour un ajout optionnel, monter la révision (
1.0vers1.1) et ajouter une fixture, sans toucher aux anciennes. -
Pour tout autre changement, créer l'événement
v2et le publier à côté de lav1, par deux lignes d'outbox dans la même transaction. - Laisser la CI valider chaque fixture figée contre son schéma et son DTO : un écart bloque la fusion.
-
Lier chaque consommateur à la
v2, handler compris, puis supprimer dans RabbitMQ la binding key de lav1: Messenger n'en retire jamais. Entre-temps, chaque fait arrive deux fois, sous deuxevent_id: le handler écarte le doublon par clé métier. -
Retirer la
v1quand aucune queue ne la reçoit plus et qu'aucun messagev1ne peut être rejoué.
Ajouter un consommateur
Prenons billing, arrivé après les autres
services.
-
Déclarer la queue
billing.eventset ses binding keys, sans#, dans la configuration debilling. Le producteur ne change pas. - Écrire un handler pour chaque événement que ces binding keys laissent passer, jokers compris.
-
Mettre en place la réception : sérialiseur de l'enveloppe,
middleware
processed_eventaprèsdoctrine_transaction, retry et failure transport. -
Déployer, puis lancer
messenger:setup-transports: la queue reçoit le flux à partir de ce moment. -
Si l'historique compte, rejouer les événements passés
depuis l'outbox de chaque producteur (
order,shipping), sans ordre garanti entre eux.processed_eventabsorbe le recouvrement avec le flux normal. - 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.
- Identifier le producteur, la clé de routage, la période et la queue cible.
- Vérifier que la queue cible est liée à cette clé : le rejeu passe par l'exchange par défaut, qui ignore les bindings.
-
Vérifier que la période reste dans l'horizon de l'outbox
et dans celui de
processed_eventchez le consommateur. -
Compter les lignes sans rien publier (option
--dry-run), puis republier. -
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
-
Lister les messages avec
messenger:failed:show, et lire l'exception de chacun avec l'option-vv. - Corriger la cause : une donnée manquante, un bug du handler, une dépendance indisponible.
-
Retraiter avec
messenger:failed:retry. Si une autre copie a été appliquée entre-temps,processed_eventl'écarte. -
Écarter avec
messenger:failed:removeun message qui ne doit pas être traité. -
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_eventchez 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
- martinfowler.com, What do you mean by “Event-Driven”? : notification d'événement et transfert d'état par événement.
- Enterprise Integration Patterns, Event Message, Command Message et Publish-Subscribe Channel.
- microservices.io, Database per service, Transactional outbox et Idempotent Consumer.
- RabbitMQ, tutoriel Topics, Exchanges (exchange par défaut) et Dead Letter Exchanges.
- Symfony, Messenger : transport AMQP, middleware Doctrine, retries et failure transport.
- JSON Schema, spécification 2020-12.
- Mercure, spécification du protocole et documentation.
- MDN, Server-sent events.