jobsphp

Microservices PHP : mes trois erreurs de découpage technique

Ingénierie Web. Microservices PHP : mes trois erreurs de découpage technique

Trois nouvelles tentatives. C’est la valeur par défaut de Symfony Messenger. Délai initial: 1 000 ms. Multiplicateur: 2. Avec cette configuration, un message qui échoue ne disparaît pas: il revient, il attend, il recommence.

Microservices PHP: mes trois erreurs de découpage technique

Si son consommateur débite un compte, réserve un stock ou émet un email sans garde idempotente, le système ne devient pas résilient. Il répète un effet métier.

C’est le point de départ réel d’une architecture microservices PHP: pas le nombre de conteneurs Docker, pas Kubernetes, pas le broker. La difficulté est dans les frontières. Une mauvaise frontière fabrique des appels synchrones en cascade, des tables partagées, des transactions impossibles et des délais de réponse qui explosent sans que le CPU soit saturé.

Dans les migrations de monolithes PHP, trois erreurs reviennent avec une régularité mécanique: découper les couches au lieu du métier, appeler l’isolation « indépendance », puis traiter les retries comme une garantie de livraison. Elles produisent l’essentiel des erreurs de communication dans une architecture microservices PHP.

Erreur n°1: découper le monolithe par couches techniques

Le découpage le plus tentant est aussi le plus coûteux à maintenir. Une équipe extrait un service users, un service orders, un service payments, puis isole les contrôleurs, les repositories, les workers ou les endpoints REST. Le résultat porte des noms de composants. Il ne porte pas de responsabilités métier nettes.

Un microservice n’est pas une classe Symfony devenue un dépôt Git. Ce n’est pas non plus une table MySQL déplacée derrière une API. Il doit encapsuler une capacité métier cohérente, avec son modèle, ses règles, ses états et ses données.

Le signal d’alerte est simple: pour terminer une commande, un service doit connaître les détails internes de trois autres services. À ce moment-là, le monolithe n’a pas disparu. Il est distribué sur le réseau.

Prenons un découpage courant, mais défectueux:

  • catalog-api expose les produits;
  • stock-api expose les quantités;
  • pricing-api calcule les prix;
  • order-api crée les commandes;
  • customer-api renvoie les droits du client.

Sur le papier, chaque domaine semble séparé. En exécution, order-api doit appeler les quatre autres services avant de persister une ligne. Une requête HTTP utilisateur devient une chaîne de dépendances. La latence réseau des microservices PHP se cumule. Les timeouts deviennent des décisions métier implicites. Une indisponibilité du calcul de prix empêche parfois la consultation d’une commande déjà créée, parce que les deux flux ont été mélangés dans le même endpoint.

Le découpage doit partir des contextes bornés. Pas des répertoires Controller, Service, Repository. Pas de la structure des namespaces. Un contexte borné définit un vocabulaire, des invariants et une source d’autorité.

Dans une plateforme de recrutement, par exemple, « candidature » et « profil » ne sont pas forcément le même domaine. Le profil décrit une personne et ses compétences. La candidature contient une décision, un statut, une date, une relation avec une offre et un historique de traitement. Les deux peuvent référencer un même identifiant, mais ils ne doivent pas partager leur modèle objet comme une dépendance transversale.

Les symptômes d’un faux microservice

On reconnaît une frontière technique à plusieurs symptômes précis:

1. Le service ne sait rien faire sans lecture distante.

Si chaque commande nécessite une série de GET interservices pour retrouver les règles essentielles, la logique n’est pas localisée. Le service est un orchestrateur vide.

2. Les DTO deviennent le modèle métier global.

Un même UserDto, sérialisé et réutilisé partout, finit par imposer ses champs à tous les domaines. Une modification banale dans l’identité casse le paiement, la facturation ou le CRM.

3. La base de données dicte les services.

Une table users n’est pas automatiquement un domaine. Elle peut contenir l’authentification, le profil public, les préférences, les données RGPD et des flags historiques. Extraire cette table telle quelle déplace seulement le désordre.

4. L’API interne expose des détails d’implémentation.

Une route du type /internal/orders/{id}/rows est souvent le signe qu’un autre service vient faire une jointure à distance. L’API devient une façade SQL lente.

