Quand un service en appelle directement un autre en HTTP, il en devient prisonnier : si le service de facturation est lent ou en panne, la prise de commande ralentit ou échoue avec lui. L’architecture orientée événements brise ce lien. Au lieu de commander aux autres d’agir, un service annonce qu’un fait s’est produit, et ceux que cela intéresse réagissent à leur rythme. Dans ce tutoriel, vous allez publier l’événement « commande confirmée » depuis une API FastAPI et le faire consommer par deux services indépendants, à travers RabbitMQ.
🎯 Ce que vous allez apprendre
- Distinguer un événement (un fait passé) d’une commande (un ordre à exécuter).
- Mettre en place la topologie RabbitMQ : échange, file et liaison.
- Publier un événement durable depuis une API FastAPI avec
aio-pika. - Consommer cet événement dans des services découplés qui s’abonnent sans connaître l’émetteur.
- Rendre un consommateur idempotent et gérer l’acquittement des messages.
🛠️ Ce que vous allez construire
Un service de commandes qui, lorsqu’une commande est confirmée, publie un événement sur RabbitMQ. Deux consommateurs distincts s’y abonnent : l’un simule l’émission d’une facture, l’autre l’envoi d’une notification. Vous pourrez arrêter l’un sans gêner l’autre, et en ajouter un troisième sans toucher au producteur — la preuve vivante du découplage.
Prérequis
- Python 3.11 ou plus récent, et Docker pour lancer RabbitMQ.
- Les bibliothèques :
pip install fastapi uvicorn aio-pika. - Niveau intermédiaire. Test express : si vous savez écrire une route FastAPI et lancer un conteneur Docker, vous êtes prêt.
- ⏱️ Temps estimé : ~55 minutes.
Étape 1 — Événement ou commande ?
La distinction est subtile mais structurante. Une commande exprime une intention adressée à un destinataire précis : « émets la facture de la commande 4072 ». Elle suppose qu’on sait qui doit agir et quoi attendre en retour. Un événement, lui, constate un fait déjà accompli : « la commande 4072 a été confirmée ». Il ne s’adresse à personne en particulier et n’attend pas de réponse. C’est cette nuance qui fait toute la différence d’architecture.
En publiant un événement plutôt qu’en envoyant une commande, le service de commandes cesse de se soucier de ce qui doit suivre. Il dit son fait au monde et passe à autre chose. La facturation, la notification, les statistiques — chacun décide pour lui-même de réagir ou non. Le nom d’un événement se formule donc toujours au passé : OrderConfirmed, PaymentReceived, OrderShipped. Cette convention n’est pas cosmétique : elle force à penser en termes de faits, pas d’ordres, et c’est ce changement de regard qui découple réellement les services.
✅ Point d’étape — Listez trois choses qui doivent se produire après la confirmation d’une commande. Pour chacune, demandez-vous : le service de commandes a-t-il besoin d’attendre le résultat ? Si non, c’est un parfait candidat à un événement plutôt qu’à un appel direct.
Étape 2 — Lancer RabbitMQ
RabbitMQ jouera le rôle d’intermédiaire : il reçoit les événements du producteur et les distribue aux consommateurs abonnés. On le démarre en une commande grâce à son image Docker officielle, qui inclut une interface d’administration bien utile pour observer ce qui circule.
docker run -d --name rabbitmq \
-p 5672:5672 -p 15672:15672 \
rabbitmq:4-management
Le port 5672 est celui du protocole de messagerie (AMQP) ; le port 15672 expose la console web, accessible sur http://localhost:15672 avec les identifiants par défaut guest / guest. Au moment d’écrire, la série 4 est la version courante de RabbitMQ. Une fois le conteneur démarré, ouvrez la console : vous y verrez bientôt apparaître l’échange et les files que nous allons créer, ce qui rend le mécanisme tangible.
Étape 3 — Le producteur : publier l’événement
Côté commandes, on ouvre une connexion robuste à RabbitMQ au démarrage de l’application, et on déclare un échange de type topic. L’échange est le point d’entrée : le producteur ne connaît que lui, jamais les files ni les consommateurs. On utilise le cycle de vie lifespan de FastAPI pour ouvrir et fermer proprement la connexion.
# producer.py — service de commandes (FastAPI)
import json
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from uuid import uuid4
import aio_pika
from fastapi import FastAPI
AMQP_URL = "amqp://guest:guest@localhost/"
@asynccontextmanager
async def lifespan(app: FastAPI):
connection = await aio_pika.connect_robust(AMQP_URL)
channel = await connection.channel()
app.state.exchange = await channel.declare_exchange(
"shop.events", aio_pika.ExchangeType.TOPIC, durable=True
)
yield
await connection.close()
app = FastAPI(lifespan=lifespan)
@app.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: str):
event = {
"type": "OrderConfirmed",
"orderId": order_id,
"occurredAt": datetime.now(timezone.utc).isoformat(),
}
message = aio_pika.Message(
body=json.dumps(event).encode(),
content_type="application/json",
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, # survit à un redémarrage
message_id=str(uuid4()), # identifiant unique du message
)
await app.state.exchange.publish(message, routing_key="order.confirmed")
return {"status": "published", "orderId": order_id}
Deux choix méritent attention. Le delivery_mode persistant demande à RabbitMQ d’écrire le message sur disque : il survivra à un redémarrage du serveur. Le message_id unique servira aux consommateurs à détecter les doublons. La clé de routage order.confirmed est l’étiquette du fait ; les consommateurs s’abonneront à ce motif. Remarquez surtout ce que le producteur ne fait pas : il n’appelle aucun service, ne connaît aucun abonné. Il dépose un fait et rend la main immédiatement.
Étape 4 — La topologie : échange, file, liaison
Trois objets structurent la messagerie RabbitMQ, et les confondre est la première source d’erreur. L’échange reçoit les messages du producteur. La file stocke les messages en attendant qu’un consommateur les traite. La liaison connecte une file à un échange selon un motif de clé de routage. Un échange de type topic route un message vers toutes les files dont la liaison correspond à la clé.
Concrètement, notre échange shop.events recevra l’événement avec la clé order.confirmed. Le service de facturation créera une file billing.order-confirmed liée au motif order.confirmed ; le service de notification créera la sienne, notify.order-confirmed, liée au même motif. Résultat : un seul événement publié, deux copies distribuées, chacune dans sa file. Si demain un service d’analyse veut aussi réagir, il crée sa propre file et se lie au motif — sans que le producteur ni les autres consommateurs ne changent d’une ligne.
Pourquoi un échange de type topic plutôt qu’un autre ? RabbitMQ en propose plusieurs, et le choix n’est pas anodin. L’échange direct route vers les files dont la clé correspond exactement, utile quand on a des catégories fixes. L’échange fanout diffuse à toutes les files liées, sans filtrage, comme une radio. L’échange topic, lui, route selon des motifs avec jokers : une file pourrait s’abonner à order.* pour capter tous les événements de commande (confirmée, expédiée, annulée) en une seule liaison. C’est cette flexibilité qui en fait le choix par défaut d’une architecture événementielle évolutive : la structure de vos clés (domaine.fait) devient un langage que les consommateurs interrogent à la granularité qu’ils veulent.
Un autre choix de conception mérite d’être posé tôt : que met-on dans l’événement ? Deux styles s’opposent, bien décrits par Martin Fowler. La notification d’événement ne transporte que le strict minimum — un identifiant — et laisse les consommateurs rappeler le producteur s’ils veulent plus de détails ; elle garde les messages légers mais recrée une dépendance. Le transfert d’état porté par l’événement embarque au contraire toutes les données utiles (ici, le montant, le client, les lignes), de sorte que le consommateur n’a besoin de personne pour agir ; les messages sont plus gros, mais l’autonomie est totale. Pour une architecture qui vise le découplage, ce second style est souvent préférable, à condition de versionner soigneusement la structure des événements.
Étape 5 — Le consommateur : s’abonner et réagir
Chaque consommateur est un programme indépendant. Il déclare la même topologie (l’opération est idempotente : déclarer un échange qui existe déjà ne fait rien), lie sa file, et traite les messages au fil de l’eau. Voici le service de facturation.
# billing_consumer.py — service de facturation
import asyncio
import json
import aio_pika
PROCESSED: set[str] = set() # mémoire de démo ; en production, un store persistant
async def on_message(message: aio_pika.abc.AbstractIncomingMessage) -> None:
# process(requeue=True) acquitte le message si le bloc se termine sans
# exception, et le remet en file en cas d'erreur pour une nouvelle tentative.
async with message.process(requeue=True):
if message.message_id in PROCESSED:
print(f"Doublon ignoré : {message.message_id}")
return
event = json.loads(message.body)
print(f"Facture émise pour la commande {event['orderId']}")
PROCESSED.add(message.message_id)
async def main() -> None:
connection = await aio_pika.connect_robust("amqp://guest:guest@localhost/")
channel = await connection.channel()
await channel.set_qos(prefetch_count=10) # 10 messages en vol au maximum
exchange = await channel.declare_exchange(
"shop.events", aio_pika.ExchangeType.TOPIC, durable=True
)
queue = await channel.declare_queue("billing.order-confirmed", durable=True)
await queue.bind(exchange, routing_key="order.confirmed")
await queue.consume(on_message)
print("Facturation : en attente d'événements…")
await asyncio.Future() # bloque indéfiniment
if __name__ == "__main__":
asyncio.run(main())
Le service de notification serait identique, à deux différences près : le nom de sa file (notify.order-confirmed) et l’action effectuée (envoyer un message au lieu d’émettre une facture). C’est tout l’intérêt : ajouter une réaction au système revient à écrire un petit programme autonome, sans jamais modifier le producteur. Le prefetch_count limite le nombre de messages qu’un consommateur traite en parallèle, ce qui évite qu’un seul instance ne se voie submergée pendant que d’autres restent oisives.
✅ Point d’étape — Démarrez le producteur (
uvicorn producer:app) et le consommateur, puis appelezcurl -X POST http://localhost:8000/orders/4072/confirm. Le consommateur doit afficher « Facture émise pour la commande 4072 ». Dans la console RabbitMQ, la filebilling.order-confirmeddoit montrer un message entré puis acquitté.
Étape 6 — Idempotence et acquittement
La messagerie offre une garantie précieuse — « au moins une fois » — mais qui a un revers : un même message peut être livré deux fois, par exemple si un consommateur plante juste après avoir agi mais avant d’avoir acquitté. Concevoir des consommateurs idempotents, c’est-à-dire dont le retraitement d’un même événement ne produit aucun effet supplémentaire, n’est donc pas une option : c’est une obligation. Émettre deux fois la même facture serait une faute métier grave.
Notre code s’en protège par le message_id : on mémorise les identifiants déjà traités et on ignore les doublons. L’ensemble PROCESSED en mémoire suffit pour la démonstration, mais en production il faut un stockage durable — une table avec une contrainte d’unicité sur l’identifiant fait parfaitement l’affaire. Quant à l’acquittement, le bloc async with message.process() s’en charge : si le traitement réussit, le message est acquitté et retiré de la file ; s’il lève une exception, il est remis en file pour une nouvelle tentative. Cette mécanique, combinée à l’idempotence, est ce qui rend l’architecture événementielle fiable malgré les pannes.
Étape 7 — Vérification du découplage
Le vrai test n’est pas qu’un message passe, mais que les services soient réellement indépendants. Faites l’expérience suivante : arrêtez le consommateur de facturation, puis confirmez trois commandes. Le producteur répond normalement, sans erreur — il ignore que personne n’écoute. RabbitMQ conserve les trois messages dans la file billing.order-confirmed, que vous voyez s’accumuler dans la console. Redémarrez le consommateur : il rattrape aussitôt les trois messages en attente et émet les trois factures.
Cette démonstration résume tout le bénéfice de l’approche. Une panne d’un consommateur ne se propage pas à l’émetteur ; les messages patientent sans être perdus ; le système se rééquilibre dès que le service revient. C’est la cohérence à terme évoquée dans le guide principal : pendant un instant, la commande existait sans sa facture, mais le système a convergé vers un état cohérent dès que possible. Accepter et borner ce délai est le prix — modeste — de la résilience gagnée.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Les messages disparaissent au redémarrage | Échange ou file non durables, message non persistant | durable=True partout et DeliveryMode.PERSISTENT à la publication |
| Une facture émise deux fois | Consommateur non idempotent | Mémoriser le message_id traité dans un store durable |
| Le consommateur ne reçoit rien | File non liée, ou clé de routage différente | Vérifier queue.bind et la concordance des clés |
| Un consommateur sature, les autres chôment | Pas de limite de préchargement | Régler prefetch_count via set_qos |
| Messages bloqués « unacked » | Exception silencieuse sans acquittement | Utiliser async with message.process() qui gère ack et rejet |
✅ Récapitulatif
Vous avez relié des services par des faits, non par des appels. Le producteur publie un événement durable sur un échange RabbitMQ sans connaître ses abonnés ; les consommateurs lient leurs files au même motif et réagissent indépendamment ; l’idempotence et l’acquittement automatique rendent l’ensemble fiable malgré les pannes. Vous avez surtout vérifié, expérience à l’appui, que l’arrêt d’un consommateur n’affecte ni l’émetteur ni les autres. C’est cette autonomie qui permet à un système distribué de grandir sans se transformer en château de cartes.
🧾 Aide-mémoire
| Concept | Rôle |
|---|---|
| Échange (topic) | Point d’entrée ; route selon la clé vers les files liées |
| File | Stocke les messages en attente de traitement |
| Liaison | Connecte une file à un échange selon un motif |
| Clé de routage | Étiquette du fait, ex. order.confirmed |
durable / PERSISTENT |
Survie des objets et messages à un redémarrage |
| Idempotence | Retraiter un message ne produit aucun effet en plus |
💪 À vous de jouer
Ajoutez un troisième consommateur « analytics » qui compte les commandes confirmées, sans modifier le producteur ni les autres consommateurs. Donnez-lui sa propre file et liez-la au motif order.confirmed.
Voir une solution
# analytics_consumer.py
queue = await channel.declare_queue("analytics.order-confirmed", durable=True)
await queue.bind(exchange, routing_key="order.confirmed")
# dans on_message : un compteur global incrémenté à chaque événement non dupliqué
Le simple fait de pouvoir ajouter ce service sans toucher au reste prouve que le découplage fonctionne.
Tutoriels liés
- CQRS et Event Sourcing pas à pas — quand les événements deviennent la source de vérité elle-même.
- Saga : transactions distribuées — coordonner plusieurs services par échange d’événements.
Pour aller plus loin
- 🔝 Revenir à la vue d’ensemble : Architecture logicielle moderne.
- Pour les très gros volumes, comparez avec un journal d’événements : Apache Kafka en production et le comparatif des solutions de streaming.
- Tutoriels officiels RabbitMQ et la documentation aio-pika.
Questions fréquentes
RabbitMQ ou Kafka ?
RabbitMQ excelle dans le routage flexible de messages entre services et la distribution de tâches ; Kafka brille pour les journaux d’événements à très haut débit qu’on rejoue et conserve longtemps. Pour la communication événementielle entre services applicatifs comme ici, RabbitMQ est souvent le choix le plus simple et le plus rapide à mettre en œuvre.
Que se passe-t-il si un message échoue indéfiniment ?
On configure une file de lettres mortes (dead-letter queue) : après un certain nombre de tentatives, le message y est déplacé pour analyse, au lieu de bloquer la file en boucle. C’est l’étape suivante naturelle une fois les bases en place.
Faut-il un schéma pour les événements ?
Oui, dès que plusieurs équipes consomment vos événements. Documenter et versionner la structure d’un événement (ses champs, leurs types) évite qu’un changement côté producteur ne casse silencieusement les consommateurs. Un simple document partagé suffit pour démarrer.
Mots-clés : architecture orientée événements, event-driven, RabbitMQ, FastAPI, aio-pika, messagerie asynchrone, idempotence, cohérence à terme.