A 2,060-Line main.go to Hexagonal Architecture: The Real Numbers

go dev.to

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


Sources: Alistair Cockburn, Hexagonal Architecture (2005) · go list documentation (pkg.go.dev) · pgx, PostgreSQL driver for Go

Source: dev.to

arrow_back Back to Tutorials