5. La cohérence repose sur l’ordre des appels HTTP.

« Appeler A, puis B, puis C, et annuler A si C échoue »: ce n’est pas une architecture stable. C’est une transaction distribuée bricolée avec des exceptions.

Un microservice sain possède une décision métier. Un microservice technique possède un endpoint et attend les décisions des autres.

Ce que le DDD apporte, concrètement

Le Domain-Driven Design est souvent vendu comme une méthode de modélisation bavarde. Son apport utile ici est plus sec: il force à déterminer qui possède la règle.

Qui décide qu’une offre est publiable? Qui décide qu’une candidature est recevable? Qui possède la règle de disponibilité d’un créneau? Tant que ces réponses sont réparties entre un contrôleur, une procédure stockée, un listener Doctrine et un appel HTTP, il n’existe pas de frontière exploitable.

Le bon découpage vertical assemble dans un même service:

  • l’API d’entrée;
  • la logique métier;
  • les données nécessaires à ses invariants;
  • les événements qu’il publie;
  • les contrats qu’il expose aux autres domaines.

Il n’impose pas que chaque action soit asynchrone. Il évite surtout que le métier doive traverser cinq couches réseau pour prendre une décision élémentaire.

Question de découpageDécoupage techniqueDécoupage par capacité métier
Unité extraiteTable, contrôleur, repository, moduleResponsabilité métier et invariants
Source de véritéSouvent fragmentée ou partagéeUne autorité clairement identifiée
CommunicationMultiples lectures synchronesContrats ciblés, événements si nécessaire
ÉvolutionCouplage par schéma et DTO communsCouplage explicite par API et événements
Échec typiqueAPI interne transformée en jointure distanteCohérence éventuelle assumée et observable

Le découpage monolithique PHP doit donc commencer par une carte des décisions, pas par un script de copie de tables. La question utile n’est pas « quelle classe extraire? ». C’est: « quelle règle doit rester atomique, et quel domaine en est propriétaire? »

Erreur n°2: confondre base privée et indépendance réelle

Le pattern database per service répond à un problème réel. Chaque service garde ses données persistantes privées. Les autres services passent par son API ou consomment ses événements. Ils ne lisent pas directement ses tables. C’est une discipline saine.

Mais une base séparée ne supprime pas le couplage. Elle le rend visible, donc plus difficile à ignorer.

Le cas classique: order-service possède les commandes, payment-service les paiements, inventory-service les réservations de stock. Une commande confirmée exige une écriture dans les trois systèmes. Dans le monolithe, une transaction SQL pouvait englober l’opération. Après extraction, une transaction MySQL ne traverse ni un broker ni trois bases autonomes.

La mauvaise réponse consiste à simuler une transaction distribuée avec des appels synchrones:

  • order-service crée une commande;
  • il appelle inventory-service pour réserver;
  • il appelle payment-service pour autoriser;
  • si le paiement échoue, il demande au stock d’annuler;
  • si l’annulation échoue, il journalise une erreur et espère qu’un cron répare la situation.

Ce mécanisme contient déjà des états intermédiaires. Il ne les modélise pas. Une commande peut être créée, le stock réservé et le paiement inconnu. L’application appelle cela une exception. Le métier appelle cela un incident de cohérence.

La cohérence éventuelle n’est pas un bug, c’est un contrat

Quand une opération traverse plusieurs services, il faut accepter que certains états soient transitoires. Mais les rendre visibles.

Une commande peut être:

  • pending: créée localement, traitement externe en cours;
  • stock_reserved: stock réservé, paiement attendu;
  • confirmed: les invariants de confirmation sont atteints;
  • cancelled: compensation appliquée;
  • manual_review: un état technique ou métier exige une intervention.

Ce ne sont pas des rustines. Ce sont les états réels d’un processus distribué. Les masquer derrière un booléen is_paid et une succession d’appels HTTP produit des incohérences impossibles à diagnostiquer.

La séparation des bases oblige également à repenser les lectures. Une jointure SQL entre commandes, clients et paiements ne survit pas telle quelle. Trois options existent, avec des compromis différents:

  • appeler les services à la demande pour une vue fraîche, avec un coût de latence et un risque de panne corrélée;
  • maintenir une projection locale alimentée par événements, avec une cohérence éventuelle;
  • produire une vue dédiée de lecture, gouvernée comme un produit de données et non comme une base partagée clandestine.

