Lesson 4 · Hexagonal — the shape of a v2 module · your code
Spot the leak
Part 1's payoff: a module can look perfectly hexagonal — right folders, right port interfaces — and still break the one rule that matters. Here's a real one, in conversationmgmt.
Your win: read a real constructor in your own service, spot the dependency-inversion break, and say exactly where the wiring should live instead.
The setup: it looks textbook
conversationmgmt's conversation module has the cleanest folder layout in the
service: core/port/ holds every interface, infrastructure/ holds every
implementation (Lesson 2). By the folder map, it's a model hexagon. Now look at the actual
constructor that builds the service — read it before the explanation:
// line 3-8 — the imports
import (
".../conversation/core/port/repository"
".../conversation/core/port/service"
".../conversation/infrastructure/postgres" // ← the core imports infrastructure
...
)
// line 31-49 — the constructor, inside package `service` (the CORE)
func NewConversationModifierService(db database.Ext, ...) service.ConversationModifierService {
return &conversationModifierServiceImpl{
ConversationRepo: &postgres.ConversationRepo{}, // ← news up a concrete adapter
ConversationMemberRepo: &postgres.ConversationMemberRepo{}, // ...here, in the core
ChatVendorUserRepo: &postgres.AgoraUserRepo{},
// ... six more concrete &postgres.XxxRepo{}
}
}
Did you catch it?
The struct fields are typed as ports — repository.ConversationRepo (an
interface). Good. But the constructor news up concrete &postgres.XxxRepo{}
adapters, right here inside core/service. To do that it has to
import ".../infrastructure/postgres" — line 6.
core/service depends on infrastructure/postgres — the exact reversal.
The port interfaces are real, but they're not buying anything, because the core still
reaches out and grabs the concrete postgres struct itself.1
Where the wiring belongs: the composition root
There should be exactly one place that knows both the port and its concrete adapter — the composition root — and it belongs at the outer edge, not in the core. Your other services get this right, in two different spots:
| Service | Where adapters get wired to ports | Core imports infra? |
|---|---|---|
| eureka v2 | cmd/server/eureka/gserver.go (manual DI at startup) | No — interface files never import postgres |
| spike | the controller constructor NewEmailModifierService | No — wired at the transport edge |
| conversationmgmt | inside core/service | Yes — the leak |
In eureka, the usecase and repository interface files are pure — the concrete
postgres.NewSubmissionRepo() is instantiated in gserver.go and
injected inward. In spike, the concrete &repositories.EmailRepo{} is
built in NewEmailModifierService at the controller edge (you'll see this exact wiring
in Lesson 8). Both keep the core innocent of postgres. conversationmgmt's
conversation module doesn't — the composition root leaked inward.
NewXxxRepo(), never a bare &Repo{}, because they hold a
querier field. conversationmgmt's &postgres.ConversationRepo{} is a
bare literal and that's fine — its repos are stateless struct{}. The leak
here isn't the literal; it's which package is doing the newing-up. Keep the two ideas
separate.
The dependency rule
Cockburn (and Three Dots Labs) on why the arrow only points inward — the exact rule this leak breaks. Re-read with the conversationmgmt constructor in mind.
→ threedots.tech — Clean Architecture in Go ·
Cockburn — Hexagonal
→ in-repo internal/conversationmgmt/modules/conversation/core/service/conversation_modifier.go:6,31-49
Check yourself (from memory)
Q1. What exactly is the leak in conversationmgmt?
core/service imports infrastructure/postgres
and constructs &postgres.XxxRepo{} itself — the dependency points outward.
Q2. Where should concrete adapters be wired to ports?
gserver.go
or spike's controller constructor — instantiates adapters and injects them inward.
Q3. The folder layout is perfectly hexagonal. Does that prove the rule holds?
conversation/core/service/conversation_modifier.go:6
imports infrastructure/postgres; :31-49 the constructor news up
&postgres.ConversationRepo{} (and 7 more) inside the core. Fields are
typed as ports (interfaces) — good — but the core still reaches for the concrete adapter, so the
dependency points outward: core → infrastructure. The fix / where wiring belongs:
one composition root at the outer edge — eureka wires in cmd/server/eureka/gserver.go;
spike wires in the controller ctor NewEmailModifierService — and injects adapters
inward, keeping the core innocent of postgres. The skill: hexagonal = "does the
arrow only point inward?", checked by import direction, not folder names. (Separate from
the Part-3 constructor-not-literal rule.)main/the transport constructor and inject them inward."
1. In-repo (verified): internal/conversationmgmt/modules/conversation/core/service/conversation_modifier.go:6 (import), :31-49 (constructor news-up). Contrast cmd/server/eureka/gserver.go (eureka wiring) and internal/spike/modules/email/controller/grpc/email_modifier_service.go:39-67 (spike wiring).