Lesson 3 · Hexagonal — the shape of a v2 module
The domain layer & the DTO boundary
The centre of the hexagon should be the part that knows nothing about databases — and the repo has a specific trick, and a specific smell, around keeping it that way.
Your win: explain why the domain should be plain Go, recognise spike's
model-vs-dto split, and name the pgtype leak the moment you
see it in a signature.
The domain is the part you'd keep if you threw out the database
In the hexagon, the domain sits dead centre: entities, value objects, business
rules. The ideal — stated plainly in eureka's convention — is that it uses
only Go stdlib types, no pgtype:1
// domain/submission.go — pure Go, no driver types
type Submission struct {
ID string
SessionID string
StudentID string
// time.Time, not pgtype.Timestamptz; string, not pgtype.Text
}
Why bother? Because the moment a domain type carries pgtype.Text or
pgtype.Timestamptz, your business logic is coupled to the postgres driver. You can't
unit-test it without dragging the driver in; you can't reuse it for a Kafka message or a gRPC
response without translating; and "the DB is just an adapter" stops being true. Pure domain is what
makes the hexagon's centre actually independent.
But rows need driver types — so the repo splits them
There's a real tension: to scan a database row you do want pgtype (it
handles NULLs cleanly). spike resolves this by keeping two representations, in two
folders:2
| Folder | Types | Job |
|---|---|---|
domain/model/ | pgtype.* fields + TableName(), FieldMap(), Add() | DB-facing: scan rows, build SQL. Stays near the adapter. |
domain/dto/ | plain Go types (string, time.Time, …) | the application boundary: what handlers and mappers pass around. |
A util/mapper converts between them (and to/from proto and the SendGrid payload).
So the driver types are quarantined at the edge in model, and the logic in the middle
speaks dto. That's the same purity goal as eureka's "no pgtype in domain" — spike just
achieves it with a model/dto split instead of a single pure struct.
dto down in the postgres
package. spike: a model (pgtype) / dto (plain) split inside
domain/. Different folders, same principle — keep driver types out of the
part that holds business logic. Don't let the folder names distract you from the rule.
The smell: when pgtype escapes into the logic
Now the failure mode — and it's real, in a service you own. notification's own module-quality
table flags that its announcement module leaks pgtype into
usecase signatures:3
pgtype.Text, every caller — the gRPC
transport, a test, a Kafka consumer — has to construct driver types to call your business logic.
The coupling you tried to push to the edge has climbed back into the middle. The fix is always
the same: the usecase speaks domain/dto; the adapter alone speaks pgtype; a mapper
bridges them. When you see pgtype above the repository layer, that's the smell.
Clean Architecture in Go — the layers
Three Dots Labs on keeping the domain independent of frameworks and drivers — the same boundary, argued for idiomatic Go.
→ threedots.tech — Clean Architecture in Go
→ in-repo internal/spike/modules/email/domain/ (model & dto),
internal/notification/CLAUDE.md (module-quality table)
Check yourself (from memory)
Q1. Why should the domain avoid pgtype fields?
Q2. In spike, what's in domain/model vs domain/dto?
model = pgtype + TableName/FieldMap, near
the adapter; dto = plain Go at the app boundary. A mapper bridges them.
Q3. A usecase method takes entity.X with pgtype fields. Verdict?
announcement smell: pgtype in
a usecase signature forces every caller to build driver types. Usecases should speak dto.
domain/model (pgtype + TableName/FieldMap/Add, DB-facing) vs
domain/dto (plain Go, app boundary); a util/mapper bridges. Same goal as
eureka's pure domain, different shape. The smell: notification
announcement leaks pgtype into usecase signatures → every
caller must build driver types → coupling climbs back into the core. Fix: usecase speaks dto;
only the adapter speaks pgtype.1. In-repo: internal/eureka/CLAUDE.md ("domain/ … only Go stdlib types, no pgtype").
2. In-repo: internal/spike/CLAUDE.md ("domain/model … pgtype DB entities" / "domain/dto … plain Go DTOs").
3. In-repo: internal/notification/CLAUDE.md (module-quality table — announcement "note: pgtype leaks into usecase signatures").