La dernière option est souvent la moins glamour et la plus solide. Une projection de lecture permet d’éviter que chaque page d’administration transforme cinq appels REST en un faux JOIN.

Une base privée ne rend pas un service autonome. Elle interdit seulement les raccourcis silencieux.

Le problème des lectures synchrones

Les problèmes de performance des API internes PHP ne viennent pas toujours de PHP-FPM, d’OPcache ou de l’allocation mémoire. Une application peut avoir des workers peu chargés et rester lente parce qu’elle attend le réseau.

Un appel synchrone bloque le service appelant jusqu’à la réponse. Si A appelle B, qui appelle C, le temps total dépend de toute la chaîne. Ajoutez les connexions, les pools, la sérialisation JSON, le TLS, les retries et les files d’attente des workers. Le coût marginal d’un service supplémentaire n’est jamais nul.

L’asynchrone isole mieux les pannes et absorbe les pics par file de messages. Il introduit en contrepartie des retards, des projections temporairement obsolètes, des tests plus complexes et une restitution du résultat moins immédiate. Il n’y a pas de mode universellement supérieur.

La règle pratique est plus stricte:

  • utilisez le synchrone quand la réponse est indispensable à la décision en cours;
  • utilisez l’asynchrone quand l’action peut être confirmée plus tard et que le domaine sait représenter cet état;
  • ne cachez pas une dépendance critique derrière une file: si l’utilisateur ne peut pas continuer sans résultat, le problème de disponibilité existe toujours, seulement sous une autre forme.

Erreur n°3: publier un événement sans transaction et croire aux retries

Le scénario est banal. Un service écrit une ligne dans MySQL, puis publie un message OrderCreated dans RabbitMQ, Redis Streams, Kafka ou un transport Symfony Messenger. Entre les deux opérations, le processus tombe. Deux états sont possibles:

  • la commande est persistée, mais aucun événement n’est publié;
  • l’événement est publié, mais la transaction métier est annulée ou n’a jamais été validée.

La première perte est silencieuse. Aucun consommateur ne sait qu’il manque un événement. Les projections dérivent. Le stock n’est pas réservé. L’email n’est pas envoyé. La supervision ne voit rien, car aucun appel n’a échoué au sens HTTP.

Le pattern transactional outbox traite ce point avec une mécanique simple. Dans la même transaction locale que la modification métier, le service écrit un enregistrement dans une table d’outbox. Un relais lit ensuite cette table et publie les messages vers le broker. La base ne doit plus coordonner une transaction distribuée avec le broker: elle garantit seulement l’écriture atomique de son état et de l’intention de publier.

Cette méthode ne fournit pas magiquement « exactement une fois ». Le relais peut publier un message puis tomber avant d’avoir enregistré qu’il l’a fait. À sa reprise, il peut republier. Le consommateur recevra donc potentiellement le même événement plusieurs fois.

C’est normal. La correction se situe côté consommateur.

Idempotence: une propriété métier, pas une option Messenger

Un consommateur idempotent peut traiter plusieurs fois le même message sans répéter l’effet métier attendu. Il faut pour cela un identifiant stable d’événement, enregistré durablement avec l’effet produit.

Exemple: payment-service reçoit OrderConfirmed avec un event_id. Avant de créer une facture, il vérifie si cet événement a déjà été consommé. Si oui, il s’arrête. Si non, il crée la facture et inscrit l’identifiant traité dans la même transaction locale.

Le piège est d’utiliser uniquement un cache ou une structure en mémoire. Après redémarrage d’un worker, le cache est vide. Après une réplication, deux workers peuvent lire l’absence du même identifiant. L’idempotence doit être soutenue par une contrainte durable: index unique, table de déduplication transactionnelle ou invariant équivalent.

Avec Symfony Messenger, la stratégie de retry par défaut est agressive juste ce qu’il faut pour faire ressortir ce défaut. Le maximum est de trois nouvelles tentatives, avec un délai initial de 1 000 ms, un multiplicateur de 2 et un jitter de 0,1. Un message qui échoue n’est pas une exception ponctuelle: c’est une opération qui sera réexécutée.

