The criticism of this is everywhere. Open any Go thread long enough and someone will show up to perform the same ritual:
"Go projects become messy...
For further actions, you may consider blocking this person and/or reporting abuse
I don’t write Go (apart from some experiments at home), but I can definitely confirm this from the frontend world. 😄
People have always said that Angular is a framework, so it almost guarantees good architecture. Well, after 10 years of working with it, I can confidently say that you can still spectacularly screw up your architecture if you really want to. 😂 Sloppy typing, putting everything into the main bundle, ignoring proper separation because someone either didn’t know better or simply couldn’t be bothered... and there you have a recipe for disaster.
Sure, having a framework is often more convenient because you don’t have to make every architectural decision yourself or spend time choosing libraries and solutions for everything. But a framework doesn’t magically give you good architecture. You can still make a beautiful mess with all the right tools.
Exactly 😂 And I love Angular, btw.
I hardly touch it these days since moving into a more server-side role, but I'd never pass up an opportunity to work with it again. There's still so much to discover in that framework!
True! And in a last couple of years a lot has changed and sometimes it's hard to catch up 😅
"The tests still pass while the architecture quietly dies" is the load-bearing sentence, and I think it undercuts the section it appears in.
Everything before it is right: folders aren't architecture, dependency direction is, and consumer-owned interfaces are the good Go instinct that people from Java have to unlearn. But the mechanism you propose for keeping it that way is a code review checklist item - and a checklist item is exactly the thing that fails the way you just described. Silently, gradually, with a green build.
The interesting part about your rule specifically is that it doesn't need discipline. Dependency direction is the one architectural property that's mechanically checkable, and Go makes it cheaper than in most languages, because imports are static, there's no runtime injection to chase, and the graph is one command away:
Fifteen lines, runs in the time it takes to list packages, and the arrow you drew in the post becomes a build failure instead of a review comment. internal/ already gives you one compiler-enforced boundary for free; this is the same idea applied to the direction rather than the visibility.
The len(deps) < 5 line is the part I'd argue for hardest, and it's the one everyone leaves out. A typo in the package path makes go list return almost nothing, every loop iterates over an empty slice, and the test goes green while checking absolutely nothing. That failure looks identical to a clean architecture from the outside - which is the exact phenomenon your closing sentence describes, one level up.
None of which contradicts the post. It's the same argument: Go doesn't give you the guardrail, so build it. I'd just rather build it once as a test than remember it at every review, because remembering is the part that decays.
This is an excellent point, Heinrich.
"The tests still pass while the architecture quietly dies" is the exact failure mode of relying on human discipline during PR reviews. When a production deadline hits, review checklists get compromised, and the dependency graph quietly metastasizes while the build stays green.
The "len(deps) < 5" defensive guard is the real highlight of your snippet. Most custom architectural tests suffer from the "silent pass" vulnerability: a path typo makes "go list" return an empty slice, the loop never executes, and CI turns green over an unverified codebase.
Automating dependency direction as an Architectural Fitness Function that breaks the build is the only reliable way to enforce structural invariants over multi-year codebases.
Solid approach.
Wow, I genuinely never thought of doing this.
I spend quite a bit of time manually reviewing dependency injection and checking whether anything is flowing the wrong way, and somehow it never occurred to me to make the dependency direction itself mechanically enforceable.
This is genius. I especially love the point about the empty dependency list silently passing - the kind of failure mode I'd want the test to guard against!
Thanks so much for sharing this! Definitely stealing this haha
On the escape-analysis branch of this thread: the interface itself is not what moves the struct to the heap, visibility of the concrete type at the call site is. On Go 1.26.2 darwin/arm64 under
-gcflags=-m, assigning a*PGto a localStoreand calling through it printsdevirtualizing s.Get to *PGand&PG{...} does not escape; handing that same value to ago:noinlinefunction whose parameter isStoreprintsescapes to heap, while the identical call with a*PGparameter does not. So the property worth protecting is not method count but the boundary where devirtualization still works, since once the value crosses into a call the compiler cannot inline or devirtualize, the allocation happens no matter how small the interface is.Great write-up, Adam.
The "Cargo Cult of Clean Architecture" has produced more unmaintainable, deeply nested folder trees than actually decoupled systems. The realization that interfaces belong to the consumer is the exact inflection point where a developer transitions from OOP muscle memory to idiomatic Go.
There is an additional, physical dimension to this dependency discipline that often gets overlooked: Go's Escape Analysis and Garbage Collection pressure.
When developers prematurely introduce large, Java-style interface hierarchies across multiple packages to make their architecture look "clean", they often inadvertently force concrete structs to escape from the Stack to the Heap. Dynamic dispatch through an "iface" container prevents the compiler from proving stack lifetime.
Keeping packages small, boundaries explicit, and consumer-defined interfaces minimal (1–2 methods max) doesn't just keep the dependency graph acyclic. It allows Go's escape analysis to keep allocations on the goroutine stack, completely bypassing the Garbage Collector on high-throughput paths.
Clean architecture in Go isn't just about code organization; it's about compiler symbiosis.
Interesting angle! I honestly wasn't thinking about the compiler side of it when I wrote this.
"Compiler symbiosis" has a nice touch, makes me wonder how often we carry Java-style abstractions into Go that are technically clean but give the compiler less room to optimize.
thanks for sharing!!