Ban7 RemoteLab
🎓 Formation certifiante~2 mois au rythme standard

MSII : Microservices

Parcours projet en huit modules autour de MiniShop : cartographier un monolithe et arrêter son découpage, extraire un premier service, poser une passerelle et une identité distribuée, survivre aux pannes, découpler par des événements, faire aboutir une transaction sans transaction, rendre le système diagnosticable, puis déployer sur un cluster Kubernetes local. Une épreuve de synthèse de 7 h sur un projet distinct (TicketFlow) clôt le parcours. Prérequis : Docker, SQL et JavaScript/Node.

S'inscrire

Programme complet

Les textes des cours sont en accès libre. Les vidéos et exercices (🔒) sont réservés aux abonnés.

Cartographier le monolithe MiniShop et arrêter son découpage

  • MiniShop : le monolithe qu'on va démonter

    Vous allez passer huit modules à construire MiniShop en microservices. Pas une suite de travaux pratiques d'illustration : **un seul projet**, repris du premier au dernier module, où chaque étape ajoute un incrément et fait apparaître le problème que l'étape suivante résout. À la fin du parcours, MiniShop tournera sur un [[cluster|cluster]] [[Kubernetes|kubernetes]] local, en cinq services, quatre bases, un courtier de messages et une passerelle.

    Aujourd'hui, MiniShop est un monolithe. Un seul processus [[Node|node-runtime?]], un seul dépôt, une seule base PostgreSQL. Il fonctionne, il est testé, et — c'est important — **il n'a rien d'un mauvais logiciel**. Le découper n'est pas une réparation : c'est un arbitrage, avec un prix. Ce module sert précisément à établir ce prix avant de le payer.

    Le périmètre fonctionnel est volontairement pauvre : consulter un catalogue de produits avec leur stock, créer un compte et s'authentifier, passer une commande portant sur un ou plusieurs produits, réserver le stock puis « payer » via un service de paiement simulé, recevoir une notification de confirmation ou d'échec. L'intérêt de MiniShop est architectural, pas métier.

    Regardez bien la réponse ci-dessus : la commande porte `productName` et `unitPrice`. Le module commandes ne stocke ni l'un ni l'autre — il les récupère par une [[jointure|jointure]] SQL sur la table des produits, au moment de la lecture. Cette [[jointure|jointure]] prend trois millisecondes et personne n'y pense jamais. C'est elle qui va casser au module 2, et c'est le premier fil que nous allons tirer.

    Trois `JOIN`, trois modules traversés, une seule transaction. Cette requête est la meilleure illustration de ce qu'un monolithe offre gratuitement et qu'un système distribué facture cher : lire de façon cohérente des données appartenant à trois domaines différents, en une opération atomique.

    Un mot sur la méthode du parcours, parce qu'elle explique la forme des modules. **La théorie n'arrive jamais avant que le problème ne se soit manifesté.** On extrait d'abord un service, on constate que la [[jointure|jointure]] est cassée, et *ensuite* on parle de duplication de données et de cohérence à terme. L'ordre inverse produit des notions apprises sans point d'accroche, oubliées en une semaine.

    Dernier point de dispositif, et le plus important : chaque module dispose d'un dépôt de départ propre (`seq-1-start`, `seq-2-start`, …) et d'un corrigé (`seq-N-done`). Un bogue hérité du module précédent ne doit jamais vous empêcher de faire le module suivant. Repartir du dépôt propre n'est pas un aveu d'échec, c'est le fonctionnement prévu.

  • Ce qu'on achète et ce qu'on paie

    La question n'est pas « les microservices, c'est bien ou c'est mal ». C'est un style d'architecture, avec un solde. Ce chapitre pose les deux colonnes de ce solde, honnêtement, parce que c'est ce qui vous permettra plus tard de dire non à un découpage — ce qui est bien plus utile professionnellement que de savoir dire oui.

    Deux lignes méritent d'être relues : latence et cohérence. Ce sont les seules où la colonne « ce qu'on achète » est vide. Un découpage ne rend jamais un parcours plus rapide ni plus cohérent — il le rend au mieux **aussi** rapide et **aussi** cohérent, au prix d'un travail considérable. Quiconque vous vend les microservices comme une optimisation de performance se trompe de sujet.

    Reste la loi de Conway, énoncée en 1967 et jamais démentie depuis : *toute organisation qui conçoit un système produit une conception dont la structure copie la structure de communication de l'organisation*. Autrement dit, si trois équipes conçoivent un système, il aura trois grandes parties, et les frontières tomberont là où passent les frontières d'équipe.

    La conséquence pratique est contre-intuitive : découper un système sans découper l'organisation ne marche pas. Les frontières techniques que vous poserez seront progressivement percées par les habitudes de communication existantes, jusqu'à retrouver un monolithe — mais avec du réseau au milieu.

    Sur MiniShop, vous serez à la fois les cinq équipes. C'est irréaliste, et vous devez le savoir : ce parcours vous fait vivre les coûts techniques du découpage, pas ses coûts organisationnels, qui sont en pratique les plus lourds. Gardez-le en tête quand vous conseillerez une architecture en entreprise.

  • Cartographier les dépendances réelles

    Premier travail concret. Par binôme, vous allez relever les dépendances **réelles** du monolithe. Réelles veut dire : lues dans le code, pas dans le `README` ni dans le schéma d'architecture qu'on vous a donné. Un couplage non documenté est la première cause d'échec d'une extraction — il se découvre en production, trois semaines après.

    Trois modules métier bien séparés en apparence, plus un répertoire `shared` — c'est presque toujours là que se cachent les surprises. Votre relevé doit distinguer trois natures de dépendance, parce qu'elles ne coûtent pas la même chose à couper.

    Le relevé donne déjà une hiérarchie : `users` ne dépend de personne, `catalog` ne dépend que du code partagé, `orders` dépend des deux. Ce n'est pas un hasard — c'est le sens de lecture du métier : on ne commande pas sans produit ni sans compte. Une frontière posée à contre-sens de cette hiérarchie créerait une dépendance circulaire entre services, la pire des situations.

    **Livrable du chapitre** : votre carte, sous la forme que vous voulez (schéma, tableau, texte structuré), à condition qu'elle distingue les trois natures de dépendance et qu'elle nomme, pour chacune des sept tables, le module qui en est **propriétaire** — c'est-à-dire le seul autorisé à y écrire. Cette colonne « propriétaire » est le vrai résultat du chapitre : c'est elle qui décidera du découpage.

  • Choisir une frontière : trois critères et une loi

    Vous avez la carte. Reste à décider où couper. Il n'existe pas de découpage correct — seulement des arbitrages, défendables ou non. Ce qui est évaluable, ce n'est donc pas votre découpage : c'est le **critère** que vous invoquez pour chaque frontière. Une frontière sans critère explicite est une frontière décorative.

    **Critère 1 — qui possède la donnée.** Chaque table doit avoir exactement un service qui y écrit. Les autres la lisent par l'[[API|api]] de ce service, ou en gardent une copie qu'ils ne modifient jamais. Si deux services écrivent dans `products`, la frontière est mal posée : vous venez de recréer une base partagée avec du réseau en plus.

    **Critère 2 — ce qui change en même temps.** Si toute évolution du catalogue impose de livrer les commandes le même jour, la frontière ne vous rend rien : vous payez le réseau sans acheter l'autonomie de déploiement. Ce critère se vérifie dans l'[[historique Git|historique-git]] — les fichiers qui changent systématiquement ensemble appartiennent au même service.

    Six commits sur soixante-dix-neuf touchent les deux modules : moins de dix pour cent. La frontière entre catalogue et commandes est donc légitime au regard du critère 2. Si le chiffre avait été de soixante pour cent, il aurait fallu se demander sérieusement si les deux ne forment pas un seul domaine mal nommé.

    **Critère 3 — le profil de charge.** Le catalogue est lu massivement pendant toute la durée de vie du site, sans écriture. Les commandes s'écrivent par rafales, à l'ouverture des ventes. Le paiement est lent parce qu'il attend un tiers. Trois profils différents, donc trois besoins de mise à l'échelle différents — c'est le critère qui rend la frontière **rentable**, pas seulement propre.

    **Livrable du chapitre** : votre découpage, sous forme de liste de services, avec pour chacun la ou les tables qu'il possède et **une phrase de justification par frontière**, nommant explicitement le critère invoqué. Une justification du type « c'est plus propre » sera refusée au point de synchronisation.

  • Le piège du découpage par couche technique

    Il existe un découpage que presque tous les groupes proposent au moins une fois, et qui est faux. Il consiste à faire des services à partir des **couches techniques** : un service base de données, un service métier, un service [[API|api]], éventuellement un service authentification qui fait tout ce qui touche à la sécurité. C'est l'erreur qui suit les gens en entreprise, donc autant la traiter tout de suite.

    Le découpage par couche paraît naturel parce que c'est ainsi qu'on organise un monolithe : `routes/`, `services/`, `repositories/`. Mais ce qui est une bonne organisation **de fichiers** est une mauvaise frontière **de déploiement**. La différence tient à ce qui traverse la frontière : dans un monolithe, un appel de fonction ; entre deux services, un contrat réseau à versionner.

    Le résultat porte un nom : le **monolithe distribué**. Des services déployables séparément, mais qui doivent l'être ensemble. On y cumule les inconvénients des deux styles — la latence réseau et la coordination des livraisons — sans aucun de leurs avantages. C'est le seul point du parcours où l'on peut parler d'une erreur sans nuance.

    Le quatrième symptôme mérite un mot, parce qu'il est le plus discuté : la bibliothèque de types partagée. Elle est tentante — elle évite de dupliquer une interface. Mais dès qu'elle est obligatoire, tous les services montent de version ensemble, et vous venez de recréer la livraison couplée par un autre chemin. La règle tenable : partager un **schéma** ([[JSON|json]] Schema, OpenAPI), jamais du **code**.

  • Rédiger les contrats d'API

    Un service se définit par ce qu'il promet, pas par son code. Ce chapitre produit donc le troisième livrable : le **contrat** de chaque service. Un contrat n'est pas une documentation écrite après coup pour faire joli — c'est ce qui permet à deux équipes de travailler en parallèle sans se parler tous les jours.

    La forme est libre : OpenAPI simplifié, ou un simple tableau ressource / verbe / charge utile. Ce qui compte est la précision. Voici le format attendu, sur l'exemple du service `catalog` — recopiez-le pour les quatre autres.

    Trois détails de ce contrat sont volontaires et méritent d'être repris dans les vôtres. **Les montants en centimes entiers** : un flottant traversant deux sérialisations [[JSON|json]] finit par arrondir, et un écart d'un centime sur un total est un incident client. **La réservation comme ressource** : `POST /products/{id}/reservations` crée un objet qu'on pourra annuler par `DELETE` — ce sera exactement la compensation de la saga au module 6.

    **Le `409` sur la réservation** : le stock insuffisant n'est pas une erreur du client (`400`) ni une panne (`500`), c'est un conflit d'état. Un appelant qui reçoit `409` sait qu'il ne doit pas réessayer, alors qu'un `503` l'y invite. Le code HTTP fait partie du contrat au même titre que la charge utile.

    **Livrable du chapitre** : les cinq contrats — `catalog`, `orders`, `users`, `payments`, `notifications` — au format ci-dessus. Le service `payments` n'existe pas encore dans le monolithe : écrivez son contrat quand même, à partir du périmètre fonctionnel. Concevoir le contrat d'un service qu'on n'a pas écrit est exactement le travail réel.

  • Anticiper les ruptures

    Quatrième et dernier livrable, et le plus important pour la suite : la liste de ce qui **marchait** dans le monolithe et qui ne marchera plus. Non pas pour se décourager, mais parce que chacune de ces ruptures est le sujet d'un module du parcours. Les nommer maintenant, avant d'écrire une ligne de code, change complètement la façon dont on les vit.

    Prenons la deuxième ligne, la plus lourde. Aujourd'hui, créer une commande et décrémenter le stock tiennent dans une transaction : si la seconde écriture échoue, la première est annulée, et personne ne voit jamais un état intermédiaire. Demain, ce sont deux bases différentes, sur deux services différents, sans transaction commune.

    Notez la ligne `201 PENDING`. C'est la rupture la plus difficile à faire accepter, parce qu'elle n'est pas technique : elle est **visible du client**. Le monolithe répondait « votre commande est confirmée ». Le système distribué répond « votre commande est enregistrée » — et la confirmation arrive quelques centaines de millisecondes plus tard. Toute interface qui ignore cette nuance produira des bogues d'affichage impossibles à corriger côté serveur.

    **Livrable du chapitre** : votre liste de ruptures, avec pour chacune une phrase décrivant le symptôme concret que verra un utilisateur. « La cohérence est à terme » n'est pas un symptôme ; « le stock affiché peut être faux pendant une seconde après une commande » en est un.

  • L'architecture cible commune

    Point de synchronisation collectif. Chaque binôme présente son découpage en cinq minutes. Vous allez constater que les découpages diffèrent — presque toujours sur la frontière entre `orders` et `payments`, et sur le sort de `shared/pricing`. C'est normal, et c'est le moment de le dire clairement : **il n'existe pas de découpage correct**, seulement des arbitrages assumés.

    Pour la suite du parcours, une architecture commune est cependant imposée. Non parce qu'elle serait la meilleure, mais parce que huit modules construits sur huit architectures différentes rendraient les corrigés inutilisables et le point de synchronisation impossible à animer. Si votre découpage diffère, notez en quoi : la restitution finale du parcours vous demandera de le défendre.

    Quatre bases pour cinq services : `notifications` n'en a pas, il écrit dans la sienne… en réalité si, il en aura une, mais minuscule. Retenez surtout que `payments` et `notifications` **ne reçoivent aucun appel HTTP**. Ils ne parlent qu'au courtier. C'est ce qui les rend insensibles à une panne des autres services, et c'est un choix d'architecture, pas une conséquence technique.

    Quatre technologies ont été **écartées délibérément**. NestJS : sa courbe d'apprentissage masquerait le sujet, on passerait le parcours à comprendre les [[décorateurs|decorateur]] plutôt que le distribué. Kafka : la puissance est réelle, le coût d'exploitation aussi, et le modèle des offsets n'est pas nécessaire ici.

    Event sourcing et CQRS : hors d'atteinte à ce stade, et surtout source de mauvais réflexes s'ils sont mal compris — on croit faire de l'event sourcing en publiant des événements, ce qui n'a rien à voir. Ces quatre sujets existent, ils sont légitimes, et vous les rencontrerez : ce parcours vous donne le socle qui permettra de les aborder plus tard sans les prendre pour des solutions magiques.

    Le module 2 commence par l'extraction de `catalog`. Vous en aurez besoin du dossier produit aujourd'hui : le contrat que vous avez écrit deviendra littéralement les routes du service, et la liste des ruptures deviendra la liste des tests à écrire. Rien de ce travail n'est un exercice pour l'exercice.