MécanismeCe qu’il résoutCe qu’il ne résout pas
Retry MessengerErreurs transitoires, indisponibilités courtesDoubles effets métier
Transactional outboxPerte entre écriture locale et intention de publicationDoublons à la publication
Consommateur idempotentRéception répétée du même événementMauvais contrat métier ou données incohérentes
Transport d’échecConservation des messages après échec répétéDiagnostic automatique de la cause
Saga ou compensationCoordination d’un processus multi-servicesAtomicité globale parfaite

Le transport d’échec n’est pas une décharge. C’est une file d’incidents techniques ou métier. Un message qui y arrive doit contenir assez de contexte pour être rejoué, annulé ou corrigé. Sans identifiant de corrélation, version de contrat, cause d’échec et état local, il devient un objet opaque qu’on purge à la main.

Avec le transport Doctrine de Messenger, redeliver_timeout est fixé à 3 600 secondes par défaut. Ce délai doit rester supérieur à la durée maximale de traitement d’un message. Sinon, un worker encore actif peut voir son message redélivré à un autre worker. C’est un doublon fabriqué par configuration.

Les retries HTTP sont tout aussi dangereux

Symfony HttpClient peut réessayer certains échecs jusqu’à trois fois par défaut, avec délai exponentiel. Les réponses 500, 504, 507 et 510 ne sont réessayées automatiquement que pour des méthodes HTTP idempotentes. Cette nuance est fondamentale.

Un GET peut généralement être répété. Un POST /payments ne l’est pas nécessairement. Si le serveur a exécuté le débit mais que la réponse est perdue, le client ne sait pas si l’opération a eu lieu. Réessayer sans clé d’idempotence peut créer un second débit.

Pour une commande sensible, l’API doit accepter une clé d’idempotence portée par le client ou le service appelant. Le serveur associe cette clé à la réponse métier. Une répétition renvoie le résultat précédent au lieu de rejouer l’action. Ce contrat doit être explicite. Les retries ne compensent pas son absence.

La résilience ne se règle pas dans un fichier YAML

Les équipes traitent souvent les pannes microservices comme un problème de paramètres: timeout de trois secondes, retry à trois tentatives, circuit breaker, dead-letter queue. Ces éléments sont nécessaires. Ils ne corrigent pas une dépendance mal placée.

Un timeout n’est pas une protection contre une architecture bavarde. Il borne seulement l’attente. Un retry ne rend pas une commande idempotente. Il augmente sa probabilité d’être exécutée plusieurs fois. Un circuit breaker ne donne pas de réponse métier lorsque le service distant est absent. Il évite surtout de saturer le système avec des appels déjà condamnés.

La bonne séquence de diagnostic est plus brutale:

1. Identifier la décision bloquée.

Quelle règle exige réellement une réponse immédiate? Une validation de stock? Une autorisation de paiement? Une vérification de droits? Tout le reste peut parfois être projeté ou différé.

2. Mesurer la chaîne complète.

Pas seulement la durée PHP. Il faut séparer le temps de connexion, l’attente DNS si elle existe, le handshake TLS, le temps serveur, la sérialisation, la mise en file et la reprise. Une moyenne masque les pointes; les percentiles élevés exposent la réalité utilisateur.

3. Fixer un budget de latence par dépendance.

Si une requête critique dépend de quatre services, chacun ne peut pas consommer arbitrairement le temps de réponse total. Sans budget, les timeouts se superposent et la requête reste bloquée bien après le moment où elle ne produit plus de valeur.

4. Définir le comportement dégradé.

Réponse partielle, état pending, donnée projetée datée, mise en attente: le choix est métier. Renvoyer une erreur 500 parce qu’un service de recommandation ne répond pas n’est pas une stratégie.

5. Tester la panne, pas seulement le chemin nominal.

Broker indisponible, message dupliqué, réponse HTTP perdue après traitement, consommateur redémarré, projection en retard, contrat de message en version mixte: ce sont des scénarios de production, pas des cas exotiques.

Tracer les flux et fermer les surfaces d’attaque

