Skip to content

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

📝 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 imports net/http; the repository is the only file that imports database/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 Repo interface 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) maps ErrNotFound→404, ErrConflict→409, ErrInvalid→400. The service stays transport-agnostic → [[error-wrapping]].
  • cmd/api/main.go does the wiring (construct repo, inject into service, mount handler). internal/ holds packages the outside world can't import. Wiring bottom-up means swapping memRepo for 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 Repo interface 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)

  1. 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".
  1. Q: Where should an ErrNotFound become a 404?
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)