Extraire le service catalog du monolithe MiniShop

  • Anatomie d'un service autonome

    Avant d'écrire une ligne de `catalog`, il faut savoir ce qui distingue un service d'un module. Un module vit dans le processus de quelqu'un d'autre : il hérite de sa configuration, de son cycle de vie, de ses journaux. Un service n'hérite de rien. Il démarre seul, se configure seul, dit lui-même s'il va bien, et sait mourir proprement.

    La deuxième propriété — l'absence d'état — est celle qu'on croit avoir et qu'on n'a pas. Un compteur de requêtes en variable de module, un cache d'objets en mémoire, une session stockée dans un objet global : chacun de ces trois exemples paraît anodin et rend le service impossible à répliquer. Dès qu'il y a deux instances, une requête sur deux ne voit pas la bonne valeur.

    La configuration externalisée obéit à une règle simple, qu'on peut vérifier mécaniquement : **la même image doit pouvoir tourner en développement et en production sans être reconstruite**. Si changer d'environnement impose un `docker build`, la configuration est dans l'image, donc au mauvais endroit. Tout ce qui varie d'un environnement à l'autre passe par une variable d'environnement.

  • Créer le projet Fastify catalog

    On crée le projet. `catalog` vit dans son propre répertoire, avec son propre `package.json` — pas un espace de travail du monolithe, pas un sous-répertoire partagé. La séparation physique n'est pas une coquetterie : c'est ce qui rend impossible l'import accidentel d'un module du monolithe, qui est la première façon de rater une extraction.

    Le point d'entrée sépare deux responsabilités qu'on mélange presque toujours : **construire** l'application et la **démarrer**. Cette séparation n'a l'air de rien aujourd'hui ; elle est ce qui permettra, au module 7, de tester le service sans ouvrir de port réseau.

    Notez `host: '0.0.0.0'`. Par défaut, Fastify n'écoute que sur `127.0.0.1`, ce qui, dans un [[conteneur|conteneur-docker]], signifie « joignable uniquement depuis l'intérieur de ce [[conteneur|conteneur-docker]] ». C'est la première cause de [[conteneur|conteneur-docker]] qui démarre, affiche un journal parfait, et ne répond à personne.

    Toute la configuration passe par un seul module, qui lit `process.env` **une fois** et échoue au démarrage si une variable obligatoire manque. Échouer tôt et bruyamment vaut infiniment mieux qu'un `undefined` qui se propage jusqu'à une requête SQL trois heures plus tard.

    Reste les routes, qui reprennent mot pour mot le contrat écrit au module 1. Fastify valide les schémas d'entrée et de sortie qu'on lui déclare : une réponse qui ne respecte pas son schéma est rejetée **par le service lui-même**, avant d'atteindre l'appelant. C'est la raison principale du choix de Fastify pour ce parcours.

    > **Ressource à venir.** Aucun cours magistral ne couvre encore Fastify dans la bibliothèque : le magistral « Fastify » reste à créer, et un paragraphe de renvoi sera posé ici quand il existera. En attendant, ce chapitre se suffit à lui-même pour le parcours — seules les fonctionnalités employées ici sont expliquées, et la documentation officielle couvre le reste.

  • Le schéma Prisma et la base dédiée

    `catalog` possède `products` et `stock` — c'est la conclusion de votre carte du module 1. Possède veut dire : il est le seul à y écrire, et le seul à connaître leur structure. Personne d'autre ne s'y connecte, jamais, pas même en lecture, pas même « juste pour un rapport ».

    Deux choix de ce schéma engagent la suite du parcours. La contrainte `@@unique([orderId, productId])` interdit deux réservations pour la même commande et le même produit : c'est une **[[idempotence|idempotent]] par la base**, celle qui tient même si le service est répliqué. Le module 5 y reviendra en détail — c'est le mécanisme le plus robuste, et le moins coûteux à écrire.

    Le second choix est `priceCents` en entier. Un prix en flottant traverse deux sérialisations [[JSON|json]], une conversion de base, et finit par produire `89.00000000000001`. Sur un total de commande, l'écart devient un incident client. La règle vaut pour tout montant, partout dans le parcours : **entiers en centimes, jamais de flottant**.

    La migration produit un fichier SQL versionné dans le dépôt de `catalog`. C'est un point souvent négligé : **chaque service porte ses propres migrations**, dans son propre dépôt, appliquées par son propre déploiement. Une migration centralisée qui toucherait plusieurs bases recréerait exactement le couplage de livraison qu'on cherche à éliminer.

    > **Ressource à venir.** Prisma est employé dans tout le parcours sans qu'aucun magistral ne le couvre : le magistral « Prisma » reste à créer. Le magistral PostgreSQL référencé ci-dessous traite le SQL sous-jacent, ce qui reste la meilleure ressource disponible pour comprendre ce que la migration génère réellement.

  • Sondes de vivacité et de disponibilité

    Un service doit répondre à deux questions différentes, et c'est pour cela qu'il faut deux [[sondes|sonde-sante]]. **Suis-je vivant ?** — si non, il faut me tuer et me relancer. **Puis-je recevoir du trafic ?** — si non, il faut arrêter de m'en envoyer, mais surtout ne pas me tuer. Confondre les deux produit les deux pires comportements possibles.

    Trois détails comptent dans ce code. Le `503` — et non un `200` avec un champ `status: 'degraded'` : un répartiteur de charge lit le code HTTP, pas le corps. Le `try/catch` — une [[sonde|sonde-sante]] qui lève une exception non capturée produit un `500`, ce qui marche par accident mais ne journalise rien d'exploitable.

    Et le `SELECT 1` plutôt qu'une vraie requête métier : la [[sonde|sonde-sante]] doit être rapide et sans effet de bord. Une [[sonde|sonde-sante]] qui compte les produits fera un balayage de table toutes les cinq secondes, sur chaque instance — c'est une charge inutile que personne ne remarque jusqu'au jour où la table grossit.

    C'est le comportement recherché : base coupée, le processus reste vivant (`200`) et se déclare indisponible (`503`). Personne ne le tue, il attend le retour de la base, et il reprend le trafic tout seul. Vous retrouverez exactement ces deux [[sondes|sonde-sante]] en `livenessProbe` et `readinessProbe` au module 8.

  • Arrêt propre : SIGTERM et requêtes en vol

    Un [[conteneur|conteneur-docker]] qu'on arrête reçoit `SIGTERM`, puis, s'il n'a pas terminé au bout d'un délai de grâce, `SIGKILL`. Sans traitement de `SIGTERM`, [[Node|node-runtime?]] meurt instantanément : les requêtes en cours sont coupées au milieu, les clients reçoivent une connexion fermée, et les transactions ouvertes attendent leur expiration côté PostgreSQL.

    Le garde `enCoursDArret` évite un piège réel : sous Compose comme sous [[Kubernetes|kubernetes]], un second signal peut arriver pendant le drainage. Sans garde, on lance deux fermetures concurrentes, `app.close()` rejette, et l'arrêt « propre » se termine par une exception non capturée.

    Il reste un piège d'exécution, invisible dans le code : si le [[conteneur|conteneur-docker]] démarre [[Node|node-runtime?]] via un shell (`CMD npm start`), c'est le shell qui reçoit `SIGTERM`, pas [[Node|node-runtime?]], et rien de ce qui précède ne s'exécute. Le [[Dockerfile|dockerfile]] du chapitre suivant utilise donc la forme exec — `CMD ["node", "src/server.js"]` — pour que [[Node|node-runtime?]] soit le processus 1.

  • Le Dockerfile multi-stage

    L'image de `catalog` doit contenir le service et rien d'autre. Pas de compilateur, pas de dépendances de développement, pas de [[dépôt Git|depot-git]], pas de shell root disponible pour qui compromettrait le processus. C'est le rôle du `Dockerfile` multi-stage : construire dans une étape, copier le résultat dans une autre, et jeter la première.

    Quatre décisions dans ce fichier méritent d'être comprises plutôt que recopiées. **`npm ci` et non `npm install`** : `ci` installe exactement ce que décrit `package-lock.json` et échoue si le verrou est incohérent ; `install` peut mettre à jour le verrou pendant la construction, ce qui rend l'image non reproductible.

    **`USER node`** : sans cette ligne, le processus tourne en root dans le [[conteneur|conteneur-docker]]. Une faille d'exécution de code y devient une compromission avec tous les droits sur le système de fichiers de l'image. **La forme exec du `CMD`** : c'est elle qui fait de [[Node|node-runtime?]] le processus 1, condition sans laquelle tout le travail du chapitre 5 est inopérant.

    Le `.dockerignore` n'est pas un détail de confort. Sans lui, `COPY` embarque le `node_modules` de votre poste — construit pour votre système d'exploitation — et le `.env` local, dont les identifiants se retrouvent dans une couche de l'image, lisible par quiconque la télécharge.

  • Le compose.yaml : catalog, sa base et le monolithe

    Trois composants doivent maintenant démarrer ensemble : `catalog`, sa base `pg-catalog`, et le monolithe résiduel avec sa propre base. Un `compose.yaml` versionné décrit l'ensemble — c'est la première ligne de la Definition of Done : `docker compose up` et rien d'autre.

    Le point sensible est `depends_on`. Dans sa forme courte, il n'attend que le **démarrage** du [[conteneur|conteneur-docker]], pas sa disponibilité : PostgreSQL est « démarré » plusieurs secondes avant d'accepter une connexion. La forme longue avec `condition: service_healthy` attend que la [[sonde de santé|sonde-sante]] passe au vert — c'est la seule qui empêche `catalog` de démarrer sur une base injoignable.

    Deux bases distinctes pour deux services : c'est la matérialisation de « une base par service ». Rien n'empêche techniquement le monolithe de se connecter à `pg-catalog` — le réseau le permet. Ce qui l'en empêche est une décision d'équipe, et c'est exactement pour cela qu'elle doit être écrite dans le contrat et vérifiée en revue.

  • Le JOIN devient un appel réseau

    Dernière étape, et la plus instructive du module. Le monolithe doit cesser de joindre la table `products` — qui n'existe plus dans sa base — et demander les noms de produits à `catalog`, en HTTP. Voici le code avant, que vous connaissez, et le code après.

    Ce code fonctionne. Le test passe, le listing affiche toujours les noms de produits, la Definition of Done est satisfaite. Et pourtant il contient **trois défauts graves**, qu'on va nommer maintenant et corriger dans les modules suivants. Commençons par le mesurer.

    Trois millisecondes deviennent quarante-et-une. Et surtout : le temps croît **linéairement avec le nombre de lignes**, parce qu'on fait un appel réseau par ligne de commande, séquentiellement. C'est le **N+1 réseau** — le même défaut que le N+1 SQL bien connu, mais avec un aller-retour réseau au lieu d'une requête locale, soit un facteur mille sur le coût unitaire.

    Vérifiez le deuxième défaut vous-même, c'est l'expérience marquante du module : `docker compose stop catalog`, puis rechargez la page d'historique. Le monolithe ne renvoie pas une erreur rapide — il **attend**. Selon la façon dont la connexion est refusée ou simplement perdue, l'attente va de quelques millisecondes à plusieurs dizaines de secondes.

    Une question va se poser au point de synchronisation, et il faut résister à la tentation d'y répondre tout de suite : **faut-il dupliquer le nom du produit dans la base des commandes ?** C'est la bonne question. Elle a une bonne réponse, elle s'appelle réplication par événement, et elle arrive au module 5 — après le module 4, qui vous aura appris à survivre à la panne de `catalog`.