Une architecture distribuée sans traçage distribué n’est pas observable. Elle produit des logs locaux, chacun correct, et aucune chronologie exploitable. Lorsqu’un endpoint déclenche quatre appels et deux messages, le fichier de log d’un seul conteneur ne répond à aucune question sérieuse.

Le standard W3C Trace Context définit la propagation du contexte entre services HTTP. L’idée opérationnelle est simple: un identifiant de trace traverse les appels synchrones et doit être injecté dans les messages asynchrones. Chaque service ajoute ses spans. Les métriques disent que le système dérive. Les traces montrent où. Les logs expliquent pourquoi. Aucun de ces signaux ne remplace les deux autres.

Pour une application PHP, le minimum consiste à propager:

  • un traceparent sur les appels HTTP sortants;
  • l’identifiant de trace dans les headers de messages Messenger;
  • un identifiant de corrélation métier distinct quand le flux représente une commande, une candidature ou une facture;
  • la version du contrat d’événement;
  • l’identifiant immuable de l’événement, nécessaire à la déduplication.

Le second angle mort est la sécurité. Chiffrer les communications internes ne suffit pas. Une API interne non documentée, sans limites de payload ni politique de débit, reste une surface d’attaque et une source de saturation.

L’édition 2023 de l’OWASP API Security Top 10 insiste notamment sur les limites de fréquence, la taille des paramètres et des payloads, ainsi que sur la documentation des hôtes, versions, flux de données, mécanismes d’authentification et erreurs. Dans un système microservices, cette documentation ne sert pas seulement à l’audit. Elle définit la topologie réelle du système.

Une API interne doit donc avoir:

  • une authentification de service à service, distincte d’un simple header décoratif;
  • une autorisation qui limite les actions, pas seulement l’accès réseau;
  • des limites de taille sur les requêtes et messages;
  • un rate limiting adapté aux flux exposés;
  • des contrats versionnés;
  • des logs qui évitent les secrets, tokens et données personnelles;
  • une politique claire de conservation des messages en échec.

Le réseau interne n’est pas une zone de confiance. C’est juste une zone où les erreurs de configuration sont plus faciles à ignorer jusqu’au premier incident.

Sans trace distribuée, les microservices ne sont pas débogables. Sans idempotence, ils ne sont pas rejouables.

Le verdict

Les microservices PHP sont justifiés quand les frontières métier sont réelles, quand les données ont des propriétaires identifiables et quand les échanges distribués sont traités comme des opérations faillibles. Pas avant.

Découper par couches techniques: à éviter en production. Cela transforme le monolithe en graphe d’appels HTTP.

Imposer une base privée sans modéliser les lectures, les états transitoires et les compensations: insuffisant en production. L’isolation du schéma ne remplace pas l’architecture.

Publier des événements sans outbox, consommer sans idempotence, compter sur les retries: à proscrire en production.

Le reste — framework, broker, conteneur, protocole — vient après. La machine tolère mal les frontières floues. Elle les facture en latence, en doublons et en incidents impossibles à rejouer proprement.

Questions fréquentes

Pourquoi est-il déconseillé de découper un monolithe par couches techniques ?
Ce découpage crée des dépendances croisées où un service doit constamment interroger les autres pour fonctionner, transformant le monolithe en un système distribué lent et difficile à maintenir.
Comment gérer la cohérence des données entre plusieurs microservices ?
Il faut accepter la cohérence éventuelle, modéliser les états transitoires du processus métier et utiliser des projections locales ou des vues dédiées pour éviter les jointures à distance.
Qu'est-ce que le pattern transactional outbox ?
C'est une méthode qui consiste à enregistrer un événement dans une table locale lors de la transaction métier, puis à le publier via un relais, garantissant ainsi que l'événement est bien émis.
Pourquoi l'idempotence est-elle indispensable dans une architecture microservices ?
Elle permet à un consommateur de traiter plusieurs fois le même message sans répéter l'effet métier, ce qui est crucial car les systèmes distribués peuvent générer des doublons lors des retries.
Quels sont les risques des appels synchrones entre services ?
Ils bloquent le service appelant, cumulent la latence réseau et créent des pannes en cascade si l'un des services de la chaîne devient indisponible.