Skip to content

Day 127 — Hexagonal Architecture (Ports & Adapters)

Month 5 · Week 3 · ⬅ Day 126 · Day 128 ➡ · Journal index

🎯 Learning Objective

Structure a service so the business core has zero infrastructure imports, by defining ports (interfaces) the core owns and adapters that implement them — making dependencies point inward.

📚 Topics

  • Hexagonal / ports & adapters; the dependency rule (arrows point toward the core)
  • Inbound (driving) vs outbound (driven) ports; where to define the interface
  • The composition root; compile-time interface assertions

📖 Reading / Sources

📝 Notes

  • The one rule: dependencies point inward. The domain core (entities + use cases) imports nothing from the outside; adapters import the core. Invert the dependency with an interface → [[dependency-inversion]].
  • A port is an interface the core declares. Inbound/driving ports are the use cases the core exposes (called by HTTP/CLI/gRPC adapters). Outbound/driven ports are what the core requires (a Repository, a Mailer) → [[ports]].
  • Define the interface next to its consumer, not next to the implementation. In Go the consumer package owns the port; the adapter package merely satisfies it. This is the opposite of Java's "interface lives with the impl" → [[accept-interfaces]].
  • Keep ports small — one or two methods. Go interfaces are satisfied structurally, so a narrow port is easy to fake and easy to swap → [[interface-segregation]].
  • The composition root (usually main) is the only place that names concrete adapters and wires them into the core. Swapping memory→Postgres is a one-line change there → [[composition-root]].
  • Return domain sentinels (ErrNotFound) from ports so callers branch on meaning, not on adapter-specific text; wrap with %w so errors.Is still matches → [[error-wrapping]].
  • A var _ Port = (*Adapter)(nil) line is a free compile-time contract check — grow the interface and the adapter fails to build.

💻 Code Examples

// OUTBOUND PORT — declared in the core, next to the service that needs it.
type AccountRepository interface {
    Find(id string) (*Account, error) // returns ErrNotFound when absent
    Save(a *Account) error
}

// CORE service depends on the PORT, injected via its constructor.
type Ledger struct{ repo AccountRepository }

func NewLedger(repo AccountRepository) *Ledger { return &Ledger{repo: repo} }

// ADAPTER (outside the hexagon) satisfies the port; imports the core, not vice-versa.
var _ AccountRepository = (*memRepo)(nil)

Full runnable model (stdlib only): examples/month-05/hexagonal/main.go · Run: go run ./examples/month-05/hexagonal

🏋️ Exercises / Practice

Exercise Status Link
Repository port + in-memory adapter + service exercises/month-05/week-3/ports

🐛 Mistakes Made

  • First put the Repository interface in the adapter package → created an import cycle when the core needed it. Moved the interface to the core (its consumer).
  • Returned the stored *Account pointer directly from the in-memory adapter; a caller mutated it and corrupted state. Now I return a copy.

❓ Open Questions

  • Where exactly does validation live — entity constructor, service, or both? (Leaning: invariants in the entity, orchestration rules in the service.)

🧠 Active Recall (answer without looking)

  1. Q: In Go, which package should declare a repository interface — the one that uses it or the one that implements it?
AThe package that *uses* it (the consumer/core). Go interfaces are satisfied implicitly, so the core declares the port it needs and the adapter just implements it — keeping the dependency arrow pointing inward, with no import cycle.
  1. Q: What does "dependencies point inward" mean for imports?
AThe domain core imports nothing from infrastructure; adapters (DB, HTTP, cache) import the core. The core is dependency-free and unit-testable; you swap adapters at the composition root without touching it.

🪶 Feynman Reflection

Picture the business logic in the middle of a hexagon. Each edge is a hole — a port — shaped like an interface. Outside the wall, adapters plug into those holes: one for the database, one for HTTP, one for tests. The core never knows what's plugged in; it only knows the hole's shape. So I can yank the Postgres adapter out and push a fake in, and the core can't tell the difference — which is exactly why it's testable.

🕳️ Knowledge Gaps

  • Modeling transactions across two repository calls without leaking SQL into the core (unit-of-work pattern).

✅ Summary

I can lay out a service as a dependency-free core with small ports it owns, plug adapters in at a composition root, and prove the contract with a compile-time assertion.

⏭️ Next Steps / Prep for Tomorrow

  • Day 128: wire that object graph by hand — manual dependency injection.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): hexagonal ports & adapters core + adapter (day 127)