Mettre MiniShop derrière une passerelle et propager l'identité

  • Extraire users : inscription et connexion

    `users` est le service le plus simple à extraire et le plus délicat à concevoir. Simple, parce qu'il ne dépend de personne : votre carte du module 1 le montrait déjà en bas de la hiérarchie. Délicat, parce qu'il porte une responsabilité que tous les autres vont consommer — attester **qui** est l'utilisateur — et qu'un mauvais choix ici se paie dans les cinq services.

    Remarquez l'absence de table `sessions`. Le monolithe en avait une : à chaque requête, il lisait le cookie, cherchait la session en base, et en tirait l'utilisateur. Ce mécanisme ne survit pas au découpage — soit tous les services interrogent la base de `users` (interdit), soit ils l'appellent en HTTP à chaque requête (un point de défaillance unique sur le chemin de toute requête du système).

    La dernière ligne mérite d'être lue deux fois. Un jeton signé **ne peut pas être révoqué** : il est valide jusqu'à son expiration, quoi qu'il arrive au compte entre-temps. C'est un vrai renoncement, qu'on compense par une durée de vie courte (quinze minutes ici) et un jeton de rafraîchissement, lui révocable, stocké côté `users`. Ne choisissez jamais le [[JWT|jwt]] sans avoir énoncé ce compromis.

    Le commentaire sur le message d'erreur n'est pas décoratif. Répondre « adresse inconnue » d'un côté et « mot de passe incorrect » de l'autre transforme le point de connexion en outil d'énumération de comptes. Un seul message, un seul code : `401 INVALID_CREDENTIALS`.

  • Le jeton JWT : ce qu'il contient, ce qu'il prouve

    Un [[JWT|jwt]] est une chaîne en trois parties séparées par des points. Rien n'y est chiffré : les deux premières parties sont du [[JSON|json]] encodé en base64url, lisible par n'importe qui. La troisième est une signature. Cette distinction — signé, pas chiffré — est la source de la moitié des mauvais usages du format.

    Trois champs de ce jeton sont là pour la vérification et non pour le métier. `exp` borne la durée de validité — c'est le seul remplaçant de la révocation. `iss` dit qui l'a émis, `aud` pour quel système : sans eux, un jeton émis par un autre service de la même entreprise, signé avec le même secret, serait accepté ici sans discussion.

    HS256 utilise un **secret partagé** : celui qui vérifie peut aussi signer. C'est acceptable ici — cinq services, une seule équipe — et c'est le choix du parcours pour rester lisible. En production réelle, on préfère RS256 : `users` détient une clé privée, les autres n'ont que la clé publique et ne peuvent donc pas fabriquer de jeton.

  • Vérifier le jeton dans chaque service

    Chaque service vérifie le jeton lui-même. Pas la passerelle à sa place, pas un appel à `users` : une vérification locale, quelques microsecondes, sans dépendance réseau. C'est la seule façon d'obtenir un système où l'indisponibilité de `users` n'empêche pas de consulter le catalogue.

    L'option `algorithms: ['HS256']` n'est pas une précaution de style. Sans elle, certaines bibliothèques acceptent le champ `alg` du jeton — que l'attaquant écrit lui-même. Le poser à `none` produit alors un jeton sans signature… accepté. C'est la vulnérabilité [[JWT|jwt]] la plus documentée, et elle reste régulièrement trouvée en audit.

    Le commentaire sur `req.user.sub` désigne la faille la plus banale de ce type d'architecture : accepter un `userId` envoyé dans le corps de la requête. Le jeton est la seule source d'identité. Si l'identifiant vient du client, n'importe qui commande au nom de n'importe qui.

    Et la distinction `401` / `403` fait partie du contrat : `401` signifie « je ne sais pas qui vous êtes, présentez un jeton », `403` signifie « je sais qui vous êtes, et ce n'est pas à vous ». Un client qui reçoit `401` doit se reconnecter ; s'il reçoit `403`, se reconnecter ne servira à rien.

    Notez l'étape 7 : `orders` propage l'en-tête `Authorization` reçu quand il appelle `catalog`. C'est le choix le plus simple, et il a une conséquence — `catalog` voit l'identité de l'utilisateur final, pas celle de `orders`. Pour la plupart des systèmes c'est ce qu'on veut ; quand ce n'est pas le cas, il faut deux identités distinctes, celle de l'utilisateur et celle du service appelant.

  • La passerelle : ce qu'on y met, ce qu'on n'y met jamais

    Aujourd'hui, le monolithe publie un port et le client s'y adresse. Demain, trois services, puis cinq — le client devrait connaître cinq adresses, gérer cinq certificats, subir cinq politiques CORS différentes. La passerelle règle ce problème : un seul point d'entrée, une seule adresse publique, une topologie interne invisible du dehors.

    Une question revient toujours à ce stade : *puisque la passerelle voit passer tous les jetons, pourquoi ne les vérifie-t-elle pas une fois pour toutes, dispensant les services de le faire ?* La réponse tient en une phrase : parce qu'alors la sécurité du système entier repose sur le fait que personne n'atteigne jamais un service directement.

    Or dans un [[cluster|cluster]], cette hypothèse est fausse par défaut : n'importe quel [[conteneur|conteneur-docker]] du même réseau peut appeler `orders` sans passer par la passerelle. La vérification dans la passerelle est une commodité utile — elle rejette tôt le trafic manifestement invalide — mais elle ne remplace jamais la vérification dans le service. C'est le principe de défense en profondeur, appliqué au cas le plus concret qui soit.

  • Traefik par labels Docker

    Traefik se configure en deux endroits, et confondre les deux est la cause de la moitié des blocages. La configuration **statique** — les points d'entrée, les sources de découverte — est lue au démarrage et ne change pas. La configuration **dynamique** — les routes vers les services — est découverte en continu, ici par lecture des labels des conteneurs Docker.

    Deux options méritent une explication. `exposedbydefault=false` inverse le comportement par défaut : sans elle, **tout** [[conteneur|conteneur-docker]] du projet devient joignable de l'extérieur dès qu'il démarre, y compris les bases de données. Avec elle, un service n'est exposé que s'il porte explicitement `traefik.enable=true`.

    Le montage du socket Docker en lecture seule, ensuite : c'est ainsi que Traefik découvre les conteneurs. Ce socket donne un contrôle total sur l'hôte à qui l'obtient — le `:ro` limite les dégâts sans les annuler. En production, on interpose un mandataire de socket qui ne laisse passer que les lectures nécessaires ; c'est hors du périmètre de ce parcours, mais il faut savoir que le montage direct n'est pas anodin.

  • Routage par préfixe et réécriture de chemin

    Chaque service déclare son routage par des labels sur son propre [[conteneur|conteneur-docker]]. C'est le grand intérêt du fournisseur Docker : la configuration de la passerelle vit à côté du service qu'elle expose, pas dans un fichier central qu'il faut modifier à chaque ajout.

    Le middleware `stripprefix` est indispensable et souvent oublié. Sans lui, `catalog` recevrait `GET /api/catalog/products` alors qu'il n'expose que `/products` — et répondrait `404`. Le service ignore tout du préfixe sous lequel il est publié, et c'est voulu : on peut le republier ailleurs sans toucher à son code.

    Fermez maintenant tous les ports publiés des services. C'est la partie du travail la plus facile à sauter et la plus importante : tant qu'un service publie un port, l'authentification peut être contournée en s'adressant directement à lui, et toute la configuration de la passerelle devient décorative.

  • Extraire orders : le monolithe disparaît

    Dernier domaine à sortir : `orders`. L'extraction elle-même ne présente plus de difficulté — même structure, même [[Dockerfile|dockerfile]], même compose. Ce qui change est ce qu'on emporte : `orders` avait deux dépendances internes, `catalog` et `users`. La première devient un appel HTTP, la seconde disparaît complètement.

    Les deux commentaires du schéma désignent une perte réelle. `userId` et `productId` sont désormais des identifiants nus : la base ne peut plus garantir qu'ils existent. Rien n'empêche d'insérer une ligne de commande référençant un produit supprimé — l'intégrité référentielle, jusqu'ici assurée gratuitement par PostgreSQL, devient une responsabilité applicative.

    Il n'y a pas de remède parfait à cela, et il faut le dire clairement plutôt que de faire semblant. On vérifie à l'écriture (`orders` demande le produit à `catalog` avant d'accepter la ligne), on tolère l'incohérence en lecture (un produit disparu affiche son identifiant), et on accepte qu'une fenêtre existe entre les deux. C'est le prix de la ligne « cohérence » du bilan du module 1.

    Le monolithe n'existe plus. C'est le jalon symbolique du parcours — et l'occasion de mesurer ce qu'il a coûté : un fichier `compose.yaml` de sept services au lieu de deux, trois bases à sauvegarder, trois images à construire et à publier, et une latence de bout en bout multipliée par dix. Tout cela est normal et attendu ; ce qui ne le serait pas, c'est de ne pas savoir le chiffrer.

  • Le parcours complet, et le secret qui traîne

    Validation de bout en bout. Le parcours complet — inscription, connexion, catalogue, commande — doit passer intégralement par la passerelle, avec le jeton, sans qu'aucun port de service ne soit publié.

    Reste le piège du module, et il est probable que votre code en soit atteint : **le secret [[JWT|jwt]] écrit en dur**. Il apparaît sous trois formes, par ordre de fréquence — une constante dans `tokens.js`, une valeur par défaut du type `process.env.JWT_SECRET ?? 'dev-secret'`, ou une valeur littérale dans le `compose.yaml` versionné.

    La syntaxe `${JWT_SECRET:?message}` du `compose.yaml`, vue au chapitre 6, joue le même rôle un cran plus tôt : Compose refuse de démarrer et affiche le message si la variable est absente de l'environnement. Deux garde-fous valent mieux qu'un, parce qu'ils échouent à des moments différents et attrapent des oublis différents.

    Le module 4 part de l'état que vous venez d'obtenir et pose une question simple : que se passe-t-il quand `catalog` s'arrête ? Vous l'avez déjà entrevu au module 2. Cette fois, on mesure, on comprend la cascade, et on la traite.

Rendre MiniShop résistant à la panne d'un service

  • Les huit idées fausses du calcul distribué

    Tout code écrit pour un monolithe embarque des hypothèses qu'on ne remarque jamais, parce qu'elles sont vraies dans un monolithe. Un appel de fonction ne peut pas échouer à cause du réseau, ne prend pas un temps variable, ne peut pas être exécuté deux fois par accident. Aucune de ces trois propriétés ne survit à la première extraction de service.

    Ces huit points ne sont pas une liste à réciter. Ils servent de grille de relecture : devant tout appel sortant, on demande « que se passe-t-il si celui-ci ne répond pas, répond lentement, ou répond deux fois ? ». Un appel dont on n'a pas répondu à ces trois questions est un incident qui n'a pas encore eu lieu.

    Un calcul rend le problème tangible. Si chaque service est disponible à 99,9 % — soit huit heures d'indisponibilité par an, ce qui est déjà exigeant —, une requête qui traverse quatre services en synchrone a une disponibilité de 0,999⁴, soit 99,6 %. Trente-cinq heures par an. Le découpage a **divisé la disponibilité par quatre** sans qu'aucun service ne soit devenu moins fiable.

  • Mesurer la panne avant de la traiter

    On ne traite pas une panne qu'on n'a pas mesurée. Première expérience : arrêter `catalog` franchement, et regarder ce que fait `orders`. Faites-le vous-même avant de lire la suite — la surprise fait partie de l'apprentissage.

    Quatre millisecondes, erreur `500`. Le [[conteneur|conteneur-docker]] est arrêté, donc plus rien n'écoute : le système d'exploitation refuse la connexion immédiatement (`ECONNREFUSED`). C'est un **échec rapide**, et c'est en réalité le cas favorable — désagréable pour l'utilisateur, mais sans effet sur le reste du système.

    Deuxième expérience, plus proche de la réalité : `catalog` n'est pas arrêté, il est **lent**. Une requête SQL qui s'est mise à faire un balayage de table, un pic de charge, un voisin bruyant sur la même machine. Ajoutons un délai artificiel de cinq secondes dans `catalog` et refaisons la mesure.

    Quarante secondes, et un code `200`. Aucune erreur nulle part, aucune alerte, aucun journal en niveau erreur — et pourtant le système est inutilisable. C'est le point le plus important du module : **la lenteur fait plus de dégâts que la panne**, parce qu'elle ne déclenche aucun des mécanismes prévus pour la panne.

  • Le délai d'attente : la première ligne de défense

    Le `fetch` de [[Node|node-runtime?]] n'a **aucun délai d'attente par défaut**. Ni cinq secondes, ni trente : aucun. Une requête qui n'obtient jamais de réponse attend jusqu'à ce que la couche TCP abandonne, ce qui peut prendre plusieurs minutes selon la configuration du système. Chaque requête ainsi bloquée retient de la mémoire et un descripteur de fichier.

    Reste à choisir la valeur, et c'est là que les équipes se trompent. Un délai choisi au jugé est presque toujours trop généreux : « trente secondes, on ne sait jamais » revient à n'avoir aucun délai pour un appel qui répond normalement en quinze millisecondes. La valeur se déduit d'une mesure, pas d'une intuition.

    Une contrainte supplémentaire, souvent oubliée : les délais d'une chaîne doivent **décroître** en profondeur. Si la passerelle abandonne au bout de deux secondes, il est inutile que `orders` attende `catalog` pendant cinq — le client est déjà parti, et `orders` travaille pour personne tout en retenant des ressources.

  • Réessai, attente exponentielle et gigue

    Un appel qui échoue peut valoir la peine d'être rejoué : beaucoup de défaillances sont transitoires — un redémarrage, une coupure réseau d'une seconde, une instance retirée de la répartition. Mais un réessai naïf est le meilleur moyen de transformer une gêne en incident majeur.

    Le mécanisme est simple à comprendre : `catalog` ralentit, les appels échouent, chaque appelant réessaie trois fois. `catalog` reçoit donc **quatre fois plus de trafic** au moment précis où il n'en supporte déjà plus. C'est ce qu'on appelle une tempête de réessais, et elle a mis à terre des systèmes de production bien plus robustes que MiniShop.

    La condition sur les codes `4xx` mérite d'être soulignée. Rejouer un `404` ne le transformera pas en `200` : le produit n'existe pas, il n'existera pas davantage dans trois cents millisecondes. Rejouer un `400` non plus. Seules les erreurs `5xx`, les délais dépassés et les erreurs réseau justifient une nouvelle tentative.

  • Idempotence : ce qu'on a le droit de rejouer

    Un réessai suppose qu'exécuter deux fois l'opération soit sans conséquence. Cette propriété porte un nom — l'[[idempotence|idempotent]] — et elle n'est pas facultative : sans elle, le réessai crée des doublons silencieux, qui sont la pire catégorie de bogue distribué parce qu'ils ne produisent aucune erreur.

    Le scénario à avoir en tête est le suivant, et il est fréquent : `orders` envoie une réservation de stock à `catalog`. `catalog` la traite correctement et écrit en base. Puis la réponse se perd — le réseau, un redémarrage, un délai dépassé d'un cheveu. `orders` n'a rien reçu, conclut à l'échec, et rejoue. Le stock est décrémenté deux fois pour une seule commande.

    La parade est déjà en place dans MiniShop, posée au module 2 sans qu'on l'explique alors : la contrainte `@@unique([orderId, productId])` sur la table des réservations. Le second `INSERT` viole la contrainte, `catalog` le détecte et renvoie la réservation existante au lieu d'en créer une nouvelle. L'opération devient rejouable.

    Deux points de ce code sont réutilisés dans tout le parcours. Le `where` du `update` contient `stock: { gte: qty }` : la vérification du stock et sa décrémentation sont **une seule opération atomique**. Vérifier d'abord, décrémenter ensuite laisserait une fenêtre où deux commandes concurrentes passent toutes les deux.

    Et le rejeu renvoie `200` plutôt que `201` : l'appelant obtient le même résultat, tout en pouvant distinguer une création d'un rejeu s'il journalise. Un taux de rejeu qui augmente est un signal utile — il annonce que les délais d'attente sont trop courts, bien avant que quiconque ne s'en plaigne.

  • Le disjoncteur : arrêter d'appeler ce qui ne répond plus

    Délai et réessai protègent une requête isolée. Ils ne protègent pas contre la situation où `catalog` est durablement en difficulté : on continue de l'appeler, chaque appel attend cent millisecondes pour rien, et on lui envoie du trafic qu'il ne peut pas absorber. Le disjoncteur règle exactement ce cas : **arrêter d'appeler ce qui ne répond plus**.

    L'état ouvert apporte deux bénéfices distincts, et le second est celui qu'on sous-estime. Il protège **l'appelant**, qui échoue en une microseconde au lieu d'attendre le délai complet à chaque requête. Et il protège **l'appelé**, qui cesse de recevoir du trafic et obtient enfin la respiration nécessaire pour se rétablir.

    `volumeThreshold` est le paramètre que tout le monde oublie et qui produit des disjoncteurs qui s'ouvrent tout seuls au démarrage : sans lui, un unique échec sur un unique appel fait un taux d'échec de cent pour cent, largement au-dessus du seuil. Il faut un volume minimal avant qu'un pourcentage veuille dire quelque chose.

    Le passage de cent millisecondes à une milliseconde est la démonstration du module. `catalog` est toujours aussi lent, mais `orders` ne l'attend plus : il sait qu'il n'obtiendra rien et sert immédiatement une réponse dégradée. Le service reste utilisable, et sa consommation de ressources redevient normale.

    Une précision qui évite un contresens : le disjoncteur d'`opossum` est **local à l'instance**. Trois réplicas de `orders` ont trois disjoncteurs indépendants, qui s'ouvrent chacun après leurs propres dix échecs. C'est acceptable et même souhaitable — un état partagé exigerait un magasin externe, donc une dépendance de plus sur le chemin critique.

  • Dégradation gracieuse et cloisonnement

    Le disjoncteur transforme une attente longue en échec rapide. Reste à décider quoi faire de cet échec. Renvoyer `500` serait un gâchis : sur une commande, le nom du produit est un agrément, pas une donnée essentielle. Le montant, la quantité, la date et le statut sont tous disponibles dans la base de `orders`.

    Le champ `partial` est ce qui distingue une dégradation d'un mensonge. Une réponse amputée qui se présente comme complète est pire qu'une erreur : le client affiche « Produit » à la place du nom, l'utilisateur croit à un bogue de données, et le support cherche pendant deux jours. Dire explicitement que l'information manque permet d'afficher « libellé temporairement indisponible ».

    Il reste un dernier mécanisme, le **cloisonnement**. Son nom vient des compartiments étanches d'un navire : une brèche noie un compartiment, pas le bâtiment. Appliqué à un service, il consiste à plafonner les ressources qu'une dépendance donnée peut monopoliser, pour qu'elle ne puisse pas assécher le service entier.

    Vingt lignes, et un effet considérable : `catalog` peut être aussi lent qu'il veut, il ne consommera jamais plus de vingt appels en vol dans `orders`. Toutes les autres requêtes — créer une commande, lister l'historique sans libellés, répondre aux [[sondes|sonde-sante]] — continuent de disposer de ressources.

  • La cascade, démontrée puis empêchée

    Point de synchronisation collectif, et le moment le plus démonstratif du module. On va provoquer la cascade — la vraie, celle qui met le système entier par terre — puis vérifier que le dispositif construit aujourd'hui l'empêche.

    La ligne à retenir est la dernière : la [[sonde de disponibilité|readinessprobe]] de `orders` répond `503` en huit secondes, alors que `pg-orders` va parfaitement bien. Le service est déclaré en panne parce qu'il n'a plus une seule ressource libre pour répondre à quoi que ce soit. En production, l'[[orchestrateur|orchestrateur]] le retire du trafic, puis le tue.

    Sept millisecondes de latence moyenne, zéro erreur, [[sonde|sonde-sante]] au vert, et une réponse qui dit honnêtement ce qui lui manque. `catalog` est toujours aussi malade — on n'a rien réparé chez lui — mais sa maladie ne se propage plus. C'est exactement la définition de la résilience : contenir la panne, pas l'empêcher.

    Le livrable du module est ce comportement, **prouvé par un test automatisé**. Un test qui démarre `catalog` en mode lent, appelle `orders`, et vérifie trois choses : la réponse arrive en moins de deux cents millisecondes, le code est `200`, et le champ `partial` vaut `true`. Sans ce test, la protection régressera silencieusement à la première refonte.

    Le module 5 attaque la quatrième colonne du tableau du chapitre 7 : ne plus dépendre du tout. `catalog` publiera ses changements de libellé, `orders` en gardera une copie locale, et l'appel synchrone disparaîtra — avec ses délais, son disjoncteur et sa dégradation. On échange un problème de disponibilité contre un problème de fraîcheur des données.

Découpler MiniShop par des événements avec RabbitMQ

  • Couplage temporel : ce que l'appel synchrone impose

    Le module 4 a rendu la panne de `catalog` supportable. Il ne l'a pas rendue indolore : sans `catalog`, l'historique de commandes reste amputé. La raison est structurelle et porte un nom — le **couplage temporel**. Un appel synchrone exige que les deux services soient vivants **au même instant**. Aucun délai, aucun disjoncteur ne change cela.

    La ligne « exécutions multiples » est celle qui coûte le plus cher à ignorer. En messagerie, la livraison exactement une fois n'existe pas — nous verrons au chapitre 5 pourquoi c'est une impossibilité, et non une limite d'implémentation. On construit donc systématiquement des consommateurs qui supportent de recevoir deux fois le même message.

    Une règle de décision simple, qui suffit pour tout ce parcours : **si l'appelant a besoin de la réponse pour répondre lui-même, c'est synchrone. Sinon, c'est asynchrone.** Vérifier le stock avant d'accepter une commande conditionne la réponse à l'utilisateur : synchrone. Envoyer un courriel de confirmation : personne n'attend, asynchrone.

  • RabbitMQ : échange, file, liaison, accusé

    AMQP — le protocole de RabbitMQ — repose sur quatre objets. Un **producteur** publie dans un **échange**, jamais dans une file. L'échange consulte ses **liaisons** pour décider dans quelle ou quelles **files** copier le message. Un **consommateur** lit une file et **accuse réception**. Ce détour par l'échange est ce qui découple : le producteur ignore qui le lit, et combien ils sont.

    L'échange de type `topic` accepte des clés de routage hiérarchiques, et les liaisons peuvent contenir des jokers : `order.*` capte `order.created` et `order.cancelled`, `#` capte tout. C'est ce type qu'on emploie par défaut — il couvre les besoins des trois autres types (`direct`, `fanout`, `headers`) sans les inconvénients d'un choix trop étroit fait au premier jour.

    L'image `management` embarque l'interface web sur le port 15672. Elle n'est pas un confort : c'est l'outil de diagnostic du module. Vous y verrez les files se remplir quand vous arrêterez un consommateur, les messages non accusés, et vous rejouerez un message à la main au chapitre 5. Ouvrez-la maintenant et gardez-la sous les yeux.

    Le `process.exit(1)` sur fermeture de connexion mérite une justification, parce qu'il choque toujours. Écrire une reconnexion correcte — avec attente exponentielle, redéclaration des files, reprise des consommateurs — représente une centaine de lignes délicates. Sortir et laisser l'[[orchestrateur|orchestrateur]] relancer obtient le même résultat pour trois lignes. C'est un choix assumé, pas un raccourci.

    > **Ressource à venir.** Aucun cours magistral ne couvre RabbitMQ ni AMQP dans la bibliothèque : le magistral « RabbitMQ » reste à créer, et un renvoi sera posé ici quand il existera. Le renvoi ci-dessous pointe vers le chapitre Redis consacré au Pub/Sub et aux Streams : le modèle est voisin et la comparaison éclairante, mais il ne couvre ni les échanges, ni les accusés de réception, ni les files de rebut.

  • Événement de domaine ou commande

    Deux natures de message circulent dans un système distribué, et les confondre produit des architectures qu'on ne peut plus faire évoluer. Un **événement** dit ce qui s'est passé : `order.created`, au passé, sans destinataire désigné. Une **commande** demande une action : `reserve.stock`, à l'impératif, adressée à un service précis.

    MiniShop emploie exclusivement des événements. Ce choix a une conséquence directe sur le module suivant : la saga sera **chorégraphiée** — chaque service réagit aux événements des autres — et non **orchestrée** par un chef d'orchestre qui enverrait des commandes. Les deux approches sont valides ; celle-ci découle du choix fait ici.

    Cinq champs de cette enveloppe sont là pour l'infrastructure, et chacun sert à quelque chose de précis. `eventId` est unique et immuable : c'est la clé d'[[idempotence|idempotent]] du chapitre 5. `eventVersion` permettra d'introduire une version 2 sans casser les consommateurs de la version 1. `correlationId` traversera tout le système et rendra le module 7 possible.

    Un choix de conception mérite d'être discuté : faut-il mettre les lignes de commande dans l'événement, ou seulement l'identifiant, à charge pour le consommateur d'appeler `orders` pour obtenir le détail ? Mettre les données évite l'appel — donc le couplage temporel qu'on cherchait justement à supprimer. C'est le choix de MiniShop, et le prix en est un contrat d'événement plus large à faire évoluer avec précaution.

  • orders publie order.created

    Passons à la publication. `orders` crée la commande en base, puis publie `order.created`. Deux opérations, dans cet ordre — et c'est précisément là que se trouve le piège du module, que nous laissons volontairement en place jusqu'au chapitre 8.

    `persistent: true` combiné à une file durable est ce qui fait qu'un redémarrage du courtier ne perd pas les messages. Sans cette option, le message ne vit qu'en mémoire : la file est bien recréée au redémarrage, mais vide. C'est un défaut qui ne se voit jamais en développement et qui perd des commandes en production.

    L'ordre choisi — [[commit|commit]] d'abord, publication ensuite — est déjà le meilleur des deux ordres possibles, et il est important de comprendre pourquoi avant d'en voir la limite au chapitre 8.

    Retenez la dernière ligne : dans la colonne de droite, la base contient tout ce qu'il faut pour republier l'événement perdu. C'est le raisonnement complet du pattern Outbox, qu'il ne reste qu'à automatiser. Dans la colonne de gauche, l'information est irrécupérable.

  • notifications consomme, et le fait une seule fois

    `notifications` est le premier service qui n'expose aucune [[API|api]] métier. Il déclare sa file, la lie à l'échange, consomme, et écrit dans sa base. Son seul [[endpoint|endpoint]] HTTP est `/health` — plus un [[endpoint|endpoint]] de consultation de l'historique, pour que le formateur puisse vérifier le résultat.

    Le `prefetch(10)` est l'option la plus fréquemment omise, et elle a des conséquences spectaculaires. Sans elle, RabbitMQ envoie **toute la file** au premier consommateur qui se connecte. Sur une file de cent mille messages, le service reçoit cent mille messages en mémoire, s'effondre, redémarre, et recommence.

    Le `ack` après traitement est la seconde règle absolue. Accuser réception à l'arrivée du message signifie « je l'ai reçu » ; le courtier l'oublie alors définitivement, et un plantage pendant le traitement perd le message sans trace. Accuser après traitement signifie « je l'ai traité » — et si le service meurt avant, le courtier redonnera le message à un autre consommateur.

    Le tableau se lit dans un sens précis : accuser après traitement donne « au moins une fois », accuser avant donne « au plus une fois », et il n'y a pas de troisième option — entre traiter et accuser, il existe toujours un instant où le service peut mourir. Choisir l'ordre inverse déplace donc le problème sans le résoudre : on échange des doublons possibles contre des pertes possibles.

    Le point décisif est que la marque et l'effet sont dans la **même transaction**. Écrire la notification, puis marquer le message dans un second temps, laisse une fenêtre où un plantage produit une notification non marquée — donc rejouée, donc dupliquée. Une transaction, deux écritures, atomiques : c'est tout le mécanisme.

    Une notification, quatre livraisons. C'est le livrable central du module, et il doit être vérifié en rejouant **réellement** depuis l'interface de gestion — pas en appelant trois fois la fonction de traitement dans un [[test unitaire|test-unitaire]], ce qui ne prouve pas la même chose.

  • La file de rebut et le message empoisonné

    Reste le cas du message qu'on ne peut pas traiter : [[JSON|json]] malformé, champ obligatoire absent, référence vers une commande qui n'existe pas. Si le consommateur le refuse en le réenfilant, le courtier le lui redonne aussitôt. Il échoue à nouveau, le réenfile à nouveau — une boucle infinie qui sature le service et bloque tous les messages derrière.

    Reste une nuance qui fait toute la différence en exploitation : distinguer les erreurs **permanentes** des erreurs **transitoires**. Un [[JSON|json]] malformé ne se réparera jamais — direction le rebut immédiatement. Une base momentanément injoignable se réparera dans dix secondes — le rebut serait une perte, il faut réenfiler.

    Ce code laisse une faiblesse connue, et il vaut mieux la nommer que la découvrir : une erreur transitoire qui dure — une base indisponible pendant une heure — produit un réenfilement en boucle serrée. La parade complète est un compteur de tentatives dans les en-têtes du message, avec bascule en rebut au-delà d'un seuil. Hors périmètre ici, mais c'est le premier ajout à faire en production.

    Dernier point, souvent oublié : **une file de rebut qui n'est surveillée par personne ne sert à rien**. Elle transforme une panne bruyante en perte silencieuse. Il faut une alerte sur son nombre de messages, et quelqu'un qui la regarde. Au module 7, vous poserez cette supervision.

  • Réplication du libellé : la fin du N+1

    Nous pouvons enfin répondre à la question laissée ouverte au module 2 : faut-il dupliquer le nom du produit dans `orders` ? Oui. Et le mécanisme qui rend cette duplication tenable est celui que vous venez de construire — un événement publié par le propriétaire de la donnée.

    La règle qui distingue une réplication saine d'une base partagée déguisée tient en une phrase : **`catalog` reste le seul propriétaire de la donnée ; `orders` en détient une copie qu'il ne modifie jamais**. Si `orders` se met un jour à écrire dans sa copie du libellé, la frontière est percée et le système a deux vérités.

    La clause `WHERE product_cache.updated_at < EXCLUDED.updated_at` traite un cas qui arrive réellement : deux modifications rapides du même produit, deux événements, et rien ne garantit qu'ils soient traités dans l'ordre d'émission. Sans cette condition, l'ancienne valeur peut écraser la nouvelle, définitivement — jusqu'à la modification suivante.

    Une subtilité métier, et elle est importante : faut-il vraiment mettre à jour le libellé d'un produit **sur une commande déjà passée** ? Un ticket de caisse ne se réécrit pas quand le magasin renomme un article. Pour une commande confirmée, on devrait figer le libellé au moment de la commande, comme on y fige déjà le prix unitaire.

    MiniShop garde le cache pour l'affichage du catalogue dans les commandes en cours, et vous ajouterez dans l'exercice le champ figé pour les commandes confirmées. Cette distinction entre **donnée de référence** (peut changer) et **donnée transactionnelle** (figée à l'instant de l'acte) est l'une des plus utiles à emporter de ce parcours.

    `catalog` est éteint, et la réponse est **complète** : `partial: false`. Le couplage temporel a disparu, le N+1 réseau aussi, et la latence est revenue au niveau du monolithe. En échange, le libellé peut être obsolète de quelques centaines de millisecondes. C'est l'arbitrage central du module — et l'un des rares du parcours où le solde est franchement positif.

  • Publier avant le commit : la démonstration

    Point de synchronisation. Reprenons le code du chapitre 4 et le commentaire qu'on y avait laissé. Entre le [[commit|commit]] de la commande et la publication de `order.created`, il existe une fenêtre. Elle dure quelques millisecondes. On va la reproduire volontairement.

    Une commande `PENDING` que rien ne fera jamais avancer. Le client a reçu son `201`, la base contient la commande, et aucun consommateur ne saura jamais qu'elle existe. Au module 6, ce sera pire : la saga ne démarrera pas, le stock ne sera pas réservé, le paiement n'aura pas lieu, et la commande restera figée pour toujours.

    Le problème de fond est qu'on écrit dans **deux systèmes** — PostgreSQL et RabbitMQ — sans transaction commune. Et il n'en existe pas : le [[commit|commit]] à deux phases est disponible en théorie, catastrophique en pratique (verrous distribués, blocage sur panne du coordinateur). Il faut donc une autre idée.

    Le relais peut publier deux fois — il plante entre la publication et le marquage —, ce qui ramène à « au moins une fois ». Mais vos consommateurs sont déjà idempotents : le problème est donc résolu par un mécanisme que vous avez construit ce matin. C'est la cohérence de l'ensemble qui rend le pattern praticable.

    L'Outbox est **présenté et démontré en direct par le formateur**, non implémenté par les stagiaires : le relais, sa reprise sur erreur et son verrou anti-concurrence représentent une demi-journée à eux seuls. Ce qui est attendu de vous, c'est de savoir nommer le problème, dire pourquoi il n'a pas de solution transactionnelle, et décrire le mécanisme.

    Le module 6 est le plus difficile du parcours. Vous y ferez circuler une commande à travers trois services par une suite d'événements, avec un paiement qui échoue volontairement une fois sur trois, et une compensation qui libère le stock. Tout ce que vous avez construit aujourd'hui — enveloppe, [[idempotence|idempotent]], rebut — y sera nécessaire.

Faire aboutir une commande MiniShop par une saga chorégraphiée

  • Pourquoi le commit à deux phases est écarté

    Reprenons le problème dans sa forme la plus nue. Passer une commande, c'est écrire dans `pg-orders` (la commande), dans `pg-catalog` (le stock réservé), et dans `pg-payments` (le prélèvement). Ces trois écritures doivent réussir ensemble ou échouer ensemble. Dans le monolithe, une transaction s'en chargeait. Ici, trois bases, trois transactions indépendantes.

    Une solution existe pourtant, et il faut la connaître pour comprendre pourquoi on ne l'emploie pas : le **[[commit|commit]] à deux phases**. Un coordinateur demande à chaque participant « es-tu prêt à commiter ? », attend que tous répondent oui, puis leur ordonne de commiter. C'est correct sur le papier, et PostgreSQL sait le faire.

    Trois raisons écartent définitivement cette solution. **Le blocage** : entre les deux phases, les participants tiennent leurs verrous et ne peuvent pas décider seuls. Un coordinateur qui tombe fige les trois bases. **La disponibilité** : la transaction ne réussit que si les trois participants sont vivants simultanément — exactement le couplage temporel qu'on a passé le module 5 à supprimer.

    **La durée des verrous**, enfin, est la raison la plus concrète. Le stock du produit le plus vendu serait verrouillé pendant tout le protocole, y compris le temps du prélèvement bancaire — plusieurs centaines de millisecondes, parfois plusieurs secondes. Un seul produit populaire suffirait à sérialiser toutes les ventes du site.

  • La saga : chorégraphie ou orchestration

    Une saga remplace une transaction distribuée par une **suite de transactions locales**, chacune commitée immédiatement dans sa propre base, et chacune associée à une opération de compensation. Si l'étape 3 échoue, on ne revient pas en arrière : on exécute les compensations des étapes 2 puis 1, dans l'ordre inverse.

    La ligne « lisibilité » est le vrai coût de la chorégraphie, et il croît vite. À trois participants, on garde le déroulé en tête. À huit, personne ne sait plus ce qui déclenche quoi, et la moindre modification devient un pari. C'est la limite pratique : au-delà de quatre ou cinq étapes, l'orchestration redevient le bon choix.

    Deux chemins d'échec, et il est essentiel de ne pas les confondre. **Stock insuffisant** : `catalog` refuse dès la première étape, aucune compensation n'est nécessaire puisque rien n'a été fait. **Paiement refusé** : le stock a déjà été réservé, il faut donc le libérer — c'est là, et là seulement, qu'une compensation intervient.

  • La machine à états de la commande

    Le statut d'une commande n'est plus un champ qu'on met à jour au fil de l'eau : c'est un **état**, dans une machine dont les transitions sont limitativement énumérées. Cette rigueur n'est pas de la coquetterie académique. En chorégraphie, les événements peuvent arriver dans le désordre, en double, ou en retard — la machine à états est ce qui empêche ces anomalies de corrompre les données.

    Le choix de la **table blanche** est structurant. Écrire « refuser si l'état est CONFIRMED » (table noire) oblige à énumérer tous les cas interdits, et on en oublie toujours un. Énumérer les cas autorisés garantit que tout le reste est refusé, y compris les combinaisons auxquelles personne n'a pensé.

    Le `FOR UPDATE` traite un cas réel : `stock.reserved` et `payment.failed` peuvent être consommés au même instant par deux instances de `orders`. Sans verrou, les deux lisent `PENDING`, calculent leur état suivant, et écrivent — la dernière écriture gagne, et l'autre transition est perdue sans aucune erreur.

    La table `order_transitions` n'est pas décorative non plus. En chorégraphie, c'est la seule trace de ce qui s'est réellement passé : quel événement a provoqué quel changement, et quand. Au module 7, quand vous chercherez pourquoi une commande est restée bloquée, c'est la première table que vous consulterez.

  • catalog entre dans la saga : réserver ou refuser

    `catalog` entre dans la saga. Il consomme `order.created`, tente de réserver le stock de chaque ligne, et publie son verdict : `stock.reserved` ou `stock.rejected`. La réservation elle-même a déjà été écrite au module 4 — atomique et idempotente. Il ne reste qu'à la brancher sur le courtier.

    Une distinction gouverne tout ce code, et c'est la plus importante du chapitre : **un refus métier n'est pas une erreur technique**. Un stock insuffisant est un verdict — on le publie, la saga suit son chemin d'échec, le message est accusé. Une base injoignable est une panne — on lève, le message est réenfilé, et il sera retraité plus tard.

    Un mot sur le regroupement des lignes dans une seule transaction. Réserver produit par produit, en publiant au fur et à mesure, semble plus simple — jusqu'à ce que la quatrième ligne manque de stock alors que les trois premières sont déjà réservées. Il faudrait alors compenser trois réservations partielles. Une transaction locale par commande évite entièrement ce cas.

  • Créer payments et son échec volontaire

    `payments` est le cinquième et dernier service. Comme `notifications`, il n'expose aucune [[API|api]] métier : il consomme `stock.reserved`, simule un prélèvement, et publie `payment.succeeded` ou `payment.failed`. L'échec est volontaire et déterministe une fois sur trois — c'est ce qui rend le chemin d'échec observable à chaque session.

    La contrainte `@unique` sur `orderId` est l'[[idempotence|idempotent]] de ce service, et elle est vitale : sans elle, un rejeu de `stock.reserved` prélèverait deux fois. C'est le seul endroit du parcours où un défaut d'[[idempotence|idempotent]] coûterait de l'argent réel à un client — d'où la contrainte portée par la base plutôt que par du code.

    L'échec **déterministe** plutôt qu'aléatoire est un choix de conception, pas un détail d'implémentation. Avec `Math.random()`, un rejeu du même message peut donner un verdict différent du premier : `payment.failed` d'abord, `payment.succeeded` ensuite, sur la même commande. La machine à états refuserait la seconde transition, mais le désordre resterait dans les journaux.

    Le traitement du rejeu republie **le verdict enregistré**, jamais un nouveau. C'est une règle générale des consommateurs qui publient : sur rejeu, on relit ce qu'on avait décidé et on le republie tel quel. Recalculer une décision est la porte ouverte aux verdicts contradictoires sur une même saga.

  • La compensation n'est pas un retour arrière

    Le paiement a échoué. Le stock a été réservé. Il faut le libérer — et c'est ici que se joue la notion la plus mal comprise du module : **une compensation n'est pas un [[retour arrière|rollback]]**. Un `ROLLBACK` SQL efface l'écriture comme si elle n'avait jamais eu lieu. Une compensation est une nouvelle opération, qui s'ajoute à l'historique.

    La dernière ligne dicte une règle de conception que vous emporterez au-delà de ce parcours : **placer les étapes irréversibles à la fin de la saga**. Envoyer le courriel de confirmation avant de savoir si le paiement a réussi est une faute — non pas technique, mais de conception : le courriel ne se compense pas.

    Deux décisions de ce code méritent d'être reprises telles quelles. **Lire les réservations existantes** plutôt que se fier aux lignes de l'événement : si l'événement contient quatre lignes et que seules trois ont été réservées, recalculer depuis l'événement créerait du stock qui n'a jamais été retiré.

    **Marquer plutôt que supprimer** : `releasedAt` conserve la trace de la réservation. Une réservation supprimée rendrait impossible de répondre à la question « ce produit a-t-il été bloqué pendant la vente flash ? », qui est exactement la question qu'on pose après un incident commercial.

    Reste l'[[idempotence|idempotent]] de la compensation, et c'est le piège le plus coûteux du module. Si `payment.failed` est livré deux fois et que la compensation n'est pas idempotente, le stock est **incrémenté deux fois** : on vient de créer du stock à partir de rien. La table `processed_messages` dans la même transaction règle le cas — c'est le même mécanisme qu'au module 5, appliqué à une opération qui, cette fois, fabrique de la marchandise.

  • Cohérence à terme et interface utilisateur

    Le client a reçu `201 PENDING`. La saga se déroule en arrière-plan et prendra entre cinquante millisecondes et — si un service est arrêté — plusieurs minutes. Que montre-t-on à l'utilisateur pendant ce temps ? C'est une question d'interface, et elle n'a pas de réponse purement technique.

    Faire dicter le rythme par le serveur est un petit détail qui rend service longtemps. Le jour où la saga ralentit, on passe `retryAfterMs` à deux secondes côté serveur et tous les clients s'adaptent immédiatement — sans redéploiement, sans version d'application mobile à publier.

    Reste le cas le plus important, et celui qu'on oublie systématiquement : **la commande qui n'avance pas**. `catalog` était arrêté, l'événement est parti en rebut, et personne ne fera jamais avancer la saga. La commande restera `PENDING` indéfiniment. Une saga sans surveillance des états bloqués est une saga incomplète.

    La colonne `nb_transitions` indique **où** la saga s'est arrêtée : zéro transition signifie que `order.created` n'a jamais été consommé — regardez du côté de `catalog` ou du rebut. Une transition signifie que le stock est réservé mais que le paiement n'a jamais répondu — regardez `payments`. La [[sonde|sonde-sante]] ne se contente pas d'alerter, elle oriente le diagnostic.

  • Vérifier la cohérence par requête SQL

    Livrable du module, et sa vérification. On passe dix commandes sur un même produit, et on vérifie en SQL, **sur les deux bases**, que le compte est exact. C'est la seule preuve qui vaille : les journaux peuvent mentir par omission, l'état final des données non.

    Trois vérifications composent la recette du module, et elles doivent toutes passer. **Aucune commande en `PENDING` ou `STOCK_RESERVED`** : la saga est allée au bout dans les dix cas. **Le stock est exactement `initial − nombre de confirmées`** : ni perte, ni création. **Le nombre de réservations libérées égale le nombre de commandes annulées après réservation** : la compensation a eu lieu autant de fois que nécessaire, et pas une de plus.

    Un écart sur la troisième requête est le symptôme le plus fréquent, et il a presque toujours la même cause : la compensation n'est pas idempotente. Le stock a été incrémenté deux fois pour une même commande, parce que `payment.failed` a été livré deux fois. Vérifiez la présence de `processed_messages` **dans la même transaction** que l'incrément.

    Un mot sur ce module, en clôture. C'est le plus difficile du parcours, et il est normal de ne pas le terminer entièrement. Ce qui est attendu de vous n'est pas un code complet mais un **raisonnement juste** : savoir dire pourquoi il n'y a pas de transaction, ce qu'une compensation laisse derrière elle, et où votre implémentation peut encore laisser le système incohérent.

    Cette dernière question — *montrez-moi où votre saga peut laisser le système incohérent* — sera posée à la soutenance de l'épreuve finale. Un candidat qui identifie lucidement une faiblesse de son rendu marque davantage qu'un candidat qui défend un rendu parfait sans le comprendre. Commencez à préparer votre réponse dès aujourd'hui.

    Le module 7 répond à un besoin que vous ressentez déjà : quand une commande reste bloquée, il faut aujourd'hui ouvrir cinq journaux et croiser les horodatages à la main. Vous y poserez un identifiant de corrélation qui traverse HTTP **et** les messages, puis des traces distribuées — et le diagnostic passera de vingt minutes à trente secondes.

Diagnostiquer et tester MiniShop en distribué

  • Pourquoi console.log ne suffit plus

    Vous avez déjà vécu le problème que ce module résout. Au module 6, une commande restait bloquée : vous avez ouvert les journaux de `orders`, de `catalog`, de `payments`, cherché l'identifiant de commande dans chacun, comparé des horodatages à la milliseconde près, et fini par deviner. Cela a pris vingt minutes pour un système de cinq services que vous avez écrit vous-même.

    Trois défauts rendent ce journal inexploitable. Il est **non structuré** : un `grep` sur du texte libre ne permet aucune requête sur un champ. Il est **non corrélé** : rien ne relie une ligne de `catalog` à la ligne de `orders` qui l'a provoquée. Il est **incomplet** : les lignes qui ne citent pas l'identifiant sont invisibles au filtre, alors qu'elles font partie du même parcours.

    La troisième ligne est celle qui change tout dans un système distribué, et c'est la raison pour laquelle ce module existe. La question « où le temps est-il passé » n'a **aucune réponse** dans les journaux, même parfaitement corrélés : il faudrait soustraire des horodatages entre machines dont les horloges ne sont pas synchronisées à la milliseconde.

  • Journaux structurés avec Pino

    Premier changement, et le moins coûteux : une ligne de journal cesse d'être une phrase pour devenir un objet. Pino le fait nativement, sans configuration, et Fastify l'utilise déjà — c'est ce qui produit les lignes [[JSON|json]] que vous voyez depuis le module 2 sans y avoir prêté attention.

    L'option `redact` n'est pas facultative. Fastify journalise les en-têtes de requête par défaut : sans expurgation, **chaque jeton d'accès de chaque utilisateur** finit dans les journaux, conservés des mois, accessibles à toute l'équipe. C'est une fuite de données classique, et elle se corrige en trois lignes.

    Reste à décider quoi journaliser, et c'est une question de coût autant que d'utilité. Une ligne par requête HTTP est un minimum ; une ligne par changement d'état métier est ce qui rend le diagnostic possible. En revanche, une ligne par itération de boucle produit des volumes qui coûtent cher à stocker et noient l'information utile.

    La règle tient en une phrase : **le message est une constante, les variables sont des champs**. Un message qui contient des valeurs interpolées ne peut plus être regroupé ni compté, alors que c'est précisément ce qu'on veut faire d'un journal en incident.

  • L'identifiant de corrélation en HTTP

    Des journaux structurés mais non corrélés ne règlent que la moitié du problème. Il manque un identifiant unique par parcours utilisateur, généré une seule fois, transporté partout, et présent sur chaque ligne émise par n'importe quel service à l'occasion de ce parcours.

    `AsyncLocalStorage` est ce qui évite la solution qu'on voit trop souvent : passer `correlationId` en paramètre de toutes les fonctions, de la route jusqu'au client HTTP. Cette solution fonctionne, pollue chaque signature, et se casse au premier oubli — un oubli qui ne provoque aucune erreur, seulement une ligne de journal orpheline.

    La dernière remarque du bloc est le sujet du chapitre suivant, et c'est celui qui distingue une équipe qui a compris d'une équipe qui a suivi la recette. La corrélation HTTP est facile et se met en place en une heure. Elle laisse **la moitié du système invisible** : tout ce qui se passe après le courtier.

  • Franchir la frontière du courtier

    Un message AMQP n'a pas d'en-têtes HTTP. Il a en revanche des **propriétés** et des **en-têtes applicatifs**, qui traversent le courtier avec le message. C'est là qu'on place l'identifiant de corrélation — et AMQP prévoit même une propriété standard nommée `correlationId`, qu'il serait dommage de ne pas utiliser.

    L'identifiant est écrit à trois endroits : dans la charge utile, dans la propriété AMQP, dans un en-tête applicatif. Cette redondance est volontaire. La charge utile survit à une republication par un outil tiers, la propriété est visible dans l'interface de gestion sans décoder le message, et l'en-tête est ce que lisent les instrumentations automatiques.

    Le dernier point du schéma est un détail qui rend service tous les jours : renvoyer l'identifiant au client dans la réponse. Un utilisateur qui écrit au support peut alors citer un identifiant qui retrouve, en une requête, l'intégralité de son parcours à travers les cinq services.

  • Traçage distribué : OpenTelemetry et Jaeger

    La corrélation permet de **retrouver** toutes les lignes d'un parcours. Elle ne dit toujours pas **où le temps est passé**. Pour cela, il faut des durées mesurées et emboîtées : c'est le rôle d'une trace, composée de spans — un span par opération, chacun avec un début, une fin et un parent.

    Le commentaire de la première ligne est la cause d'échec numéro un de ce chapitre. L'instrumentation automatique remplace des fonctions des bibliothèques au chargement : si `fastify` ou `amqplib` sont déjà importés quand le SDK démarre, ils ne seront jamais instrumentés — et vous obtiendrez des traces vides sans le moindre message d'erreur.

    Passez une commande, puis ouvrez `http://localhost:16686`. Cherchez le service `orders`, ouvrez la trace la plus récente : vous devez y voir un arbre de spans qui couvre les cinq services, avec les spans `publish` et `process` d'AMQP en plus des spans HTTP et SQL. Si les spans du courtier manquent, l'instrumentation d'`amqplib` n'est pas active — revenez au `--import`.

    Un mot sur l'échantillonnage, parce que la question tombe à chaque session : en production, on n'enregistre pas cent pour cent des traces — le volume et le coût seraient déraisonnables. On échantillonne, avec une règle qui garde toujours les requêtes en erreur et les requêtes lentes. En développement, on garde tout : c'est le réglage du parcours.

  • L'exercice du bug caché

    Exercice chronométré, et moment le plus marquant de la formation. Le formateur va déployer une version modifiée de l'un des cinq services, contenant une latence artificielle de huit cents millisecondes. Il ne dira pas lequel. Vous devez l'identifier **uniquement dans Jaeger**, sans lire une ligne de code, sans `docker compose logs`.

    La notion clé est la **durée propre** d'un span : sa durée totale moins celle de ses enfants. Un span `orders` de 850 ms dont l'enfant `catalog` fait 840 ms a une durée propre de 10 ms — `orders` n'a rien fait de lent, il a attendu. C'est le service dont la durée propre est anormale qui est en cause.

    Le diagnostic se lit en trois secondes sur cet arbre : `catalog` a une durée propre de 824 ms alors que sa seule requête SQL prend 2,5 ms. Il consomme 821 ms sans rien faire d'observable. L'attribut `service.version` confirme qu'une version différente est déployée — c'est pour cela qu'on le met dans les attributs du span.

    Refaites maintenant le même exercice **sans Jaeger**, avec les seuls journaux corrélés, sur un second service que le formateur aura cassé. Chronométrez à nouveau. L'écart entre les deux durées — généralement d'un facteur dix — est ce que ce module cherche à vous faire ressentir plutôt qu'à vous faire admettre.

  • Tester sans mocker la base : Testcontainers

    Seconde partie du module, sur les tests. On ne reprend pas les bases — vous écrivez des tests depuis le module 2, comme l'exige la Definition of Done. On traite uniquement ce qui est **spécifique au distribué**, et le premier point est le plus contre-intuitif : ne pas mocker la base de données.

    > **Ressource à venir.** Aucun magistral ne couvre Vitest ni Testcontainers : `c-tests-e2e` traite Playwright, c'est-à-dire le bout en bout par le navigateur, et `c-python-tests` traite pytest. Un magistral « Tests JavaScript : Vitest et Testcontainers » reste à créer ; le renvoi ci-dessous situe la place de ces tests dans la pyramide, ce qui est la notion la plus utile ici.

    Le deuxième test est celui qui justifie tout le dispositif : dix appels **parallèles**, sur la dernière unité de stock, avec le même identifiant de commande. Aucun [[mock|mock]] ne peut le vérifier — il met en jeu les verrous de PostgreSQL, la contrainte d'unicité et la transaction, c'est-à-dire exactement les trois mécanismes sur lesquels repose l'[[idempotence|idempotent]] des modules 5 et 6.

    Notez aussi `app.inject()` : Fastify traite une requête sans ouvrir de port réseau. C'est ce que permettait la séparation `buildApp` / `listen` faite au module 2 — un choix qui semblait alors gratuit et qui rend ici les tests plus rapides, sans conflit de port et sans nettoyage de serveur à écrire.

  • Tests de contrat consommateur-dirigés

    Dernier point du module, et le plus spécifique au distribué. `orders` dépend de la réponse de `catalog`. Comment `catalog` sait-il qu'il ne casse pas `orders` en modifiant son [[API|api]] ? Le réflexe — tester les deux ensemble de bout en bout — est un piège dont il faut comprendre la nature exacte.

    Le renversement à comprendre est celui-ci : **c'est le consommateur qui écrit le contrat**, pas le producteur. `catalog` ne sait pas ce dont `orders` a besoin — il expose vingt champs, `orders` en utilise trois. Le contrat porte sur ces trois champs, et sur eux seuls : `catalog` reste libre de faire évoluer les dix-sept autres.

    `additionalProperties: true` est le cœur du dispositif. `catalog` peut ajouter `category`, `weightGrams`, ce qu'il veut : le contrat reste satisfait. Il ne peut pas retirer `name`, ni le passer d'une chaîne à un objet. C'est exactement la définition d'un changement compatible ascendant, rendue exécutable.

    C'est le résultat recherché, et il faut mesurer ce qu'il a coûté : deux fichiers, une dépendance de validation, quelques secondes d'exécution. Aucun démarrage de `orders`, aucun réseau, aucune base. Et l'équipe `catalog` apprend qu'elle casse `orders` **avant** de fusionner sa branche, pas en production.

    Ce dispositif est une version simplifiée de ce que fait un outil comme Pact — sans courtier de contrats, sans vérification croisée des versions déployées, sans publication automatique. L'objectif ici est de comprendre le **principe** ; l'outillage réel s'apprend en poste, et il s'apprend beaucoup plus vite quand on a déjà vu le mécanisme nu.

    Le module 8 ferme le parcours : MiniShop quitte Docker Compose pour un [[cluster|cluster]] [[Kubernetes|kubernetes]] local. Les [[sondes|sonde-sante]] du module 2 deviendront des `readinessProbe` et des `livenessProbe`, la configuration deviendra des `ConfigMap` et des `Secret`, et le routage de Traefik deviendra un `Ingress`. Presque tout ce que vous y ferez a déjà un équivalent dans ce que vous avez construit.

Déployer MiniShop sur un cluster Kubernetes local

  • De Compose à Kubernetes, terme à terme

    [[Kubernetes|kubernetes]] a la réputation d'être vaste, et elle est méritée. Mais vous n'abordez pas ce module en terrain vierge : vous avez déjà, sous Compose, des services, des ports, de la configuration, des [[sondes de santé|sonde-sante]], du routage et de la mise à l'échelle. Chacun de ces concepts a un équivalent exact côté [[Kubernetes|kubernetes]]. Commençons par la table de traduction.

    La quatrième ligne mérite un arrêt, parce qu'elle est la source de la plus grande confusion des débutants. **[[Kubernetes|kubernetes]] n'a pas de `depends_on`.** Il n'existe aucun moyen de dire « démarre `catalog` après `pg-catalog` ». Tous les [[pods|pod]] démarrent en même temps, et c'est délibéré.

    La conséquence est que chaque service doit **supporter que ses dépendances ne soient pas encore prêtes**, et se rétablir seul. C'est exactement ce que vous avez construit au module 2 : `/health/ready` qui répond `503` sans que le processus meure, et un service qui reprend le trafic dès que sa base revient. Ce travail-là est déjà fait.

  • Le modèle déclaratif et la boucle de réconciliation

    Sous Compose, vous **exécutez des commandes** : `up` démarre, `stop` arrête, `--scale 3` lance trois conteneurs. Sous [[Kubernetes|kubernetes]], vous **décrivez un état souhaité**, et le [[cluster|cluster]] travaille en permanence à le faire correspondre à la réalité. La différence semble subtile ; elle explique presque tout le comportement de l'outil.

    Trois conséquences pratiques découlent de cette boucle, et elles surprennent toujours au début. **Supprimer un [[pod|pod]] ne le supprime pas** : il revient en quelques secondes, parce que l'état souhaité en demande trois. Pour le supprimer vraiment, il faut modifier l'état souhaité — supprimer le `Deployment` ou passer `replicas` à zéro.

    **Une modification manuelle est écrasée** : éditer un [[pod|pod]] à la main sera défait à la prochaine [[réconciliation|boucle-reconciliation]]. Et **`kubectl apply` est [[idempotent|idempotent]]** : appliquer deux fois le même [[manifeste|manifeste]] ne fait rien la seconde fois, parce que l'état souhaité n'a pas changé. C'est ce qui rend le déploiement rejouable sans risque.

    Le `Service` est l'objet le plus important à comprendre. Les [[pods|pod]] ont des adresses IP qui changent à chaque recréation ; aucun service ne peut donc en joindre un autre par son adresse. Le `Service` fournit un nom DNS stable et répartit la charge entre les [[pods|pod]] **prêts** — ceux dont la `readinessProbe` passe.

  • Créer le cluster kind et y charger les images

    kind — *[[Kubernetes|kubernetes]] in Docker* — crée un [[cluster|cluster]] dont chaque [[nœud|node-k8s]] est un [[conteneur Docker|conteneur-docker]]. C'est le moyen le plus rapide d'obtenir un [[cluster|cluster]] multi-[[nœuds|node-k8s]] réaliste sur un poste de travail, et il se supprime aussi vite qu'il se crée, ce qui compte quand on va volontairement le casser.

    Vient maintenant le piège numéro un du module, et il coûte en général vingt minutes à chaque groupe. Vos images `minishop/catalog:dev` existent dans le démon Docker de votre poste. Les [[nœuds|node-k8s]] du [[cluster|cluster]] sont des conteneurs, avec **leur propre stockage d'images**. Ils ne voient rien de ce que vous avez construit.

    Un second réglage évite un piège jumeau : `imagePullPolicy: IfNotPresent` dans les [[manifestes|manifeste]]. Avec la valeur `Always` — qui est le défaut implicite pour une image étiquetée `latest` —, le [[kubelet|kubelet]] tenterait d'aller chercher l'image sur Docker Hub malgré le chargement local, et échouerait de la même façon.

  • Deployment et Service : les manifestes de catalog

    Vous écrivez les [[manifestes|manifeste]] de `catalog`, et de `catalog` seulement — ceux des quatre autres services et des dépendances vous sont fournis complets. Le squelette ci-dessous comporte des trous marqués `# ← À COMPLÉTER` : chacun correspond à une notion du chapitre, et à une ligne de votre `compose.yaml`.

    Les deux [[sondes|sonde-sante]] sont le cœur de l'exercice, et le point le plus mal traité en entreprise. La `livenessProbe` pointe sur `/health/live` : elle ne teste aucune dépendance, et son échec **tue le [[conteneur|conteneur-docker]]**. La `readinessProbe` pointe sur `/health/ready` : elle teste la base, et son échec **retire le [[pod|pod]] du Service** sans le tuer.

    Les seuils sont dissymétriques, et c'est voulu : `failureThreshold: 3` sur la vivacité, `2` sur la disponibilité. Retirer un [[pod|pod]] du trafic est peu coûteux et réversible en cinq secondes ; le tuer coûte un démarrage complet. On est donc prudent sur la première et réactif sur la seconde.

    Le `selector` est le point de rupture le plus fréquent, et son échec est silencieux : un `Service` dont le sélecteur ne correspond à aucun [[pod|pod]] se crée sans erreur, apparaît dans `kubectl get svc`, et ne route vers rien. `kubectl get endpoints catalog` renvoie alors `<none>` — c'est le premier réflexe de diagnostic à acquérir.

  • ConfigMap et Secret : externaliser la configuration

    La configuration ne doit pas vivre dans le `Deployment` : elle change d'un environnement à l'autre, alors que le [[manifeste|manifeste]] décrit l'application. [[Kubernetes|kubernetes]] fournit deux objets pour cela — `ConfigMap` pour ce qui n'est pas sensible, `Secret` pour le reste. La distinction est exactement celle du module 2.

    Un point doit être dit sans ambiguïté, parce qu'il induit énormément de faux sentiment de sécurité : **un `Secret` n'est pas chiffré**. Son contenu est encodé en base64, ce qui est un encodage réversible par n'importe qui, pas un chiffrement. Quiconque peut lire les `Secret` du [[namespace|namespace]] lit vos mots de passe en une commande.

    Ce que le `Secret` apporte vraiment est ailleurs, et c'est utile : le contrôle d'accès RBAC peut être plus strict sur les `Secret` que sur les `ConfigMap`, le contenu n'apparaît pas dans un `kubectl describe`, et [[etcd|etcd]] peut être configuré pour chiffrer les `Secret` au repos. En production, on va plus loin — coffre externe, opérateur de secrets, identité de charge de travail.

    Un comportement surprend systématiquement : modifier un `ConfigMap` **ne redémarre pas** les [[pods|pod]] qui l'utilisent en variables d'environnement. Elles sont lues au démarrage du processus, une fois pour toutes. Il faut donc relancer explicitement le déploiement — `kubectl rollout restart deployment/catalog` — pour que la nouvelle valeur soit prise en compte.

  • L'Ingress : reproduire le routage de la passerelle

    Dernier objet à écrire : l'`Ingress`, qui reproduit le routage par préfixe de chemin mis en place avec Traefik au module 3. Le vocabulaire diffère, la logique est identique — une règle par chemin, une réécriture, un service cible.

    L'annotation `rewrite-target` est propre au contrôleur NGINX : chaque contrôleur d'[[ingress|ingress]] a ses annotations. C'est une limite réelle de l'objet `Ingress` — sa spécification standard ne couvre pas la réécriture, ce qui rend les [[manifestes|manifeste]] non portables d'un contrôleur à l'autre. C'est précisément le problème que l'[[API|api]] `Gateway` cherche à résoudre.

    Le parcours complet doit maintenant fonctionner à l'identique : inscription, connexion, catalogue, commande, saga, notification. Reprenez la séquence de vérification du module 3, en remplaçant simplement l'adresse de la passerelle. Aucun code applicatif n'a été modifié — c'est le résultat que le module cherche à obtenir.

  • Démonstrations : suppression, mise à l'échelle, mise à jour bloquée

    Quatre démonstrations collectives, à faire dans l'ordre. Elles sont courtes et chacune illustre une propriété que Compose n'a pas. La quatrième est la plus utile du module — gardez-lui du temps.

    Quatrième démonstration, et la plus importante : on déploie une image cassée. Elle démarre, elle répond à `/health/live`, mais sa `readinessProbe` échoue — parce qu'elle ne parvient pas à joindre sa base, par exemple. Que fait le [[cluster|cluster]] ?

    C'est la démonstration la plus utile du module. Une mise à jour cassée a été **arrêtée automatiquement**, sans coupure de service, sans réveil nocturne. Le [[cluster|cluster]] refuse de retirer un ancien [[pod|pod]] tant que le nouveau n'est pas prêt — et comme il ne le sera jamais, la mise à jour reste bloquée, à moitié faite, ce qui est exactement le comportement souhaitable.

    Insistons sur ce qui rend ce comportement possible : **la qualité de la `readinessProbe`**. Une [[sonde|sonde-sante]] qui répondrait `200` sans vérifier la base laisserait passer la mise à jour, retirerait les [[pods|pod]] v2, et provoquerait une panne totale. Toute la protection tient dans le travail fait au module 2 — c'est pourquoi il y a deux endpoints et non un.

  • Diagnostic d'un cluster cassé et hors périmètre

    Dernier exercice du fil rouge. Chaque binôme reçoit un [[cluster|cluster]] volontairement cassé — image inexistante, mauvais nom de `Secret`, port de `Service` erroné — et doit identifier la panne. Quatre commandes suffisent à couvrir la quasi-totalité des cas.

    Le cas montré ci-dessus est le plus instructif de tous : un label mal orthographié dans le `Deployment`. Rien n'échoue. Le [[pod|pod]] tourne, le `Service` existe, aucun message d'erreur n'est émis nulle part — et le service est totalement injoignable. `kubectl get endpoints` est la seule commande qui le révèle immédiatement.

    Clôture du fil rouge, et un point d'honnêteté qui compte autant que le reste. Ce module vous a montré une petite partie de [[Kubernetes|kubernetes]]. Il faut nommer explicitement ce qu'il n'a pas montré, sans quoi vous repartirez avec une impression de maîtrise que la première mise en production démentira.

    Le fil rouge s'arrête ici. Vous avez démonté un monolithe, extrait cinq services, posé une passerelle et une identité, survécu aux pannes, découplé par des événements, fait aboutir une transaction sans transaction, rendu le système diagnosticable, et déployé le tout sur un [[cluster|cluster]]. Chaque étape a introduit un problème que la suivante a résolu — c'est ce qui reste, bien plus que les commandes.

    Le dernier module est l'épreuve : un projet différent, TicketFlow, dont le domaine change mais dont la structure est analogue. Elle vérifie le **transfert** de compétence, pas la mémorisation d'un enchaînement de commandes. Prévoyez au moins une semaine entre aujourd'hui et l'épreuve : le délai d'oubli fait partie du dispositif.

TP de synthèse : livrer TicketFlow en autonomie

  • Clôture du fil rouge : les six anti-patterns

    Avant l'épreuve, une heure de clôture. On revient sur six anti-patterns du style microservices — non pas comme une liste à mémoriser, mais en les confrontant à ce que vous venez de construire. Chacun d'eux, vous l'avez frôlé ou traversé pendant le fil rouge.

    Le dernier mérite d'être développé, parce qu'il est le seul qui porte sur une décision qu'on prend avant d'écrire une ligne. **Commencer par un monolithe modulaire bien découpé est presque toujours le bon choix.** Les frontières y sont déplaçables en une après-midi ; entre deux services déployés, chaque déplacement coûte une migration de données et une coordination d'équipes.

    La trajectoire raisonnable est donc : monolithe modulaire d'abord, découpage ensuite, service par service, en commençant par celui dont on a un besoin **mesuré** — une équipe bloquée par la fenêtre de livraison commune, un composant qui doit monter en charge seul. C'est exactement ce que vous avez fait aux modules 2 et 3, dans cet ordre.

    La clôture se termine par une **restitution croisée** de trente minutes. Chaque binôme démontre un point où son implémentation diffère de celle des autres et explique pourquoi. Les écarts les plus fréquents portent sur la frontière `orders`/`payments`, la stratégie de dégradation, et la façon de traiter un rejeu dans la saga.

  • Principe et modalités de l'épreuve

    L'évaluation ne porte **pas** sur MiniShop. Elle porte sur TicketFlow, une billetterie de concerts dont le domaine est différent mais dont la structure architecturale est analogue. Ce choix coûte cher à préparer — il faut écrire et tester deux projets au lieu d'un — et il se justifie par trois raisons précises.

    **Modalités.** Individuel, sept heures. Documentation et notes personnelles autorisées, dépôts de MiniShop autorisés en consultation. L'assistance par IA est interdite ou autorisée selon la politique du centre — cette question est tranchée et annoncée **une semaine avant**, jamais le jour même. Le formateur ne répond qu'aux questions de compréhension de l'énoncé, jamais aux questions techniques.

    **Le dispositif anti-effondrement** est ce qu'il faut avoir compris avant de commencer. Les sept tâches sont ordonnées par dépendance : T4 suppose T1 fait. Mais elles sont **notées indépendamment**, et sept dépôts de secours `ticketflow-eval-tN-done` vous sont remis dès le départ.

    Le malus est de **−4 points par dépôt de secours utilisé**, cumulables. Signalez chaque usage dans votre `README` : un usage non déclaré et découvert par le correcteur compte double. À l'inverse, un candidat qui déclare deux usages et traite correctement les cinq autres tâches obtient un meilleur résultat qu'un candidat qui s'obstine six heures sur T5.

    **Bonus** : jusqu'à +6 points pour des tests automatisés pertinents ajoutés spontanément. Aucune tâche ne les exige explicitement, et c'est volontaire : c'est un bon indicateur du réflexe acquis. Un test de non-régression sur la dégradation de T3, ou un test d'[[idempotence|idempotent]] sur T4, valent chacun leur pesant de points.

  • TicketFlow : le sujet et ce qui est fourni

    TicketFlow est une billetterie de concerts. Un utilisateur consulte les concerts, réserve des places, paie, reçoit une confirmation. Le vocabulaire change entièrement par rapport à MiniShop — et c'est ce changement de vocabulaire qui vous obligera à comprendre plutôt qu'à recopier.

    **Ce qui vous est fourni dans `ticketflow-eval-start`**, testé et fonctionnel sur poste vierge : le service `events` complet (catalogue de concerts avec places disponibles), le service `users` complet (inscription, login, [[JWT|jwt]]), un `compose.yaml` avec Traefik, une instance PostgreSQL par service à venir, RabbitMQ et Jaeger, tous déjà câblés.

    S'y ajoutent un squelette de service `notifications` — le `Dockerfile` et le projet Fastify existent, la logique est à écrire —, un dossier `k8s/` contenant les [[manifestes|manifeste]] des dépendances et des deux services fournis, et un `POSTMAN.json` décrivant les parcours attendus. Lisez ce dernier **en premier** : il est la spécification fonctionnelle la plus précise dont vous disposiez.

    **Conseil de conduite d'épreuve**, et il vaut des points : consacrez les vingt premières minutes à lire, sans écrire une ligne. Le `POSTMAN.json`, le `README`, le contrat d'`events`, le `compose.yaml`. Les candidats qui commencent à coder à la première minute perdent en général une heure à T3, au moment où ils découvrent ce qu'`events` expose réellement.

  • T1 et T2 — créer et exposer reservations

    **T1 — Créer un service (45 min, 12 pts)** *[réf. module 2]*. Créez `reservations` : projet Fastify, base PostgreSQL dédiée, schéma Prisma, `Dockerfile` multi-stage avec utilisateur non-root, [[endpoint|endpoint]] `/health`, et entrée dans le `compose.yaml` avec `healthcheck` et dépendances.

    Trois endpoints au minimum : créer une réservation, consulter une réservation, lister les réservations de l'utilisateur courant. *Évalué* : configuration entièrement par variables d'environnement (aucune valeur en dur), base réellement isolée, image qui démarre du premier coup sur un poste vierge.

    **T2 — Exposer et sécuriser (30 min, 10 pts)** *[réf. module 3]*. Routez `reservations` derrière Traefik sur `/api/reservations`. Vérifiez le [[JWT|jwt]] et extrayez l'identifiant utilisateur. Une réservation non authentifiée est refusée en `401` ; un utilisateur ne peut consulter que ses propres réservations.

    Une précision sur le `403` : renvoyer `404` sur la réservation d'un autre utilisateur est un choix **défendable** — il évite de révéler l'existence d'une ressource. Si c'est votre choix, écrivez-le dans le `README` et justifiez-le : le correcteur acceptera. Le faire sans le dire sera compté comme une confusion entre authentification et autorisation.

  • T3 et T4 — résilience et asynchrone

    **T3 — Résister aux pannes (45 min, 14 pts)** *[réf. module 4]*. `reservations` appelle `events` pour vérifier l'existence du concert et récupérer son libellé. Protégez cet appel : délai d'attente explicite, réessai avec attente exponentielle sur les seules opérations idempotentes, disjoncteur.

    Définissez et implémentez un comportement dégradé lorsque `events` est indisponible, et **justifiez-le dans le `README`**. *Vérification par le correcteur* : `docker compose stop events`, puis création d'une réservation. Le service doit répondre en moins de deux secondes, sans `500`, avec un comportement documenté.

    *Évalué sur T3* : la **valeur** du délai d'attente et sa justification, la pertinence du choix de dégradation, et **l'absence de réessai sur une opération non idempotente**. Ce dernier point est le plus discriminant : un réessai posé sur la création d'une réservation, sans clé d'[[idempotence|idempotent]], coûte cher.

    **T4 — Communiquer en asynchrone (60 min, 16 pts)** *[réf. module 5]*. `reservations` publie `reservation.created`. Complétez `notifications` pour le consommer et enregistrer une notification. Rendez la consommation idempotente. Configurez une file de rebut.

    *Vérification* : rejeu manuel du même message trois fois depuis l'interface RabbitMQ — une seule notification doit exister. Publication d'un message malformé — il doit atterrir en file de rebut sans bloquer la file. *Évalué* : mécanisme d'[[idempotence|idempotent]] réellement **persistant** (une variable en mémoire ne compte pas), accusé de réception au bon moment, publication après [[commit|commit]].

  • T5 — saga et compensation

    **T5 — Saga et compensation (90 min, 24 pts)** *[réf. module 6]*. C'est la tâche la plus lourdement notée et la plus discriminante. Créez `payments`, qui échoue volontairement une fois sur trois, et implémentez la saga par chorégraphie.

    *Vérification* : un script de charge fourni crée quinze réservations. Le correcteur vérifie en SQL que `places_disponibles` de chaque concert est **exactement** cohérent avec le nombre de réservations `CONFIRMED`, et qu'aucune réservation ne reste `PENDING`. Aucune tolérance : une place d'écart est un échec de l'invariant.

    *Évalué sur T5* : machine à états **explicite avec transitions interdites**, compensation effective, absence de perte ou de création de places. Une machine à états implicite — des `if` disséminés dans les gestionnaires d'événements — perd des points même si le résultat est correct, parce qu'elle ne tiendra pas au premier événement en double.

  • T6 et T7 — diagnostiquer et déployer

    **T6 — Diagnostiquer (45 min, 12 pts)** *[réf. module 7]*. Deux volets. *(a) Instrumentation* : propagez un `correlationId` de la passerelle jusqu'à `notifications`, à travers les appels HTTP **et** les messages RabbitMQ. Instrumentez OpenTelemetry vers Jaeger, déjà présent dans le `compose.yaml`.

    *(b) Diagnostic* : à mi-parcours, le formateur fournit une image `events:v2-broken` à substituer dans le `compose.yaml`. Produisez un `DIAGNOSTIC.md` de **quinze lignes maximum** identifiant la nature du dysfonctionnement, le service responsable et la preuve issue de Jaeger ou des journaux corrélés. L'image est distribuée **sans les sources** : le code fautif ne peut pas être lu.

    *Évalué sur T6* : deux choses, et la première est celle que la moitié des candidats manque — **la corrélation traverse bien la frontière du courtier**. Si vos journaux corrélés montrent `reservations` et `events` mais pas `payments` ni `notifications`, la propagation AMQP n'est pas faite. La seconde est la qualité du raisonnement de diagnostic, **indépendamment de l'exactitude de la conclusion**.

    La section « ce que j'ai éliminé » est ce qui distingue un rapport à douze points d'un rapport à six. Elle montre une démarche, pas une intuition — et c'est elle qui reste valable le jour où la conclusion est fausse.

    **T7 — Déployer sur [[Kubernetes|kubernetes]] (45 min, 12 pts)** *[réf. module 8]*. Écrivez les [[manifestes|manifeste]] de `reservations` : `Deployment` avec `readinessProbe`, `livenessProbe` et limites de ressources, `Service` [[ClusterIP|clusterip]], entrée dans l'`Ingress` existant. Externalisez la configuration en `ConfigMap` et le secret [[JWT|jwt]] en `Secret`, puis faites fonctionner le parcours de réservation sur le [[cluster|cluster]] kind.

    *Vérification* : `kubectl scale --replicas=3` puis contrôle de la répartition dans les journaux ; `kubectl delete pod` puis vérification du retour automatique. *Évalué* : probes réellement pertinentes — **pointer les deux sur `/health` n'est pas correct**, la disponibilité doit vérifier les dépendances —, aucun secret en clair dans un [[manifeste|manifeste]], et un service qui redémarre proprement.

  • T8, barème, grille de correction et rattrapage

    **T8 — Soutenance individuelle (20 min par candidat, 20 pts)**. Menée en fin d'épreuve, ou en parallèle des dernières tâches par roulement si l'effectif l'impose. Trois questions tirées d'une banque, dont systématiquement **une question de justification sur le code rendu**.

    *Évalué sur T8* : la capacité à expliquer ses propres choix et à en reconnaître les limites. **Un candidat qui identifie lucidement une faiblesse de son rendu marque davantage qu'un candidat qui défend un rendu parfait sans le comprendre.** Ce critère n'est pas une formule de politesse : il est appliqué tel quel.

    **Seuil de validation : 60/120, avec la condition que T5 rapporte au moins 8 points.** Cette condition n'est pas négociable : un candidat qui n'a rien compris à la cohérence distribuée n'a pas validé la compétence centrale du module, quel que soit son total par ailleurs.

    La première étape mérite d'être relue : **si l'ensemble ne démarre pas en moins de trois minutes sur un poste vierge, sans intervention manuelle, T1 est plafonné à 4 points sur 12**. Réservez les vingt dernières minutes de l'épreuve à un `git clone` dans un répertoire neuf suivi d'un `docker compose up -d`. C'est le meilleur retour sur investissement du jour.

    **Rendu** : correction sur poste, [[dépôt Git|depot-git]] déposé à l'heure de fin annoncée, dernier [[commit|commit]] faisant foi. **Rattrapage** : reprise du même sujet, tâches non validées uniquement, sur 3 h 30, avec un jeu de vérification différent — le script de charge et la collection Postman ne sont pas ceux de la première session.

    Un dernier mot, qui vaut au-delà de l'épreuve. Ce que ce parcours vous a appris n'est pas d'assembler cinq services : c'est de savoir ce que chaque frontière coûte, de reconnaître les problèmes qu'elle crée avant qu'ils ne se manifestent, et de dire non quand le solde est négatif. C'est cette dernière compétence qui a le plus de valeur en entreprise, et c'est la plus rare.

Tarifs

Prix mensuel

29 € / mois

Durée estimée

2 mois

au rythme standard (72 chapitres)

Coût total estimé

58 €

prix mensuel × durée estimée

Commencer maintenant