Day 106 — Project: Layered Architecture¶
Month 4 · Week 4 · ⬅ Day 105 · Day 107 ➡ · Journal index
🎯 Learning Objective¶
Lay out the Week 4 capstone — a Users/Notes REST service — as clean layers (transport → service → repository → domain) whose dependencies all point inward, so each layer is swappable and testable in isolation.
📚 Topics¶
- Layered (hexagonal-lite) architecture: handler, service, repository, domain
- Dependency inversion: interfaces owned by their consumer
- Mapping domain errors to HTTP status in exactly one place
- Idiomatic Go project layout (
cmd/,internal/)
📖 Reading / Sources¶
- Organizing a Go module (go.dev)
- Effective Go — interfaces
- Standard Go Project Layout (golang-standards) — read critically; it is a convention, not a Go team mandate
- Bill Kennedy, Ardan Labs — package-oriented design
📝 Notes¶
- A layer depends only on the layer beneath it, and only through an interface that the upper layer declares. The concrete database type implements that interface but is never imported by the business logic → [[dependency-inversion]].
- Direction of knowledge: transport knows the service; the service knows the repo interface; nobody below knows about
net/http. The handler is the only file that importsnet/http; the repository is the only file that importsdatabase/sql→ [[separation-of-concerns]]. - The domain package (types + sentinel errors) is the shared vocabulary every layer translates to/from. Keep it dependency-free.
- "Accept interfaces, return structs": the service accepts a
Repointerface and returns concrete domain structs. The interface lives next to the service (consumer), not next to the database (producer) → [[accept-interfaces-return-structs]]. - Error translation belongs at the boundary: a single
statusFor(err)mapsErrNotFound→404,ErrConflict→409,ErrInvalid→400. The service stays transport-agnostic → [[error-wrapping]]. cmd/api/main.godoes the wiring (construct repo, inject into service, mount handler).internal/holds packages the outside world can't import. Wiring bottom-up means swappingmemRepofor a Postgres repo changes one line.- Avoid one giant
package main: small packages with clear ports are what make the integration tests (Day 109) and the OpenAPI contract (Day 110) tractable.
💻 Code Examples¶
// The service depends on an interface it owns — not on *sql.DB.
type Repo interface {
Create(ctx context.Context, email string) (User, error)
Get(ctx context.Context, id int) (User, error)
}
type Service struct{ repo Repo } // inject any adapter (memory, Postgres, fake)
func (s *Service) Register(ctx context.Context, email string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" || !strings.Contains(email, "@") {
return User{}, fmt.Errorf("register: %w", ErrInvalid)
}
return s.repo.Create(ctx, email)
}
Full code:
examples/month-04/layered/main.go· Run:go run ./examples/month-04/layered
🏋️ Exercises / Practice¶
| Exercise | Status | Link |
|---|---|---|
| Service layer over a fake repo | ✅ | exercises/month-04/week-4/service |
| Wire handler→service→repo and demo with httptest | ✅ | examples/month-04/layered |
🐛 Mistakes Made¶
- First draft put the
Repointerface in the database package — that forces the service to import the DB package and defeats inversion. Moved it next to the service. - Returned a pointer into the in-memory map from
Get; callers could then mutate "the database". Returned a copy instead.
❓ Open Questions¶
- How big should
internal/get before splitting into sub-packages by feature vs by layer? (Leaning feature-first once there are 3+ resources.)
🧠 Active Recall (answer without looking)¶
- Q: In Go, which side of a dependency should declare the interface, and why?
A
The **consumer** (e.g. the service) declares it. That keeps the producer (database) swappable and avoids forcing the consumer to import the concrete package — Go's "accept interfaces, return structs".- Q: Where should an
ErrNotFoundbecome a404?
A
At the transport boundary, in one mapping function (`statusFor`). The service returns only domain errors so it stays usable from HTTP, gRPC, or a CLI.🪶 Feynman Reflection¶
Think of the app as onion rings. The core is the domain (plain types, no I/O). The next ring is business rules (the service), which only knows an abstract "storage" shape. The outer ring is the database adapter and the HTTP handler — the messy, replaceable parts. Arrows only point inward, so I can yank out Postgres or swap HTTP for gRPC without touching the core.
🕳️ Knowledge Gaps¶
- When DI frameworks (
wire,fx) pay off vs. plain constructor wiring — revisit, but plain wiring is fine at this size.
✅ Summary¶
I scaffolded the capstone as inward-pointing layers with consumer-owned interfaces, a dependency-free domain, and a single error-to-status mapping point.
⏭️ Next Steps / Prep for Tomorrow¶
- Day 107: flesh out the handlers and service methods with validation and JSON encode/decode helpers.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(project): layered architecture skeleton (day 106)