Why hexagonal fails, almost every time
Ports, adapters, and the small matter of requiring every person on the team to behave perfectly, forever
On paper it is perfect.
Your domain sits in the middle, pure, knowing nothing about the database, the queue, the framework or the web. Everything else is an adapter plugged into a port. Swap the database, the domain does not notice. Swap the transport, the domain does not notice. Test the whole thing with no infrastructure at all.
I have believed in this. I have drawn the hexagon on whiteboards for other people. I have been the one insisting that the repository interface belongs in the domain and the implementation belongs outside it.
And in almost every large codebase I have worked in, it has failed. Not collapsed, not exploded, just quietly eroded into a more expensive version of the thing it was supposed to prevent.
It is worth being precise about why, because the reason is not technical.
What the design actually demands
Read the rules as requirements for people rather than for code.
Everyone inverts every dependency, every time, including on the Friday before a release.
Nobody takes the shortcut of importing the ORM entity into the domain, even though it is right there and the deadline is real.
Every person maintains the mappers between layers, including the ones that translate a structure into an identical structure with a different name.
Every new joiner learns the rules before writing their first feature, and the rules are written down somewhere, and they are current.
Every reviewer enforces a boundary that is invisible at runtime, forever, across hundreds of pull requests, while also reviewing the actual change.
That is not an architecture. That is a code of conduct that happens to compile.
The comparison I keep coming back to
Hexagonal fails the way centrally planned economies fail, and for the same underlying reason.
The design is not stupid. On paper it is more rational than the mess it replaces. Everything in its proper place, no duplication, resources allocated by the plan rather than by whoever shouted loudest. You can show it is better. People have filled blackboards proving it.
It assumes a participant who does not exist.
It assumes everyone will act for the good of the system even when acting for themselves is faster, invisible and rewarded. It assumes nobody defects under pressure. It assumes the planners at the centre know enough about every local situation to make the rules correct, and that the rules stay correct as the situation changes.
We are not that. We are mostly self-centred organisms with deadlines, and a quarterly review that measures what shipped rather than what stayed clean.
One person taking the shortcut does not merely fail to help. It makes things worse than never having tried: now you have the ceremony and the leak, the abstraction and the thing that bypasses it, and every future reader has to work out which of the two is the truth.
Then comes the part every such system reaches. The rules need enforcement, so you get architecture police, a review board, a document nobody reads, a linter with forty exceptions, and a meeting about why the exceptions exist. The bureaucracy grows to defend a plan that is no longer connected to the work.
I am not making a claim about history here. I am making a claim about designs that need people to be better than they are. They do not survive contact with a team of twenty under a deadline, and the ones who held the line get tired.
The complexity is a real cost, paid up front
Set the human problem aside for a moment, because there is a second one.
Needless complexity is bad regardless of the reason it was added. There is no good motive that makes an unnecessary indirection free.
Every port is a file to open, a name to invent, a thing to mock, a layer to step through in a debugger, a place where a stack trace loses its meaning. Every mapper is code that can be wrong. Every abstraction is a lie you have to maintain in two places.
You pay that rent on day one, and the payoff is a swap you will probably never perform. I have changed the database under a hexagonal application exactly once in my career, and the ports were not what made it possible. What made it possible was that the data access was already in a handful of files, which is a property you get from ordinary tidiness, not from a pattern.
Meanwhile the thing that actually changes constantly is the domain itself: new rules, new fields, new flows. The architecture was optimised for the axis that never moves.
When it does work
I want to be fair, because I have also seen it work.
It works in small teams where everyone genuinely agrees, where the agreement is maintained by conversation rather than by policy. It works when there is a real second adapter, not a hypothetical one: an actual second transport, an actual second storage engine, something in production today that proves the seam earns its keep. It works where the boundary is kept by continuous refactoring rather than by a document.
And it works during real migrations if people use branch by abstraction properly: introduce the abstraction over the thing you want to replace, move callers across behind it, build the new implementation, flip, then delete the abstraction when it has done its job.
That last clause is the one everybody skips. The abstraction is scaffolding for a migration, not a monument. Scaffolding that is never taken down is just a building nobody can see out of.
If every engineer in a large team pushed with that discipline, every time, and nobody broke the rules of the hexagon under pressure, it would work. It genuinely would.
But hey. We are humans.
What I do instead
Start with the boring shape. Keep the domain logic in functions that do not import the framework, because that is cheap and it costs nothing to maintain.
Add a port when the second implementation exists, not when someone imagines it might. The cost of adding an interface later is a refactor of an afternoon. The cost of maintaining an unused one is forever.
Prefer a seam you can see over a seam you have to be told about. Package boundaries, module boundaries, a folder you can point at.
Assume the next person will be in a hurry and will not have read the document. Design so that the lazy path and the correct path are the same path, because the lazy path is the one that will be taken.
That last sentence is the whole thing, really. Any architecture whose correctness depends on everyone behaving well is a plan for a species we are not.
Update, 23 September 2025
Once a wise man said:
Hexagonal always brings pain and suffering.
Which took me several thousand words to get to, and he did it in six.
Comments
Threads live in GitHub Discussions. Nothing from GitHub is requested until you press the button.