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

internal/eureka/v2/modules/{module}/domain/ — the rule
// 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

FolderTypesJob
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.

Two valid styles, one goal eureka: one pure domain struct, with a separate DB 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

GOOD usecase.Create(ctx, db, dto.Announcement{ Title: "x", ... }) ← plain Go LEAK usecase.Create(ctx, db, entity.Announcement{ Title: pgtype.Text{...} }) ← driver type in the core
Why the leak is a problem Once a usecase method's signature contains 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.
Read this next

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?

Driver types in the domain couple your logic to postgres — you can't test or reuse it cleanly. Purity keeps the hexagon's centre independent.

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?

That's the announcement smell: pgtype in a usecase signature forces every caller to build driver types. Usecases should speak dto.
Recall: the domain layer, the model/dto split, and the pgtype leak.
the ideal + the split + the smell, then reveal
Ideal: the domain is the part you'd keep if you deleted the DB — eureka's rule is pure Go, no pgtype, so logic isn't coupled to the driver (testable, reusable). The tension: scanning rows wants pgtype. spike's fix: 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.
🎯 Interview one-liner "How do you keep the database out of your domain?" → "Domain types are plain Go — no driver types. Driver-specific structs live only in the adapter, and a mapper converts at the boundary. If a database type like a nullable-string wrapper shows up in a usecase signature, that's a leak I'd refactor: it forces every caller to depend on the driver."
Next: Part 1's payoff — spot the leak. We'll find a real dependency-inversion break in conversationmgmt and you'll learn to read your own hexagon critically. Ask me if the model/dto split still feels like duplication (it's a deliberate trade).

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").