L'architecture hexagonale a un problème. Tout le monde la cite, presque personne ne montre la facture d'un vrai refactor.
TL;DR : j'ai extrait une app web d'un dépôt fourre-tout. Au départ : 49 fichiers en
package main, un main.go de 2060 lignes, une god-struct à 20 dépendances. À l'arrivée : un module autonome hexagonal, 3 dépendances directes au lieu de 30, 8 services de domaine. Plus un bug produit latent découvert en route. Une journée, 20 commits, des sous-agents IA en séquentiel. Voici la méthode.
Cet article est pour ceux qui vivent avec un monolithe plat qui grossit, et qui repoussent le refactor faute d'un plan crédible.
Le point de départ : un dépôt, trois applications
Mon dépôt de départ mélangeait trois applications. Des daemons de tri d'emails, un daemon LinkedIn, et un cockpit web de prospection en HTMX. La cible du refactor, c'était le cockpit.
Son état, mesuré avant de toucher quoi que ce soit. 49 fichiers dans package main. Un main.go de 2060 lignes, un api.go de 1033. Une struct server à environ 20 dépendances.
Les handlers faisaient tout : parser la requête, décider, écrire en base, rendre le HTML. La logique de matching existait en double. Le template de base pesait 102 Ko.
Un point sain quand même : un seul pool Postgres, des migrations propres. C'est la fondation qui a rendu le reste possible.
L'architecture hexagonale sépare le métier des détails techniques. Le domaine au centre. Des ports autour : des interfaces que le domaine définit. Des adapters à l'extérieur : Postgres, HTTP, Redis, qui implémentent ces ports.
Décision n°1 : la frontière se calcule, elle ne se devine pas
Extraire le cockpit dans son propre dépôt pose une question piège. Quel code partagé emporter ?
Le cockpit réutilisait du métier des daemons : génération de brouillons, enrichissement, réseau. Un module partagé aurait couplé les deux dépôts pour toujours.
La réponse est venue d'un outil, pas d'un débat. go list -deps donne la fermeture transitive exacte : la liste complète des packages que le cockpit importe, directement ou non.
Résultat : 20 packages internes à emporter, pas un de plus. Les packages réservés aux daemons sont restés derrière.
Effet immédiat sur le go.mod : de plus de 30 dépendances directes à 3. Le poids mort des daemons a disparu du build.
Décision n°2 : une composition root, des handlers qui ne savent rien
Première extraction : le câblage. Le main() de 213 lignes est devenu un point d'entrée fin, plus un app.go qui construit tout. La composition root est l'endroit unique où l'application assemble ses pièces.
Ensuite, domaine par domaine, du plus isolé au plus couplé. Le matching d'abord, déjà presque pur. LinkedIn en dernier, le plus enchevêtré.
Le pattern est le même partout :
internal/veille/
service.go // la logique, testable
ports.go // interfaces vers le stockage
adapters/
postgres/ // implémente les ports
http/ // handlers fins, 1 fichier par domaine
cmd/cockpit/
app.go // composition root : tout le câblage
Huit services de domaine sont sortis des handlers. Le handler parse, appelle le service, rend la réponse. Rien d'autre.
La règle hexagonale tient en une phrase : le domaine ne dépend que des ports, jamais l'inverse.
Ce que le refactor a trouvé : un bug produit latent
Au milieu de la migration, un template a refusé de rentrer dans le rang. Le détail d'un lead lisait un champ réservé aux conversations LinkedIn, hors de sa garde.
Le chemin email passait un autre type, sans ce champ. Ouvrir le détail d'un lead email pouvait planter le rendu. En prod, personne ne l'avait encore déclenché.
Le refactor a forcé la question que personne ne posait : qui passe quoi à ce template ? Un accesseur neutre a réparé, un test verrouille désormais le rendu.
C'est un bénéfice sous-coté du refactor structurel. Des frontières nettes font remonter les mensonges du code.
Les sous-agents IA, en séquentiel strict
La série des huit domaines était répétitive. J'ai fait le premier à la main, comme pilote du pattern. Puis un sous-agent IA a traité chaque domaine suivant.
Deux règles ont tout sauvé. D'abord, jamais deux agents en parallèle sur un package main : un seul espace de noms, des collisions garanties.
Ensuite, build et tests vérifiés par moi après chaque domaine. Les agents recâblent bien les handlers, mais ils oublient les fixtures de test. Une régression de service nil est morte à ce contrôle.
Le pilote à la main, la série à l'agent, la vérification à chaque pas.
Ce que j'ai refusé de faire
Un refactor se juge aussi à ce qu'il ne touche pas.
La file de jobs est restée dans Postgres. Une file Redis aurait fait plus propre sur le diagramme. La file Postgres, elle, est durable, transactionnelle, et déjà en place. Migrer aurait affaibli le système pour le rendre plus à la mode.
Le big-bang était borné par une règle : chaque phase se termine build vert, tests verts. Pas de phase suivante sur une base rouge.
Et le rollback a été écrit avant la bascule. L'ancien déploiement reste prêt à redémarrer, volumes conservés. La bascule a migré les données, vérifié les intégrations, puis coupé l'ancien.
La facture
Avant Après
49 fichiers package main cmd/ + internal/ + adapters/
main.go : 2060 lignes entrypoint fin + composition root
go.mod : 30+ deps directes 3 deps directes
0 service de domaine 8 services, ports + adapters
CSS de base : 1256 lignes 192 lignes + fichiers par page
1 bug latent en prod trouvé, corrigé, testé
Le tout en une journée de travail dense et une vingtaine de commits, sous-agents compris. L'app tourne en production depuis, dans son propre déploiement.
Le plus dur n'était pas l'architecture. C'était la frontière.
La checklist avant ton refactor
Si tu envisages le même chantier, passe par là.
- Mesure l'état de départ : fichiers en
package main, lignes du main.go, deps directes - Calcule la frontière avec
go list -deps, ne la devine pas - Extrais la composition root avant de toucher aux domaines
- Migre domaine par domaine, du plus isolé au plus couplé, build vert à chaque pas
- Fais le pilote à la main avant de déléguer la série à un agent
- Vérifie les fixtures de test après chaque recâblage
- Garde ce qui marche : une file Postgres durable bat une file Redis théorique
- Écris le rollback avant le cutover
Ce qu'il faut retenir
L'hexagonal n'est pas une religion. C'est un outil de séparation, et il se paie en journées, pas en années, si la frontière est bien calculée.
Le refactor a rendu plus que de l'architecture : un build allégé, des services testables, et un bug produit trouvé avant les utilisateurs.
Un monolithe à découper, un refactor à cadrer ? Parlons-en.