A propos Compétences Expérience Services Blog Contact

Refactorer un main.go de 2060 lignes en hexagonal, avec les chiffres Refactoring a 2,060-line main.go to hexagonal, with the numbers

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.

Hexagonal architecture has a problem. Everyone quotes it, almost nobody shows the bill of a real refactor.

TL;DR: I extracted a web app from a catch-all repository. Before: 49 files in package main, a 2,060-line main.go, a god struct with 20 dependencies. After: a standalone hexagonal module, 3 direct dependencies instead of 30, 8 domain services, and one latent production bug found on the way. One day, 20 commits, AI sub-agents run in sequence. Here is the method.

This article is for people living with a flat monolith that keeps growing, postponing the refactor for lack of a credible plan.

The starting point: one repo, three applications

My starting repository mixed three applications. Email triage daemons, a LinkedIn daemon, and an HTMX prospecting cockpit. The refactor target was the cockpit.

Its state, measured before touching anything. 49 files in package main. A 2,060-line main.go, a 1,033-line api.go. A server struct with about 20 dependencies.

Handlers did everything: parse the request, decide, write to the database, render HTML. The matching logic existed twice. The base template weighed 102 KB.

One healthy spot: a single Postgres pool and clean migrations. That foundation made the rest possible.

Hexagonal architecture separates business logic from technical detail. The domain sits in the center. Ports around it: interfaces the domain defines. Adapters outside: Postgres, HTTP, Redis, implementing those ports.

Decision 1: compute the boundary, do not guess it

Extracting the cockpit into its own repository raises a trap question. Which shared code comes along?

The cockpit reused business code from the daemons: draft generation, enrichment, network logic. A shared module would have coupled both repositories forever.

The answer came from a tool, not a debate. go list -deps gives the exact transitive closure: the full list of packages the cockpit imports, directly or not.

Result: 20 internal packages to bring over, not one more. Daemon-only packages stayed behind.

Immediate effect on go.mod: from 30+ direct dependencies to 3. The daemons' dead weight vanished from the build.

Decision 2: one composition root, handlers that know nothing

First extraction: the wiring. The 213-line main() became a thin entrypoint plus an app.go that builds everything. The composition root is the single place where the application assembles its parts.

Then domain by domain, from the most isolated to the most entangled. Matching first, already nearly pure. LinkedIn last, the messiest.

The pattern is the same everywhere:

internal/monitoring/
  service.go   // the logic, testable
  ports.go     // interfaces to storage
adapters/
  postgres/    // implements the ports
  http/        // thin handlers, 1 file per domain
cmd/cockpit/
  app.go       // composition root: all the wiring

Eight domain services came out of the handlers. A handler parses, calls the service, renders the response. Nothing else.

The hexagonal rule fits in one sentence: the domain depends only on ports, never the other way around.

What the refactor found: a latent production bug

Halfway through the migration, one template refused to fall in line. The lead detail view read a field reserved for LinkedIn conversations, outside its guard.

The email path passed a different type, without that field. Opening an email lead's detail could crash the render. In production, nobody had triggered it yet.

The refactor forced the question nobody was asking: who passes what to this template? A neutral accessor fixed it, and a test now locks the render.

That is an underrated benefit of structural refactoring. Clean boundaries surface the code's lies.

AI sub-agents, strictly sequential

The series of eight domains was repetitive. I did the first one by hand, as the pattern's pilot. Then an AI sub-agent handled each following domain.

Two rules saved everything. First, never two agents in parallel on a package main: one namespace, guaranteed collisions.

Second, build and tests checked by me after every domain. Agents rewire handlers well, but they forget test fixtures. One nil-service regression died at that checkpoint.

Pilot by hand, series by agent, verification at every step.

What I refused to do

A refactor is also judged by what it leaves alone.

The job queue stayed in Postgres. A Redis queue would have looked cleaner on the diagram. The Postgres queue is durable, transactional, and already there. Migrating it would have weakened the system to make it more fashionable.

The big-bang had one bounding rule: every phase ends with a green build and green tests. No next phase on a red base.

And the rollback was written before the cutover. The old deployment stays ready to restart, volumes kept. The switch migrated the data, checked the integrations, then shut the old one down.

The bill

Before                        After
49 files in package main      cmd/ + internal/ + adapters/
main.go: 2,060 lines          thin entrypoint + composition root
go.mod: 30+ direct deps       3 direct deps
0 domain services             8 services, ports + adapters
base CSS: 1,256 lines         192 lines + per-page files
1 latent production bug       found, fixed, tested

All of it in one dense working day and about twenty commits, sub-agents included. The app has been running in production since, in its own deployment.

The hard part was not the architecture. It was the boundary.

The checklist before your refactor

Planning the same project? Walk through this first.

  • Measure the starting point: files in package main, main.go lines, direct deps
  • Compute the boundary with go list -deps, do not guess it
  • Extract the composition root before touching any domain
  • Migrate domain by domain, most isolated first, green build at every step
  • Do the pilot by hand before delegating the series to an agent
  • Check test fixtures after every rewiring
  • Keep what works: a durable Postgres queue beats a theoretical Redis one
  • Write the rollback before the cutover

What to remember

Hexagonal is not a religion. It is a separation tool, and it costs days, not years, when the boundary is computed properly.

The refactor paid back more than architecture: a lighter build, testable services, and a product bug found before users did.

A monolith to split, a refactor to scope? Let's talk.